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
2 changes: 1 addition & 1 deletion QUICK_START.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ alembic upgrade head
```

```bash
python app.py
python -m uvicorn asgi:app --port 5000
```

API at http://localhost:5000
Expand Down
6 changes: 5 additions & 1 deletion backend/agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ class AgentState(TypedDict):
_ASKS_USER = re.compile(r"(would you|do you (want|need|wish)|shall i|want me|would it help|is there anything|"
r"anything else|let me know|how about i|you could|you can|tell me (which|if|whether)|if you('d| would) like|"
r"feel free)\b", re.IGNORECASE)
# A step the user does themselves ("Connect your Gmail…") isn't a request to Lumen either: as a chip it was sent to the
# chat (owner report, 2026-10-22). Uploads are the exception: the UI turns an "Upload…" chip into the upload button.
_USER_STEP = re.compile(r"(connect|go to|log ?in|sign ?in|visit|click|tap|open (the|your)|check your|forward)\b",
re.IGNORECASE)


def split_follow_up(text: str) -> tuple[str, str | None]:
Expand All @@ -65,7 +69,7 @@ def split_follow_up(text: str) -> tuple[str, str | None]:
answer, follow_up = (text[:m.start()] + text[m.end():]).strip(), m.group(1).strip(" \t*_\"'“”‘’")
if not follow_up: # a bare "Next:"
return answer, None
if _ASKS_USER.match(follow_up):
if _ASKS_USER.match(follow_up) or _USER_STEP.match(follow_up):
return f"{answer}\n\n{follow_up}", None
return answer, follow_up

Expand Down
2 changes: 1 addition & 1 deletion backend/agent/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
- Write naturally, like a knowledgeable friend: lead with the answer, then a sentence or two of useful context.
Use a short list or table only when it helps.
- If you need to ask the user something, ask it in the answer itself. Then end with one short request the user
might send next, in their own words (e.g. "Show last month by category"), never a question to them, on its own
might send next, in their own words (e.g. "Show last month by category"), never a question to them or a step they must do in the app, on its own
last line, starting "Next: "."""

FORCE_ANSWER = ("You have used the maximum number of tool calls. Answer now from the information above, "
Expand Down
5 changes: 3 additions & 2 deletions backend/start.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ if (-not (Test-Path .env)) {
}

# Start Flask application
Write-Host "Starting Flask server on http://localhost:5000" -ForegroundColor Cyan
python app.py
Write-Host "Starting the API (uvicorn) on http://localhost:5000" -ForegroundColor Cyan
# uvicorn runs the whole API; `python app.py` would start only the old Flask half (no /api/demo, agent or documents)
python -m uvicorn asgi:app --port 5000
7 changes: 7 additions & 0 deletions backend/tests/test_agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,13 @@ def test_split_follow_up():
"If you'd like a breakdown, ask", "You can ask for last month"):
assert split_follow_up(f"Done.\nNext: {lumen}")[1] is None
assert split_follow_up("Done.\nNext: Tell me my top vendors")[1] == "Tell me my top vendors"
# owner report 2026-10-22: a step for the user is not a question for Lumen; clicking it sent it to the chat.
# Uploads stay a chip (the UI turns it into an "upload" button); other do-it-yourself steps stay in the answer.
assert split_follow_up("Empty.\nNext: Upload an invoice or receipt to get started.")[1] == \
"Upload an invoice or receipt to get started."
for step in ("Connect your Gmail in Settings", "Go to Receipts and import a statement", "Log in to your bank app",
"Visit your bank's website", "Click Draft budget on Home"):
assert split_follow_up(f"Done.\nNext: {step}") == (f"Done.\n\n{step}", None)
assert split_follow_up("Here it is.\nNext: Would you like last month or the last three?") == (
"Here it is.\n\nWould you like last month or the last three?", None)
assert split_follow_up("Done.\nNext: Can I afford a trip in December?") == (
Expand Down
8 changes: 5 additions & 3 deletions docs/CODEBASE-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,10 @@ embeds the demo documents once per server and uploads the files in the backgroun
in the `sidebar_state` cookie. The layout reads that cookie on the sidebar's first render, which is always
client-side because the auth guard shows a loader on the server, so there's no jump and no server read. Shortcuts
skip key presses in text fields (`isTypingTarget` in `lib/utils.ts`). **Account** (Settings, API keys, Log out) is the
top-right `components/avatar-menu.tsx`.
`app/(app)/layout.tsx` holds the auth guard, sidebar and top bar; they mount once and stay while you move between
top-right `components/avatar-menu.tsx`, floating over the page (there is no top bar on desktop; phones keep a slim
row with the sheet button). The collapse button lives in the sidebar header; on the 56px rail the logo turns into it
on hover. Every sidebar group is labelled (Overview, Track, Records, Assistant).
`app/(app)/layout.tsx` holds the auth guard and sidebar; they mount once and stay while you move between
pages, so only the content area swaps. `app/(app)/template.tsx` fades each page's content in (`.app-page-in` in
`globals.css`). `components/dashboard-shell.tsx` is just a page's content wrapper and optional header card.
- There is no root `loading.tsx`: the pages are static and prefetched, and a full-screen spinner made every click
Expand All @@ -273,7 +275,7 @@ embeds the demo documents once per server and uploads the files in the backgroun
swaps every shadcn colour token to the light Sky palette, so portaled menus and dialogs match too. The
`dark:` variant is also switched off while the shell is mounted. Public pages keep the forced dark theme.
Extra tokens: `good`, `bad`, `accent-soft`, `--band`. Type roles: `font-serif` (Newsreader), `font-label` /
`.label-mono` (IBM Plex Mono labels).
`.label-ui` (card titles, tags, ticks: Funnel Sans, sentence case; the owner ruled out all-caps mono labels).
- **No layout shift from overlays.** `globals.css` sets `scrollbar-gutter: stable` and cancels Radix's scroll-lock
margin (`body[data-scroll-locked]`), so opening a menu or dialog never moves the page. Guarded by
`npm run test:e2e` (Playwright, local only), which drives the dev-only harness `app/dev/layout-shift` (404 in
Expand Down
19 changes: 19 additions & 0 deletions frontend/e2e/ask.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,22 @@ test("the send button looks different when it can send", async ({ page }) => {
const lum = (c: number[]) => (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * (c[3] / 255) + 244 * (1 - c[3] / 255);
expect(Math.abs(lum(on) - lum(off))).toBeGreaterThan(80); // a dark filled button vs a faint one on the light page
});

test("an upload suggestion opens the upload dialog instead of asking Lumen", async ({ page }) => {
await openAsk(page, { followUp: "Upload an invoice or receipt to get started." });
await composer(page).fill("Do I have invoices?");
await composer(page).press("Enter");
const chip = page.getByRole("link", { name: "Upload an invoice or receipt to get started" });
await expect(chip).toHaveAttribute("href", "/home?upload=1");
await chip.click();
await expect(page).toHaveURL(/\/home\?upload=1$/);
await expect(page.getByRole("dialog")).toBeVisible();
});

test("a question suggestion is still sent to Lumen", async ({ page }) => {
await openAsk(page, { followUp: "Show last month by category" });
await composer(page).fill("How much did I spend?");
await composer(page).press("Enter");
await page.getByRole("button", { name: "Show last month by category" }).click();
await expect(page.getByText("Answer to: Show last month by category")).toBeVisible();
});
4 changes: 3 additions & 1 deletion frontend/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type ChatFixture = {
threads?: { id: string; title: string; updated_at: string; message_count: number; messages?: { role: string; content: string }[] }[];
/** the answer text POST /chat returns (default "Answer to: <query>") */
answer?: string;
/** the "Next:" suggestion POST /chat returns (default none) */
followUp?: string;
suggestions?: string[];
};

Expand Down Expand Up @@ -139,7 +141,7 @@ export async function signIn(
const response = opts.chat?.answer ?? `Answer to: ${body.query}`;
t.messages.push({ role: "user", content: body.query }, { role: "assistant", content: response });
return route.fulfill({
json: { success: true, data: { response, follow_up: null, sources: [], steps: [], proposals: [], stopped: "answer", thread_id: t.id } },
json: { success: true, data: { response, follow_up: opts.chat?.followUp ?? null, sources: [], steps: [], proposals: [], stopped: "answer", thread_id: t.id } },
});
}
if (path === "/api/profile")
Expand Down
19 changes: 18 additions & 1 deletion frontend/e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ test("sidebar shows the new IA", async ({ page }) => {
await page.goto("/home");
const links = sidebar(page).locator('[data-sidebar="menu-button"]');
await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Receipts & statements", "Ask Lumen", /^Ask anything/]);
await expect(sidebar(page).getByText("Track", { exact: true })).toBeVisible();
// every group is labelled (owner, 2026-10-22)
for (const group of ["Overview", "Track", "Records", "Assistant"]) {
await expect(sidebar(page).getByText(group, { exact: true })).toBeVisible();
}
for (const old of ["Workspace", "Compare", "Financial command center"]) {
await expect(page.getByText(old, { exact: true })).toHaveCount(0);
}
Expand All @@ -85,6 +88,20 @@ test("ctrl+b collapses to a 56px rail with tooltips", async ({ page }) => {
await expect(page.getByRole("tooltip")).toHaveText("Spending");
});

test("the collapse control sits in the sidebar header, with no top bar on desktop", async ({ page }) => {
await signIn(page);
await page.goto("/home");
const toggle = sidebar(page).getByRole("button", { name: "Toggle sidebar (Ctrl+B)" });
await toggle.click();
await expect.poll(() => railWidth(page)).toBeCloseTo(56, 0);
// on the rail the logo turns into the expand button on hover
await sidebar(page).locator('[data-sidebar="header"]').hover();
await toggle.click();
await expect.poll(() => railWidth(page)).toBeGreaterThan(200);
await expect(page.locator("#main > header")).toBeHidden();
await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible();
});

test("collapsed state survives reload without a jump", async ({ page }) => {
// record the sidebar's data-state the moment it first enters the DOM (the auth guard renders it client-side)
await page.addInitScript(() => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/(app)/ask/chatbotContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export default function ChatbotContent() {

return (
<DashboardShell contentClassName="p-0 sm:p-0 lg:p-0 gap-0">
<div className="bui flex h-[calc(100dvh-3.5rem)]">
<div className="bui flex h-[calc(100dvh-3.5rem)] md:h-dvh">
<aside className="hidden w-[248px] shrink-0 border-r border-line lg:block">
<ThreadRail {...railProps} />
</aside>
Expand All @@ -194,7 +194,7 @@ export default function ChatbotContent() {
</SheetContent>
</Sheet>
<div className="relative flex min-w-0 flex-1 flex-col" data-testid="ask-column">
<div className="absolute top-3 right-4 z-10 flex gap-1.5 lg:hidden">
<div className="absolute top-3 right-4 z-10 flex gap-1.5 md:right-16 lg:hidden">
<Button variant="quiet" size="xs" onClick={() => setHistoryOpen(true)}>
<History className="size-3.5" /> History
</Button>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/(app)/ask/thread-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function ThreadRail({
>
<Plus className="size-4" /> New chat
</button>
<div className="label-mono px-1 pt-2 text-muted-foreground">Recent</div>
<div className="label-ui px-1 pt-2 text-muted-foreground">Recent</div>
<ul className="min-h-0 flex-1 overflow-y-auto">
{threads.length === 0 && <li className="px-2 py-1.5 text-[13px] text-muted-foreground">No past chats yet.</li>}
{threads.map((t) => (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/(app)/home/_components/activity-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; n
items.map((a, i) => (
<div key={i} className="flex gap-2 py-1 text-[12.5px]">
<span className="min-w-0 flex-1 truncate">{fill(a.text, a.amounts, money)}</span>
<span className="label-mono shrink-0 text-muted-foreground">{relativeTime(a.at, now)}</span>
<span className="label-ui shrink-0 text-muted-foreground">{relativeTime(a.at, now)}</span>
</div>
))
)}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/(app)/home/_components/bills-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function BillsCard({ reminders, today, loading }: { reminders: Reminder[]
const d = parseDay(r.date);
return (
<div key={r.id} data-testid="bill-row" className="flex items-center gap-2.5 py-1.5 text-[13px]">
<span className="label-mono w-9 shrink-0 rounded-md border border-border py-0.5 text-center text-muted-foreground">
<span className="label-ui w-9 shrink-0 rounded-md border border-border py-0.5 text-center text-muted-foreground">
{d.toLocaleString("en-GB", { weekday: "short" })}
<b className="block text-[13px] text-foreground">{String(d.getDate()).padStart(2, "0")}</b>
</span>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/(app)/home/_components/budget-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) {
type="button"
onClick={() => void draft()}
disabled={busy}
className="label-mono mt-2.5 rounded-full bg-foreground px-3.5 py-1.5 text-background transition-opacity disabled:opacity-60"
className="label-ui mt-2.5 rounded-full bg-foreground px-3.5 py-1.5 text-background transition-opacity disabled:opacity-60"
>
Draft budget
</button>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/(app)/home/_components/calendar-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P
})}
</div>
<div className="min-w-0">
<div className="label-mono mb-1 text-muted-foreground">Recent transactions</div>
<div className="label-ui mb-1 text-muted-foreground">Recent transactions</div>
{recent.length === 0 ? (
<p className="text-sm text-muted-foreground">No transactions this month yet.</p>
) : (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/(app)/home/_components/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function HomeCard({
}) {
return (
<section {...rest} className={cn("rounded-[14px] border border-border bg-card px-4 py-3.5", className)}>
<div className="label-mono mb-2.5 flex items-center text-muted-foreground">
<div className="label-ui mb-2.5 flex items-center text-muted-foreground">
{href ? (
<Link href={href} className="hover:text-foreground">
{title} ›
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/app/(app)/home/_components/lumen-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null;
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
{found.map((f, i) => (
<div key={i} data-testid="found-tile" className="flex flex-col rounded-xl border border-border bg-background p-3">
<div className="label-mono text-primary">{f.tag}</div>
{/* the API sends tags in caps ("RECEIPTS"); labels are sentence case */}
<div className="label-ui text-primary">{f.tag.charAt(0) + f.tag.slice(1).toLowerCase()}</div>
<p className="my-1 font-serif text-[17px] leading-tight">{fill(f.headline, f.amounts, money)}</p>
<p className="text-[12.5px] leading-snug text-muted-foreground">{fill(f.detail, f.amounts, money)}</p>
{f.href && (
<Link href={f.href} className="label-mono mt-auto pt-2 underline underline-offset-4">
<Link href={f.href} className="label-ui mt-auto pt-2 underline underline-offset-4">
{f.kind === "review" ? "Review →" : "See →"}
</Link>
)}
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/app/(app)/home/_components/spend-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export function SpendCard({
)}
</div>
</div>
<div className="label-mono ml-auto hidden gap-3 text-muted-foreground sm:flex">
<div className="label-ui ml-auto hidden gap-3 text-muted-foreground sm:flex">
<span>— now</span>
<span>┄ before</span>
{fc.length > 0 && <span>· forecast</span>}
Expand Down Expand Up @@ -149,15 +149,15 @@ export function SpendCard({
const i = dayIndex(period, parseDay(p.date));
return (
<span key={p.id} className="absolute flex -translate-x-1/2 flex-col items-center" style={{ left: `${(x(i) / W) * 100}%`, top: 0 }}>
<span data-testid="bill-pin" className="label-mono max-w-24 truncate text-muted-foreground">
<span data-testid="bill-pin" className="label-ui max-w-24 truncate text-muted-foreground">
{p.vendor}
</span>
<span className="mt-0.5 size-2 rounded-full border-2 border-bad bg-card" />
</span>
);
})}
</div>
<div className="label-mono relative mt-1 h-4 text-muted-foreground">
<div className="label-ui relative mt-1 h-4 text-muted-foreground">
{ticks.map((i) => (
<span key={i} className="absolute -translate-x-1/2 whitespace-nowrap first:translate-x-0 last:-translate-x-full" style={{ left: `${(x(i) / W) * 100}%` }}>
{i === t ? "today" : tick(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i))}
Expand All @@ -174,7 +174,7 @@ export function SpendCard({
aria-pressed={r === range}
onClick={() => onRange(r)}
className={cn(
"label-mono rounded-md px-2.5 py-1 transition-colors",
"label-ui rounded-md px-2.5 py-1 transition-colors",
r === range ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/(app)/home/homeContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export default function HomeContent() {
<div className="relative flex-1">
<div aria-hidden data-testid="sky-band" className="pointer-events-none absolute inset-x-0 top-0 h-[260px] bg-[image:var(--band)]" />
<div className="relative px-4 py-6 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center gap-3">
{/* md:pr-12 leaves room for the floating account menu (app-shell.tsx) */}
<div className="flex flex-wrap items-center gap-3 md:pr-12">
<h1 className="text-2xl font-medium tracking-tight">
{firstName ? `Good ${partOfDay(new Date().getHours())}, ${firstName}` : "Welcome back"}
</h1>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/(app)/receipts/receipts-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function ReceiptsTabs() {
href={`?tab=${t.id}`}
aria-current={t.id === active.id ? "page" : undefined}
className={cn(
"label-mono border-b-2 pb-2 transition-colors",
"label-ui border-b-2 pb-2 transition-colors",
t.id === active.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/beautiful-ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,4 @@
}

/* uppercase mono micro-label (eyebrows, badges, data annotations) */
.bui-label { font-family: var(--font-label); font-size: 11px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; }
.bui-label { font-family: var(--font-label); font-size: 12.5px; font-weight: 500; letter-spacing: 0.01em; }
12 changes: 6 additions & 6 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
--color-bad: var(--bad);
--color-accent-soft: var(--accent-soft);
--font-serif: var(--font-newsreader), Georgia, serif;
--font-label: var(--font-plex-mono), ui-monospace, monospace;
--font-label: var(--font-ui), system-ui, sans-serif;
--font-sans: "Inter", "Inter Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, monospace;
--font-script: "Dancing Script", cursive;
Expand Down Expand Up @@ -232,11 +232,11 @@
animation: slide-out-to-right-full 0.2s ease-in;
}

.label-mono {
font-family: var(--font-plex-mono), ui-monospace, monospace;
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
/* card titles, tags and axis ticks: the UI font, sentence case, quiet (owner: no all-caps mono labels) */
.label-ui {
font-family: var(--font-ui), system-ui, sans-serif;
font-size: 12.5px;
letter-spacing: 0.01em;
font-weight: 500;
}

Expand Down
Loading
Loading