From d3ab4d1e776caa527edd64d022da7551f507ac9a Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:20:35 -0600 Subject: [PATCH 1/5] feat(ui): add toasts, payment confirmation, and queue auto-triage --- ui/src/components/dashboard/FocusView.tsx | 2 +- .../dashboard/ObligationsWidget.tsx | 2 +- ui/src/components/ui/ConfirmDialog.tsx | 78 +++++++++++ ui/src/components/ui/Toast.tsx | 79 ++++++++++++ ui/src/lib/toast.tsx | 122 ++++++++++++++++++ ui/src/main.tsx | 41 +++--- ui/src/pages/ActionQueue.tsx | 87 ++++++++++--- ui/src/pages/Dashboard.tsx | 48 ++++++- 8 files changed, 415 insertions(+), 44 deletions(-) create mode 100644 ui/src/components/ui/ConfirmDialog.tsx create mode 100644 ui/src/components/ui/Toast.tsx create mode 100644 ui/src/lib/toast.tsx diff --git a/ui/src/components/dashboard/FocusView.tsx b/ui/src/components/dashboard/FocusView.tsx index c9490e8..216bef0 100644 --- a/ui/src/components/dashboard/FocusView.tsx +++ b/ui/src/components/dashboard/FocusView.tsx @@ -40,7 +40,7 @@ export function FocusView({ data, onPayNow, onExecute, payingId, executingId }: : `Due ${formatDate(ob.due_date)}`, metric: formatCurrency(ob.amount_due), action: { - label: 'Pay Now', + label: 'Mark Paid', onClick: () => onPayNow(ob), loading: payingId === ob.id, }, diff --git a/ui/src/components/dashboard/ObligationsWidget.tsx b/ui/src/components/dashboard/ObligationsWidget.tsx index bacf3a1..8ed6924 100644 --- a/ui/src/components/dashboard/ObligationsWidget.tsx +++ b/ui/src/components/dashboard/ObligationsWidget.tsx @@ -43,7 +43,7 @@ export function ObligationsWidget({ obligations, onPayNow, payingId }: Props) { disabled={payingId === ob.id} className="px-3 py-1 text-xs font-medium bg-chitty-600 text-white rounded-lg hover:bg-chitty-700 disabled:opacity-50" > - {payingId === ob.id ? '...' : 'Pay'} + {payingId === ob.id ? '...' : 'Mark Paid'} )} diff --git a/ui/src/components/ui/ConfirmDialog.tsx b/ui/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..3a5bd49 --- /dev/null +++ b/ui/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { ActionButton } from './ActionButton'; + +interface ConfirmDialogProps { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'primary' | 'danger'; + loading?: boolean; + onConfirm: () => void | Promise; + onCancel: () => void; +} + +export function ConfirmDialog({ + open, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + variant = 'primary', + loading = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + useEffect(() => { + if (!open) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !loading) { + onCancel(); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [loading, onCancel, open]); + + if (!open || typeof document === 'undefined') return null; + + return createPortal( +
+
!loading && onCancel()} + /> +
+

+ {title} +

+

{message}

+
+ + +
+
+
, + document.body, + ); +} + diff --git a/ui/src/components/ui/Toast.tsx b/ui/src/components/ui/Toast.tsx new file mode 100644 index 0000000..b9cda0d --- /dev/null +++ b/ui/src/components/ui/Toast.tsx @@ -0,0 +1,79 @@ +import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import type { ToastItem } from '../../lib/toast'; + +interface ToastViewportProps { + toasts: ToastItem[]; + onDismiss: (id: string) => void; +} + +const toastVariants: Record = { + success: { + icon: CheckCircle2, + className: 'border-emerald-200 bg-emerald-50 text-emerald-900', + }, + error: { + icon: AlertCircle, + className: 'border-rose-200 bg-rose-50 text-rose-900', + }, + info: { + icon: Info, + className: 'border-indigo-200 bg-indigo-50 text-indigo-900', + }, +}; + +export function ToastViewport({ toasts, onDismiss }: ToastViewportProps) { + if (toasts.length === 0) return null; + + return ( +
+
+ {toasts.map((toast) => { + const variant = toastVariants[toast.variant]; + const Icon = variant.icon; + + return ( +
+
+ +
+

{toast.title}

+ {toast.description && ( +

{toast.description}

+ )} + {toast.actionLabel && toast.onAction && ( + + )} +
+ +
+
+ ); + })} +
+
+ ); +} + diff --git a/ui/src/lib/toast.tsx b/ui/src/lib/toast.tsx new file mode 100644 index 0000000..e88a08d --- /dev/null +++ b/ui/src/lib/toast.tsx @@ -0,0 +1,122 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { ToastViewport } from '../components/ui/Toast'; + +export type ToastVariant = 'success' | 'error' | 'info'; + +export interface ToastInput { + title: string; + description?: string; + variant?: ToastVariant; + durationMs?: number; + actionLabel?: string; + onAction?: () => void; +} + +export interface ToastItem extends ToastInput { + id: string; + variant: ToastVariant; + durationMs: number; +} + +interface ToastContextType { + showToast: (toast: ToastInput) => void; + dismissToast: (id: string) => void; + success: (title: string, description?: string, options?: Omit) => void; + error: (title: string, description?: string, options?: Omit) => void; + info: (title: string, description?: string, options?: Omit) => void; +} + +const ToastContext = createContext(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const fallbackCounter = useRef(0); + const timers = useRef>>(new Map()); + + const dismissToast = useCallback((id: string) => { + const timer = timers.current.get(id); + if (timer) { + clearTimeout(timer); + timers.current.delete(id); + } + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const showToast = useCallback((toast: ToastInput) => { + const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${fallbackCounter.current++}`; + + const nextToast: ToastItem = { + ...toast, + id, + variant: toast.variant || 'info', + durationMs: toast.durationMs ?? 5000, + }; + + setToasts((prev) => [...prev.slice(-4), nextToast]); + + if (nextToast.durationMs > 0) { + const timer = setTimeout(() => { + dismissToast(id); + }, nextToast.durationMs); + timers.current.set(id, timer); + } + }, [dismissToast]); + + useEffect(() => () => { + for (const timer of timers.current.values()) { + clearTimeout(timer); + } + timers.current.clear(); + }, []); + + const success = useCallback( + (title: string, description?: string, options?: Omit) => { + showToast({ title, description, variant: 'success', ...options }); + }, + [showToast], + ); + + const error = useCallback( + (title: string, description?: string, options?: Omit) => { + showToast({ title, description, variant: 'error', ...options }); + }, + [showToast], + ); + + const info = useCallback( + (title: string, description?: string, options?: Omit) => { + showToast({ title, description, variant: 'info', ...options }); + }, + [showToast], + ); + + const value = useMemo( + () => ({ showToast, dismissToast, success, error, info }), + [dismissToast, error, info, showToast, success], + ); + + return ( + + {children} + + + ); +} + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} + diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 316dbb9..61f290c 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -15,6 +15,7 @@ import { ActionQueue } from './pages/ActionQueue'; import { Login } from './pages/Login'; import { isAuthenticated } from './lib/auth'; import { FocusModeProvider } from './lib/focus-mode'; +import { ToastProvider } from './lib/toast'; import './index.css'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -26,24 +27,26 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { ReactDOM.createRoot(document.getElementById('root')!).render( - - - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + , ); diff --git a/ui/src/pages/ActionQueue.tsx b/ui/src/pages/ActionQueue.tsx index 32c818c..18b7d83 100644 --- a/ui/src/pages/ActionQueue.tsx +++ b/ui/src/pages/ActionQueue.tsx @@ -6,6 +6,7 @@ import { DesktopControls } from '../components/swipe/DesktopControls'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { ActionButton } from '../components/ui/ActionButton'; import { Card } from '../components/ui/Card'; +import { useToast } from '../lib/toast'; export function ActionQueue() { const [items, setItems] = useState([]); @@ -14,17 +15,63 @@ export function ActionQueue() { const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); const sessionId = useRef(crypto.randomUUID()); + const autoTriageAttempted = useRef(false); + const toast = useToast(); - const loadQueue = useCallback(async () => { + const runTriage = useCallback(async (auto = false) => { + setGenerating(true); + setError(null); + + if (auto) { + toast.info('Queue is empty', 'Running triage automatically...'); + } + + try { + const result = await api.generateRecommendations(); + const created = result.recommendations_created; + if (created > 0) { + toast.success( + auto ? 'Queue ready' : 'Triage complete', + `Created ${created} recommendation${created === 1 ? '' : 's'}.`, + { durationMs: 2500 }, + ); + } else { + toast.info('No new recommendations', 'Everything is already up to date.', { durationMs: 2500 }); + } + return true; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Triage failed'; + setError(message); + toast.error('Triage failed', message); + return false; + } finally { + setGenerating(false); + } + }, [toast]); + + const loadQueue = useCallback(async (allowAutoTriage = true) => { try { const data = await api.getQueue(10); + + if (data.length === 0 && allowAutoTriage && !autoTriageAttempted.current) { + autoTriageAttempted.current = true; + const generated = await runTriage(true); + if (generated) { + const refreshed = await api.getQueue(10); + setItems(refreshed); + return; + } + } + setItems(data); } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Failed to load queue'); + const message = e instanceof Error ? e.message : 'Failed to load queue'; + setError(message); + toast.error('Could not load queue', message); } finally { setLoading(false); } - }, []); + }, [runTriage, toast]); const loadStats = useCallback(async () => { try { @@ -42,26 +89,34 @@ export function ActionQueue() { const handleDecide = useCallback(async (id: string, decision: 'approved' | 'rejected' | 'deferred') => { try { + const current = items.find((item) => item.id === id); await api.decideQueue(id, decision, sessionId.current); setItems((prev) => prev.filter((item) => item.id !== id)); loadStats(); + + if (current) { + const title = current.title.length > 60 ? `${current.title.slice(0, 57)}...` : current.title; + if (decision === 'approved') { + toast.success('Approved', title, { durationMs: 2000 }); + } else if (decision === 'rejected') { + toast.info('Rejected', title, { durationMs: 2000 }); + } else { + toast.info('Deferred', title, { durationMs: 2000 }); + } + } } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Decision failed'); + const message = e instanceof Error ? e.message : 'Decision failed'; + setError(message); + toast.error('Decision failed', message); } - }, [loadStats]); + }, [items, loadStats, toast]); const handleGenerate = useCallback(async () => { - setGenerating(true); - setError(null); - try { - await api.generateRecommendations(); - await loadQueue(); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Triage failed'); - } finally { - setGenerating(false); + const generated = await runTriage(false); + if (generated) { + await loadQueue(false); } - }, [loadQueue]); + }, [loadQueue, runTriage]); // Keyboard shortcuts for desktop const currentItem = items[0]; @@ -108,7 +163,7 @@ export function ActionQueue() { loadQueue(false)} /> {/* Desktop controls */} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 9ab55b4..dc34f9a 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -3,13 +3,18 @@ import { api, type DashboardData, type Obligation, type Recommendation } from '. import { useFocusMode } from '../lib/focus-mode'; import { FocusView } from '../components/dashboard/FocusView'; import { FullView } from '../components/dashboard/FullView'; +import { ConfirmDialog } from '../components/ui/ConfirmDialog'; +import { useToast } from '../lib/toast'; +import { formatCurrency } from '../lib/utils'; export function Dashboard() { const [data, setData] = useState(null); const [error, setError] = useState(null); const [payingId, setPayingId] = useState(null); const [executingId, setExecutingId] = useState(null); + const [pendingPayment, setPendingPayment] = useState(null); const { focusMode } = useFocusMode(); + const toast = useToast(); const reload = useCallback(() => { api.getDashboard().then(setData).catch((e) => setError(e.message)); @@ -17,18 +22,31 @@ export function Dashboard() { useEffect(() => { reload(); }, [reload]); - const handlePayNow = async (ob: Obligation) => { + const requestPayNow = useCallback((ob: Obligation) => { if (payingId) return; - setPayingId(ob.id); + setPendingPayment(ob); + }, [payingId]); + + const handleConfirmPayNow = useCallback(async () => { + if (!pendingPayment || payingId) return; + setPayingId(pendingPayment.id); try { - await api.markPaid(ob.id); + await api.markPaid(pendingPayment.id); + toast.success( + 'Marked as paid', + `${pendingPayment.payee}: ${formatCurrency(pendingPayment.amount_due)}`, + { durationMs: 2500 }, + ); + setPendingPayment(null); reload(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Payment failed'); + const message = e instanceof Error ? e.message : 'Payment failed'; + setError(message); + toast.error('Could not mark paid', message); } finally { setPayingId(null); } - }; + }, [payingId, pendingPayment, reload, toast]); const handleExecute = async (rec: Recommendation) => { if (executingId) return; @@ -56,7 +74,23 @@ export function Dashboard() { return
Loading...
; } - const viewProps = { data, onPayNow: handlePayNow, onExecute: handleExecute, payingId, executingId }; + const viewProps = { data, onPayNow: requestPayNow, onExecute: handleExecute, payingId, executingId }; - return focusMode ? : ; + return ( + <> + {focusMode ? : } + setPendingPayment(null)} + /> + + ); } From df1b3099c2651508e3f7b624333e8283dcdd1d43 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:20:44 -0600 Subject: [PATCH 2/5] docs(plan): restate adversarial review with corrected findings --- .../2026-03-05-adversarial-review-restated.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/plans/2026-03-05-adversarial-review-restated.md diff --git a/docs/plans/2026-03-05-adversarial-review-restated.md b/docs/plans/2026-03-05-adversarial-review-restated.md new file mode 100644 index 0000000..391e602 --- /dev/null +++ b/docs/plans/2026-03-05-adversarial-review-restated.md @@ -0,0 +1,83 @@ +# ChittyCommand Adversarial Review (Restated) + +**Date:** 2026-03-05 +**Status:** Canonical correction to prior analysis + +## Corrections to Prior Claims + +1. Queue approval does execute a DB payment state change today. +- `src/routes/swipe-queue.ts` sets linked obligation `status='paid'` on approved payment-type actions. +- It bypasses the explicit `/obligations/:id/pay` path, but it is still a real mutation. + +2. Dispute "Take Action" is not a same-page no-op from Dashboard context. +- In Focus Mode it navigates from `/` to `/disputes`. +- Real issue: it does not deep-link to a specific dispute/action target. + +## Verified Findings + +1. Dispute progress dots are currently synthetic and not backed by lifecycle-stage storage. +2. Recommendations page duplicates Action Queue capabilities. +3. Payment plan activation updates DB status only; no downstream execution artifacts are created. +4. Cash Flow tabs are disconnected data views without synthesis. +5. Upload flows cannot explicitly set `linked_dispute_id` from UI. +6. Legal and Disputes are not cross-linked via `case_ref`. +7. Chat sidebar content is in-memory and lost on refresh. +8. Queue does not auto-run triage on empty first-load. +9. Dashboard pay action had no confirmation. +10. No frontend dispute creation form despite backend POST support. + +## Plan Flaws Identified + +1. Double-write risk in queue approval flow (`decideQueue` plus `markPaid`). +2. `case_ref` top-level proposal conflicts with existing `metadata.ledger_case_id`. +3. Stage/status semantics were undefined. +4. Chat context expansion added heavy per-message query cost with no cache strategy. +5. Replacing Cash Flow tabs entirely was high-risk relative to value. +6. Chat message action buttons did not account for SSE stream completion boundaries. +7. No automated test coverage was defined for large-scope financial-flow changes. + +## Required Fixes + +1. Remove payment side-effect from queue decide endpoint; keep payment execution explicit and single-path. +2. Promote `metadata.ledger_case_id` into a first-class column (or migration path), not parallel sources of truth. +3. Define lifecycle contract: stage = position, status = resolution outcome with terminal transitions only. +4. Lazy-load heavy dispute verification context only for targeted prompts/actions. +5. Keep Cash Flow tabs and add synthesis strip first. +6. Render chat action buttons only after SSE stream completion (`[DONE]`). +7. Gate each phase with integration tests before merge. + +## Priority Order + +### P0 (ship first) +- Toast system + ConfirmDialog +- Dashboard pay confirmation +- Auto-triage on empty queue + +### P1 +- Remove queue payment double-write path and add explicit execution path +- Swipe decision feedback toasts + +### P2 +- Real dispute lifecycle model (stage column + stage/status rules) +- Dispute creation form +- Progress dots from DB state +- Upload documents from dispute context + +### P3 +- Legal ↔ Disputes cross-linking +- Payment plan outputs into queue items +- Cash Flow synthesis strip while preserving tabs + +### P4 +- Chat persistence and streaming-aware insight actions +- Chat-to-correspondence bridge +- Sidebar information architecture cleanup +- Remove redundant Recommendations page + +## Current Execution Snapshot (this pass) + +- Implemented P0 foundations in UI: + - Added global toast system and provider. + - Added reusable confirmation dialog. + - Added Dashboard pay confirmation and feedback toasts. + - Added Action Queue auto-triage on first empty load and decision feedback toasts. From 2e697d3b3416579914b81a4b75489c904f71d0d7 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sat, 7 Mar 2026 18:54:18 +0000 Subject: [PATCH 3/5] Initial plan From c3210bb9c069120393acd38e6e8264fa956fb199 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sat, 7 Mar 2026 18:58:36 +0000 Subject: [PATCH 4/5] fix: clean toast timers and dialog accessibility --- ui/src/components/ui/ActionButton.tsx | 25 ++++++++--- ui/src/components/ui/ConfirmDialog.tsx | 62 +++++++++++++++++++++++--- ui/src/components/ui/Toast.tsx | 3 +- ui/src/lib/toast.tsx | 21 ++++++++- 4 files changed, 96 insertions(+), 15 deletions(-) diff --git a/ui/src/components/ui/ActionButton.tsx b/ui/src/components/ui/ActionButton.tsx index 5b5f4d7..f94e235 100644 --- a/ui/src/components/ui/ActionButton.tsx +++ b/ui/src/components/ui/ActionButton.tsx @@ -1,3 +1,4 @@ +import { forwardRef } from 'react'; import { cn } from '../../lib/utils'; interface ActionButtonProps { @@ -7,18 +8,30 @@ interface ActionButtonProps { loading?: boolean; disabled?: boolean; className?: string; + type?: 'button' | 'submit' | 'reset'; + autoFocus?: boolean; } -export function ActionButton({ label, onClick, variant = 'primary', loading, disabled, className }: ActionButtonProps) { - const base = 'px-4 py-2 text-sm font-semibold rounded-xl transition-all duration-200 disabled:opacity-50 focus-ring'; +export const ActionButton = forwardRef(function ActionButton( + { label, onClick, variant = 'primary', loading, disabled, className, type = 'button', autoFocus }, + ref, +) { + const base = + 'px-4 py-2 text-sm font-semibold rounded-xl transition-all duration-200 disabled:opacity-50 focus-ring'; const variants = { - primary: 'bg-gradient-to-b from-chitty-500 to-chitty-600 text-white hover:from-chitty-400 hover:to-chitty-500 shadow-sm hover:shadow-glow-brand active:scale-[0.97]', - secondary: 'bg-card-hover text-card-text border border-card-border hover:border-chitty-300 hover:bg-white active:scale-[0.97]', - danger: 'bg-gradient-to-b from-urgency-red to-rose-600 text-white hover:from-rose-400 hover:to-rose-500 shadow-sm hover:shadow-glow-danger active:scale-[0.97]', + primary: + 'bg-gradient-to-b from-chitty-500 to-chitty-600 text-white hover:from-chitty-400 hover:to-chitty-500 shadow-sm hover:shadow-glow-brand active:scale-[0.97]', + secondary: + 'bg-card-hover text-card-text border border-card-border hover:border-chitty-300 hover:bg-white active:scale-[0.97]', + danger: + 'bg-gradient-to-b from-urgency-red to-rose-600 text-white hover:from-rose-400 hover:to-rose-500 shadow-sm hover:shadow-glow-danger active:scale-[0.97]', }; return (