From 7bc006f03cdbe2db28ad9c7e5f6ea89cc0ffa863 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:19:24 +0530 Subject: [PATCH] fix: owner feedback on the revamp shell, labels and Ask chips - Sidebar: every group is labelled (Overview, Track, Records, Assistant); the collapse button moves into the sidebar header (on the rail the logo becomes the expand button); no top bar on desktop, the account menu floats top-right; phones keep a slim row. - Labels: Funnel Sans, sentence case (`.label-ui`) instead of all-caps IBM Plex Mono; Plex Mono is no longer loaded. Ask's labels match. - Ask: an "Upload ..." suggestion opens the upload dialog instead of being sent to Lumen as a question; other do-it-yourself steps ("Connect your Gmail...") stay in the answer text instead of becoming a chip; the prompt says the same. - QUICK_START and start.ps1 start the API with uvicorn (python app.py only starts the old Flask half, so /demo failed). --- QUICK_START.md | 2 +- backend/agent/graph.py | 6 ++- backend/agent/prompts.py | 2 +- backend/start.ps1 | 5 ++- backend/tests/test_agent_tools.py | 7 +++ docs/CODEBASE-GUIDE.md | 8 ++-- frontend/e2e/ask.spec.ts | 19 ++++++++ frontend/e2e/fixtures.ts | 4 +- frontend/e2e/shell.spec.ts | 19 +++++++- frontend/src/app/(app)/ask/chatbotContent.tsx | 4 +- frontend/src/app/(app)/ask/thread-rail.tsx | 2 +- .../(app)/home/_components/activity-card.tsx | 2 +- .../app/(app)/home/_components/bills-card.tsx | 2 +- .../(app)/home/_components/budget-card.tsx | 2 +- .../(app)/home/_components/calendar-card.tsx | 2 +- .../src/app/(app)/home/_components/card.tsx | 2 +- .../(app)/home/_components/lumen-found.tsx | 5 ++- .../app/(app)/home/_components/spend-card.tsx | 8 ++-- frontend/src/app/(app)/home/homeContent.tsx | 3 +- .../src/app/(app)/receipts/receipts-tabs.tsx | 2 +- frontend/src/app/beautiful-ui.css | 2 +- frontend/src/app/globals.css | 12 ++--- frontend/src/app/layout.tsx | 11 +---- frontend/src/components/app-shell.tsx | 17 ++++--- frontend/src/components/app-sidebar.tsx | 44 +++++++++++++------ frontend/src/components/ask-palette.tsx | 2 +- frontend/src/components/ask/answer.tsx | 25 +++++++---- 27 files changed, 148 insertions(+), 71 deletions(-) diff --git a/QUICK_START.md b/QUICK_START.md index 5ae047f..15247cb 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -70,7 +70,7 @@ alembic upgrade head ``` ```bash -python app.py +python -m uvicorn asgi:app --port 5000 ``` API at http://localhost:5000 diff --git a/backend/agent/graph.py b/backend/agent/graph.py index 518c2b5..a4ea8f2 100644 --- a/backend/agent/graph.py +++ b/backend/agent/graph.py @@ -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]: @@ -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 diff --git a/backend/agent/prompts.py b/backend/agent/prompts.py index 975fe71..9bef0bb 100644 --- a/backend/agent/prompts.py +++ b/backend/agent/prompts.py @@ -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, " diff --git a/backend/start.ps1 b/backend/start.ps1 index e6ad7b8..1d37e6b 100644 --- a/backend/start.ps1 +++ b/backend/start.ps1 @@ -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 diff --git a/backend/tests/test_agent_tools.py b/backend/tests/test_agent_tools.py index 69a2cd3..f294256 100644 --- a/backend/tests/test_agent_tools.py +++ b/backend/tests/test_agent_tools.py @@ -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?") == ( diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index cdc170c..acc3d61 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -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 @@ -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 diff --git a/frontend/e2e/ask.spec.ts b/frontend/e2e/ask.spec.ts index 3aa7b42..a81ecd9 100644 --- a/frontend/e2e/ask.spec.ts +++ b/frontend/e2e/ask.spec.ts @@ -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(); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 6f5d5c1..64868a8 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -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: ") */ answer?: string; + /** the "Next:" suggestion POST /chat returns (default none) */ + followUp?: string; suggestions?: string[]; }; @@ -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") diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 53a6963..8bc6b4d 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -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); } @@ -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(() => { diff --git a/frontend/src/app/(app)/ask/chatbotContent.tsx b/frontend/src/app/(app)/ask/chatbotContent.tsx index 55bc560..e81baf7 100644 --- a/frontend/src/app/(app)/ask/chatbotContent.tsx +++ b/frontend/src/app/(app)/ask/chatbotContent.tsx @@ -183,7 +183,7 @@ export default function ChatbotContent() { return ( -
+
@@ -194,7 +194,7 @@ export default function ChatbotContent() {
-
+
diff --git a/frontend/src/app/(app)/ask/thread-rail.tsx b/frontend/src/app/(app)/ask/thread-rail.tsx index 4352439..b2f14bb 100644 --- a/frontend/src/app/(app)/ask/thread-rail.tsx +++ b/frontend/src/app/(app)/ask/thread-rail.tsx @@ -35,7 +35,7 @@ export function ThreadRail({ > New chat -
Recent
+
Recent
    {threads.length === 0 &&
  • No past chats yet.
  • } {threads.map((t) => ( diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx index c6fb675..5287f27 100644 --- a/frontend/src/app/(app)/home/_components/activity-card.tsx +++ b/frontend/src/app/(app)/home/_components/activity-card.tsx @@ -21,7 +21,7 @@ export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; n items.map((a, i) => (
    {fill(a.text, a.amounts, money)} - {relativeTime(a.at, now)} + {relativeTime(a.at, now)}
    )) )} diff --git a/frontend/src/app/(app)/home/_components/bills-card.tsx b/frontend/src/app/(app)/home/_components/bills-card.tsx index 6e0192c..214bbd1 100644 --- a/frontend/src/app/(app)/home/_components/bills-card.tsx +++ b/frontend/src/app/(app)/home/_components/bills-card.tsx @@ -19,7 +19,7 @@ export function BillsCard({ reminders, today, loading }: { reminders: Reminder[] const d = parseDay(r.date); return (
    - + {d.toLocaleString("en-GB", { weekday: "short" })} {String(d.getDate()).padStart(2, "0")} diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index 777b64b..9e0737f 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -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 diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index f4da6f4..8d66590 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -38,7 +38,7 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P })}
    -
    Recent transactions
    +
    Recent transactions
    {recent.length === 0 ? (

    No transactions this month yet.

    ) : ( diff --git a/frontend/src/app/(app)/home/_components/card.tsx b/frontend/src/app/(app)/home/_components/card.tsx index c8bd384..2e8692d 100644 --- a/frontend/src/app/(app)/home/_components/card.tsx +++ b/frontend/src/app/(app)/home/_components/card.tsx @@ -25,7 +25,7 @@ export function HomeCard({ }) { return (
    -
    +
    {href ? ( {title} › diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx index 8c1e987..a986f88 100644 --- a/frontend/src/app/(app)/home/_components/lumen-found.tsx +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -25,11 +25,12 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null;
    {found.map((f, i) => (
    -
    {f.tag}
    + {/* the API sends tags in caps ("RECEIPTS"); labels are sentence case */} +
    {f.tag.charAt(0) + f.tag.slice(1).toLowerCase()}

    {fill(f.headline, f.amounts, money)}

    {fill(f.detail, f.amounts, money)}

    {f.href && ( - + {f.kind === "review" ? "Review →" : "See →"} )} diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index e56885a..a10fbfa 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -109,7 +109,7 @@ export function SpendCard({ )}
    -
    +
    — now ┄ before {fc.length > 0 && · forecast} @@ -149,7 +149,7 @@ export function SpendCard({ const i = dayIndex(period, parseDay(p.date)); return ( - + {p.vendor} @@ -157,7 +157,7 @@ export function SpendCard({ ); })}
    -
    +
    {ticks.map((i) => ( {i === t ? "today" : tick(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i))} @@ -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", )} > diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index 0d98a48..ca06636 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -68,7 +68,8 @@ export default function HomeContent() {
    -
    + {/* md:pr-12 leaves room for the floating account menu (app-shell.tsx) */} +

    {firstName ? `Good ${partOfDay(new Date().getHours())}, ${firstName}` : "Welcome back"}

    diff --git a/frontend/src/app/(app)/receipts/receipts-tabs.tsx b/frontend/src/app/(app)/receipts/receipts-tabs.tsx index 783edcb..694ea98 100644 --- a/frontend/src/app/(app)/receipts/receipts-tabs.tsx +++ b/frontend/src/app/(app)/receipts/receipts-tabs.tsx @@ -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", diff --git a/frontend/src/app/beautiful-ui.css b/frontend/src/app/beautiful-ui.css index a4b0385..9a4c71f 100644 --- a/frontend/src/app/beautiful-ui.css +++ b/frontend/src/app/beautiful-ui.css @@ -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; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a95d3f5..b0cbd07 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; @@ -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; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 7e92c84..8630b62 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { IBM_Plex_Mono, Inter, JetBrains_Mono, Newsreader } from "next/font/google"; +import { Inter, JetBrains_Mono, Newsreader } from "next/font/google"; import localFont from "next/font/local"; import "./globals.css"; import { AuthProvider } from "@/components/auth/auth-provider"; @@ -26,13 +26,6 @@ const newsreader = Newsreader({ variable: "--font-newsreader", }); -// In-app card titles, tags and axis ticks (the `.label-mono` / `font-label` style). -const plexMono = IBM_Plex_Mono({ - subsets: ["latin"], - weight: ["400", "500"], - variable: "--font-plex-mono", -}); - // Funnel Sans (OFL, self-hosted from @fontsource-variable/funnel-sans): text, buttons and labels on the landing, // sign-in and onboarding pages, and the label style everywhere (it replaced Hanken Grotesk and Roboto Mono). const funnelSans = localFont({ @@ -141,7 +134,7 @@ export default function RootLayout({ {/* every signed-in page talks to these two right away */} diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index f6d215d..9810ec2 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -22,15 +22,18 @@ export function AppShell({ children }: { children: ReactNode }) { state straight from the cookie gives the right width on the sidebar's first paint: no jump, no SSR read. */} - -
    -
    - -
    - -
    + + {/* no top bar on desktop: just the account menu, floating top-right over the page (owner, 2026-10-22). + Phones keep a slim row, since the sidebar there is a sheet that needs a button to open. */} +
    + +
    +
    +
    + +
    {children}
    diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index c73ff27..2329ff9 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -17,7 +17,7 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarRail, - SidebarSeparator, + SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; import { AskPalette } from "@/components/ask-palette"; @@ -27,8 +27,8 @@ import { isTypingTarget } from "@/lib/utils"; type Item = { label: string; url: string; icon: LucideIcon }; -// Spec section 3. Group labels say what you do, not what the backend is. Budget and Forecast (PLAN) join once -// their pages exist. +// Spec section 3. Every group is labelled (owner, 2026-10-22); labels say what you do, not what the backend is. +// Budget and Forecast (Plan) join once their pages exist. const HOME: Item = { label: "Home", url: "/home", icon: House }; const TRACK: Item[] = [ { label: "Spending", url: "/spending", icon: ChartLine }, @@ -83,34 +83,52 @@ export function AppSidebar(props: React.ComponentProps) { return ( - - - - - Lumen - - + {/* the collapse control lives here, not in a top bar (owner, 2026-10-22). On the icon rail the logo turns into + the expand button on hover, so the rail stays 56px wide. */} + +
    + + + + Lumen + + + +
    + Overview - Track + Track {TRACK.map((item) => ( ))} - + Records {toReview > 0 && {toReview}} + + + + Assistant + @@ -126,7 +144,7 @@ export function AppSidebar(props: React.ComponentProps) { Ask anything {/* fixed width: the label swaps to ⌘K on a Mac after mount without moving anything */} - + {mac ? "⌘K" : "Ctrl K"} diff --git a/frontend/src/components/ask-palette.tsx b/frontend/src/components/ask-palette.tsx index 42f529a..358e2c0 100644 --- a/frontend/src/components/ask-palette.tsx +++ b/frontend/src/components/ask-palette.tsx @@ -42,7 +42,7 @@ export function AskPalette({ open, onOpenChange }: { open: boolean; onOpenChange className="h-11 w-full bg-transparent text-[16px] outline-none placeholder:text-muted-foreground" /> - + Enter to ask · Esc to close diff --git a/frontend/src/components/ask/answer.tsx b/frontend/src/components/ask/answer.tsx index 6229d52..07513d8 100644 --- a/frontend/src/components/ask/answer.tsx +++ b/frontend/src/components/ask/answer.tsx @@ -3,17 +3,25 @@ // An Ask Lumen answer: markdown with inline citation chips, an action row and the cited passages. // Visuals adapted from Beautiful UI's Streaming Text and Context Cards (beautifului.dev, MIT). import { useState, type ReactNode } from "react"; +import Link from "next/link"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { ArrowUpRight, Check, Copy, FileText, Globe, Info, RotateCcw } from "lucide-react"; +import { ArrowUpRight, Check, Copy, FileText, Globe, Info, RotateCcw, Upload } from "lucide-react"; import { filesApi, type ChatAttachment, type ChatSource } from "@/lib/api/client"; import { InvoiceCard } from "./invoice-card"; import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; +const CHIP = + "self-start rounded-full border border-line px-3.5 py-1.5 text-left text-[13px] text-ink-2 transition-colors duration-200 hover:border-line-strong hover:bg-hover hover:text-ink"; + // Same shape the agent cites with (backend agent/graph.py _CITATION). const CITATION = /[[【]\s*([^[\]【】\s]+#s\d{2}(?:-\d+)?|web:\d+)\s*[\]】]/g; const CITE_PREFIX = "#cite-"; +// A suggested next step that is an upload is something you do, not something to ask: the chip opens the upload dialog +// instead of sending the sentence to Lumen (owner report, 2026-10-22). Other do-it-yourself steps never become chips +// (backend agent/graph.py _USER_STEP). +const UPLOAD_STEP = /^(upload|import|add|attach|scan)\b.*\b(receipt|invoice|statement|document|bill|file)s?\b/i; function openOriginal(key: string) { filesApi.open(key).catch(() => toast.error("Couldn't open the original document.")); @@ -166,15 +174,16 @@ export function Answer({ {children} - {followUp && onFollowUp && ( - - )} + ))}