Skip to content

Commit d4d1548

Browse files
authored
feat(ui): add toast feedback, payment confirm dialog, and queue auto-triage (#21)
* feat(ui): add toasts, payment confirmation, and queue auto-triage * docs(plan): restate adversarial review with corrected findings
1 parent 74493fe commit d4d1548

12 files changed

Lines changed: 590 additions & 55 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# ChittyCommand Adversarial Review (Restated)
2+
3+
**Date:** 2026-03-05
4+
**Status:** Canonical correction to prior analysis
5+
6+
## Corrections to Prior Claims
7+
8+
1. Queue approval does execute a DB payment state change today.
9+
- `src/routes/swipe-queue.ts` sets linked obligation `status='paid'` on approved payment-type actions.
10+
- It bypasses the explicit `/obligations/:id/pay` path, but it is still a real mutation.
11+
12+
2. Dispute "Take Action" is not a same-page no-op from Dashboard context.
13+
- In Focus Mode it navigates from `/` to `/disputes`.
14+
- Real issue: it does not deep-link to a specific dispute/action target.
15+
16+
## Verified Findings
17+
18+
1. Dispute progress dots are currently synthetic and not backed by lifecycle-stage storage.
19+
2. Recommendations page duplicates Action Queue capabilities.
20+
3. Payment plan activation updates DB status only; no downstream execution artifacts are created.
21+
4. Cash Flow tabs are disconnected data views without synthesis.
22+
5. Upload flows cannot explicitly set `linked_dispute_id` from UI.
23+
6. Legal and Disputes are not cross-linked via `case_ref`.
24+
7. Chat sidebar content is in-memory and lost on refresh.
25+
8. Queue does not auto-run triage on empty first-load.
26+
9. Dashboard pay action had no confirmation.
27+
10. No frontend dispute creation form despite backend POST support.
28+
29+
## Plan Flaws Identified
30+
31+
1. Double-write risk in queue approval flow (`decideQueue` plus `markPaid`).
32+
2. `case_ref` top-level proposal conflicts with existing `metadata.ledger_case_id`.
33+
3. Stage/status semantics were undefined.
34+
4. Chat context expansion added heavy per-message query cost with no cache strategy.
35+
5. Replacing Cash Flow tabs entirely was high-risk relative to value.
36+
6. Chat message action buttons did not account for SSE stream completion boundaries.
37+
7. No automated test coverage was defined for large-scope financial-flow changes.
38+
39+
## Required Fixes
40+
41+
1. Remove payment side-effect from queue decide endpoint; keep payment execution explicit and single-path.
42+
2. Promote `metadata.ledger_case_id` into a first-class column (or migration path), not parallel sources of truth.
43+
3. Define lifecycle contract: stage = position, status = resolution outcome with terminal transitions only.
44+
4. Lazy-load heavy dispute verification context only for targeted prompts/actions.
45+
5. Keep Cash Flow tabs and add synthesis strip first.
46+
6. Render chat action buttons only after SSE stream completion (`[DONE]`).
47+
7. Gate each phase with integration tests before merge.
48+
49+
## Priority Order
50+
51+
### P0 (ship first)
52+
- Toast system + ConfirmDialog
53+
- Dashboard pay confirmation
54+
- Auto-triage on empty queue
55+
56+
### P1
57+
- Remove queue payment double-write path and add explicit execution path
58+
- Swipe decision feedback toasts
59+
60+
### P2
61+
- Real dispute lifecycle model (stage column + stage/status rules)
62+
- Dispute creation form
63+
- Progress dots from DB state
64+
- Upload documents from dispute context
65+
66+
### P3
67+
- Legal ↔ Disputes cross-linking
68+
- Payment plan outputs into queue items
69+
- Cash Flow synthesis strip while preserving tabs
70+
71+
### P4
72+
- Chat persistence and streaming-aware insight actions
73+
- Chat-to-correspondence bridge
74+
- Sidebar information architecture cleanup
75+
- Remove redundant Recommendations page
76+
77+
## Current Execution Snapshot (this pass)
78+
79+
- Implemented P0 foundations in UI:
80+
- Added global toast system and provider.
81+
- Added reusable confirmation dialog.
82+
- Added Dashboard pay confirmation and feedback toasts.
83+
- Added Action Queue auto-triage on first empty load and decision feedback toasts.

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@hono/zod-validator": "^0.7.6",
2525
"@neondatabase/serverless": "^0.10.0",
2626
"drizzle-orm": "^0.33.0",
27-
"hono": "^4.4.0",
27+
"hono": "^4.12.5",
2828
"zod": "^3.23.0"
2929
},
3030
"devDependencies": {

ui/src/components/dashboard/FocusView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function FocusView({ data, onPayNow, onExecute, payingId, executingId }:
4040
: `Due ${formatDate(ob.due_date)}`,
4141
metric: formatCurrency(ob.amount_due),
4242
action: {
43-
label: 'Pay Now',
43+
label: 'Mark Paid',
4444
onClick: () => onPayNow(ob),
4545
loading: payingId === ob.id,
4646
},

ui/src/components/dashboard/ObligationsWidget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function ObligationsWidget({ obligations, onPayNow, payingId }: Props) {
4343
disabled={payingId === ob.id}
4444
className="px-3 py-1 text-xs font-medium bg-chitty-600 text-white rounded-lg hover:bg-chitty-700 disabled:opacity-50"
4545
>
46-
{payingId === ob.id ? '...' : 'Pay'}
46+
{payingId === ob.id ? '...' : 'Mark Paid'}
4747
</button>
4848
)}
4949
</div>

ui/src/components/ui/ActionButton.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { forwardRef } from 'react';
12
import { cn } from '../../lib/utils';
23

34
interface ActionButtonProps {
@@ -7,18 +8,30 @@ interface ActionButtonProps {
78
loading?: boolean;
89
disabled?: boolean;
910
className?: string;
11+
type?: 'button' | 'submit' | 'reset';
12+
autoFocus?: boolean;
1013
}
1114

12-
export function ActionButton({ label, onClick, variant = 'primary', loading, disabled, className }: ActionButtonProps) {
13-
const base = 'px-4 py-2 text-sm font-semibold rounded-xl transition-all duration-200 disabled:opacity-50 focus-ring';
15+
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>(function ActionButton(
16+
{ label, onClick, variant = 'primary', loading, disabled, className, type = 'button', autoFocus },
17+
ref,
18+
) {
19+
const base =
20+
'px-4 py-2 text-sm font-semibold rounded-xl transition-all duration-200 disabled:opacity-50 focus-ring';
1421
const variants = {
15-
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]',
16-
secondary: 'bg-card-hover text-card-text border border-card-border hover:border-chitty-300 hover:bg-white active:scale-[0.97]',
17-
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]',
22+
primary:
23+
'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]',
24+
secondary:
25+
'bg-card-hover text-card-text border border-card-border hover:border-chitty-300 hover:bg-white active:scale-[0.97]',
26+
danger:
27+
'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]',
1828
};
1929

2030
return (
2131
<button
32+
ref={ref}
33+
type={type}
34+
autoFocus={autoFocus}
2235
onClick={onClick}
2336
disabled={disabled || loading}
2437
className={cn(base, variants[variant], className)}
@@ -31,4 +44,4 @@ export function ActionButton({ label, onClick, variant = 'primary', loading, dis
3144
) : label}
3245
</button>
3346
);
34-
}
47+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { useEffect, useId, useRef } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import { ActionButton } from './ActionButton';
4+
5+
interface ConfirmDialogProps {
6+
open: boolean;
7+
title: string;
8+
message: string;
9+
confirmLabel?: string;
10+
cancelLabel?: string;
11+
variant?: 'primary' | 'danger';
12+
loading?: boolean;
13+
onConfirm: () => void | Promise<void>;
14+
onCancel: () => void;
15+
}
16+
17+
export function ConfirmDialog({
18+
open,
19+
title,
20+
message,
21+
confirmLabel = 'Confirm',
22+
cancelLabel = 'Cancel',
23+
variant = 'primary',
24+
loading = false,
25+
onConfirm,
26+
onCancel,
27+
}: ConfirmDialogProps) {
28+
const dialogRef = useRef<HTMLDivElement>(null);
29+
const confirmButtonRef = useRef<HTMLButtonElement>(null);
30+
const cancelButtonRef = useRef<HTMLButtonElement>(null);
31+
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
32+
const titleId = useId();
33+
const descriptionId = useId();
34+
35+
useEffect(() => {
36+
if (!open) return;
37+
38+
previouslyFocusedElement.current =
39+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
40+
(confirmButtonRef.current ?? cancelButtonRef.current)?.focus();
41+
42+
const onKeyDown = (event: KeyboardEvent) => {
43+
if (event.key === 'Escape' && !loading) {
44+
onCancel();
45+
}
46+
47+
if (event.key === 'Tab' && dialogRef.current) {
48+
const focusable = Array.from(
49+
dialogRef.current.querySelectorAll<HTMLElement>(
50+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
51+
),
52+
).filter((el) => !el.hasAttribute('disabled'));
53+
54+
if (focusable.length === 0) return;
55+
56+
const first = focusable[0];
57+
const last = focusable[focusable.length - 1];
58+
59+
if (!event.shiftKey && document.activeElement === last) {
60+
event.preventDefault();
61+
first.focus();
62+
} else if (event.shiftKey && document.activeElement === first) {
63+
event.preventDefault();
64+
last.focus();
65+
}
66+
}
67+
};
68+
69+
window.addEventListener('keydown', onKeyDown);
70+
return () => {
71+
window.removeEventListener('keydown', onKeyDown);
72+
if (
73+
previouslyFocusedElement.current &&
74+
document.contains(previouslyFocusedElement.current)
75+
) {
76+
previouslyFocusedElement.current.focus();
77+
}
78+
};
79+
}, [loading, onCancel, open]);
80+
81+
if (!open || typeof document === 'undefined') return null;
82+
83+
return createPortal(
84+
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4">
85+
<div
86+
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
87+
onClick={() => !loading && onCancel()}
88+
/>
89+
<div
90+
role="dialog"
91+
aria-modal="true"
92+
aria-labelledby={titleId}
93+
aria-describedby={descriptionId}
94+
className="relative w-full max-w-md rounded-2xl border border-card-border bg-card-bg shadow-2xl p-5"
95+
ref={dialogRef}
96+
>
97+
<h2 id={titleId} className="text-lg font-semibold text-card-text">
98+
{title}
99+
</h2>
100+
<p
101+
id={descriptionId}
102+
className="mt-2 text-sm text-card-muted whitespace-pre-wrap"
103+
>
104+
{message}
105+
</p>
106+
<div className="mt-5 flex items-center justify-end gap-2">
107+
<ActionButton
108+
label={cancelLabel}
109+
variant="secondary"
110+
onClick={onCancel}
111+
disabled={loading}
112+
type="button"
113+
ref={cancelButtonRef}
114+
/>
115+
<ActionButton
116+
label={confirmLabel}
117+
variant={variant}
118+
onClick={onConfirm}
119+
loading={loading}
120+
type="button"
121+
ref={confirmButtonRef}
122+
/>
123+
</div>
124+
</div>
125+
</div>,
126+
document.body,
127+
);
128+
}

ui/src/components/ui/Toast.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react';
2+
import { cn } from '../../lib/utils';
3+
import type { ToastItem } from '../../lib/toast';
4+
5+
interface ToastViewportProps {
6+
toasts: ToastItem[];
7+
onDismiss: (id: string) => void;
8+
}
9+
10+
const toastVariants: Record<ToastItem['variant'], { icon: typeof CheckCircle2; className: string }> = {
11+
success: {
12+
icon: CheckCircle2,
13+
className: 'border-emerald-200 bg-emerald-50 text-emerald-900',
14+
},
15+
error: {
16+
icon: AlertCircle,
17+
className: 'border-rose-200 bg-rose-50 text-rose-900',
18+
},
19+
info: {
20+
icon: Info,
21+
className: 'border-indigo-200 bg-indigo-50 text-indigo-900',
22+
},
23+
};
24+
25+
export function ToastViewport({ toasts, onDismiss }: ToastViewportProps) {
26+
if (toasts.length === 0) return null;
27+
28+
return (
29+
<div className="fixed bottom-4 left-1/2 z-[100] w-full max-w-md -translate-x-1/2 px-4 pointer-events-none">
30+
<div className="space-y-2">
31+
{toasts.map((toast) => {
32+
const variant = toastVariants[toast.variant];
33+
const Icon = variant.icon;
34+
35+
return (
36+
<div
37+
key={toast.id}
38+
className={cn(
39+
'pointer-events-auto rounded-xl border shadow-lg backdrop-blur-sm px-3 py-2 animate-fade-in-up',
40+
variant.className,
41+
)}
42+
role="status"
43+
aria-live="polite"
44+
>
45+
<div className="flex items-start gap-2">
46+
<Icon size={16} className="mt-0.5 shrink-0" />
47+
<div className="min-w-0 flex-1">
48+
<p className="text-sm font-semibold leading-tight">{toast.title}</p>
49+
{toast.description && (
50+
<p className="text-xs mt-0.5 opacity-90">{toast.description}</p>
51+
)}
52+
{toast.actionLabel && toast.onAction && (
53+
<button
54+
type="button"
55+
onClick={() => {
56+
toast.onAction?.();
57+
onDismiss(toast.id);
58+
}}
59+
className="mt-1.5 text-xs font-semibold underline underline-offset-2"
60+
>
61+
{toast.actionLabel}
62+
</button>
63+
)}
64+
</div>
65+
<button
66+
type="button"
67+
onClick={() => onDismiss(toast.id)}
68+
className="opacity-70 hover:opacity-100 transition-opacity"
69+
aria-label="Dismiss notification"
70+
>
71+
<X size={14} />
72+
</button>
73+
</div>
74+
</div>
75+
);
76+
})}
77+
</div>
78+
</div>
79+
);
80+
}

0 commit comments

Comments
 (0)