Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/plans/2026-03-05-adversarial-review-restated.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@hono/zod-validator": "^0.7.6",
"@neondatabase/serverless": "^0.10.0",
"drizzle-orm": "^0.33.0",
"hono": "^4.4.0",
"hono": "^4.12.5",
"zod": "^3.23.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/dashboard/FocusView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/dashboard/ObligationsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
</button>
)}
</div>
Expand Down
25 changes: 19 additions & 6 deletions ui/src/components/ui/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { forwardRef } from 'react';
import { cn } from '../../lib/utils';

interface ActionButtonProps {
Expand All @@ -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<HTMLButtonElement, ActionButtonProps>(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 (
<button
ref={ref}
type={type}
autoFocus={autoFocus}
onClick={onClick}
disabled={disabled || loading}
className={cn(base, variants[variant], className)}
Expand All @@ -31,4 +44,4 @@ export function ActionButton({ label, onClick, variant = 'primary', loading, dis
) : label}
</button>
);
}
});
128 changes: 128 additions & 0 deletions ui/src/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useEffect, useId, useRef } 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<void>;
onCancel: () => void;
}

export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'primary',
loading = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const confirmButtonRef = useRef<HTMLButtonElement>(null);
const cancelButtonRef = useRef<HTMLButtonElement>(null);
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
const titleId = useId();
const descriptionId = useId();

useEffect(() => {
if (!open) return;

previouslyFocusedElement.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
(confirmButtonRef.current ?? cancelButtonRef.current)?.focus();

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !loading) {
onCancel();
}

if (event.key === 'Tab' && dialogRef.current) {
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute('disabled'));

if (focusable.length === 0) return;

const first = focusable[0];
const last = focusable[focusable.length - 1];

if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
}
};

window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
if (
previouslyFocusedElement.current &&
document.contains(previouslyFocusedElement.current)
) {
previouslyFocusedElement.current.focus();
}
};
}, [loading, onCancel, open]);

if (!open || typeof document === 'undefined') return null;

return createPortal(
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => !loading && onCancel()}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="relative w-full max-w-md rounded-2xl border border-card-border bg-card-bg shadow-2xl p-5"
ref={dialogRef}
>
<h2 id={titleId} className="text-lg font-semibold text-card-text">
{title}
</h2>
<p
id={descriptionId}
className="mt-2 text-sm text-card-muted whitespace-pre-wrap"
>
{message}
</p>
<div className="mt-5 flex items-center justify-end gap-2">
<ActionButton
label={cancelLabel}
variant="secondary"
onClick={onCancel}
disabled={loading}
type="button"
ref={cancelButtonRef}
/>
<ActionButton
label={confirmLabel}
variant={variant}
onClick={onConfirm}
loading={loading}
type="button"
ref={confirmButtonRef}
/>
</div>
</div>
</div>,
document.body,
);
}
80 changes: 80 additions & 0 deletions ui/src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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<ToastItem['variant'], { icon: typeof CheckCircle2; className: string }> = {
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 (
<div className="fixed bottom-4 left-1/2 z-[100] w-full max-w-md -translate-x-1/2 px-4 pointer-events-none">
<div className="space-y-2">
{toasts.map((toast) => {
const variant = toastVariants[toast.variant];
const Icon = variant.icon;

return (
<div
key={toast.id}
className={cn(
'pointer-events-auto rounded-xl border shadow-lg backdrop-blur-sm px-3 py-2 animate-fade-in-up',
variant.className,
)}
role="status"
aria-live="polite"
>
<div className="flex items-start gap-2">
<Icon size={16} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold leading-tight">{toast.title}</p>
{toast.description && (
<p className="text-xs mt-0.5 opacity-90">{toast.description}</p>
)}
{toast.actionLabel && toast.onAction && (
<button
type="button"
onClick={() => {
toast.onAction?.();
onDismiss(toast.id);
}}
className="mt-1.5 text-xs font-semibold underline underline-offset-2"
>
{toast.actionLabel}
</button>
)}
</div>
<button
type="button"
onClick={() => onDismiss(toast.id)}
className="opacity-70 hover:opacity-100 transition-opacity"
aria-label="Dismiss notification"
>
<X size={14} />
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
Loading
Loading