From f18c6d1078b1dd110d9a92ea57242a32ccd3e691 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 12:32:05 -0400 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20app=20shell=20=E2=80=94=20rail?= =?UTF-8?q?,=20chat=20panel,=20routing,=20states=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reimplements the handoff Shell in React/TS: 208px left rail (nav + connection dot + Evening service theme switch), max-1120 main column, 330px Reginald chat panel. Routing across top-level views with an issue drill-in + back-stack of one; data-theme owned by the shell. - ChatPanel: fixture write-path — echoes a canned reply so layout + interactions are real; model router wiring lands in P4. - states.tsx: EmptyState / OfflineBanner / ModelAwayState + the States specimen gallery, ported from the handoff. - PlaceholderScreen stands in for not-yet-built views (P3-3+), keeping navigation live; it also exposes the issue drill-in for now. - Gallery loses its own theme toggle (shell owns data-theme); reachable via a Primitives rail entry as a living reference. Fixtures mirrored from the handoff's data.js. typecheck + 7 e2e green (nav, theme, offline banner + disabled composer, chat echo, drill-in back-stack); light/dark/states screenshots verified. Closes P3-2. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/pages/app.page.ts | 12 +- apps/desktop/e2e/smoke.spec.ts | 59 ++-- apps/desktop/src/renderer/src/app.tsx | 4 +- .../src/renderer/src/components/gallery.tsx | 27 +- .../src/components/shell/app-shell.tsx | 278 ++++++++++++++++++ .../src/components/shell/chat-panel.tsx | 167 +++++++++++ .../components/shell/placeholder-screen.tsx | 41 +++ .../renderer/src/components/shell/states.tsx | 232 +++++++++++++++ .../desktop/src/renderer/src/data/fixtures.ts | 26 ++ apps/desktop/src/renderer/src/vite-env.d.ts | 1 + 10 files changed, 802 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/shell/app-shell.tsx create mode 100644 apps/desktop/src/renderer/src/components/shell/chat-panel.tsx create mode 100644 apps/desktop/src/renderer/src/components/shell/placeholder-screen.tsx create mode 100644 apps/desktop/src/renderer/src/components/shell/states.tsx create mode 100644 apps/desktop/src/renderer/src/data/fixtures.ts create mode 100644 apps/desktop/src/renderer/src/vite-env.d.ts diff --git a/apps/desktop/e2e/pages/app.page.ts b/apps/desktop/e2e/pages/app.page.ts index fadb0c4..8cc5ac9 100644 --- a/apps/desktop/e2e/pages/app.page.ts +++ b/apps/desktop/e2e/pages/app.page.ts @@ -15,13 +15,19 @@ const SCREENS_DIR = join(here, '..', '.artifacts', 'screens') export class AppPage { constructor(readonly page: Page) {} - get heading(): Locator { - return this.page.getByRole('heading', { name: 'CommiTea' }) + /** The rail wordmark — present on every in-app view. */ + get wordmark(): Locator { + return this.page.getByText('CommiTea', { exact: true }) + } + + /** Navigate via a left-rail entry by its label. */ + nav(label: string): Locator { + return this.page.getByRole('button', { name: label, exact: true }) } /** Assert the shell has rendered. */ async expectLoaded(): Promise { - await expect(this.heading).toBeVisible() + await expect(this.wordmark).toBeVisible() } /** Read the `commitea` preload API surface from the renderer. */ diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 5854c87..7f38224 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -1,33 +1,50 @@ import { expect, test } from './fixtures.js' -test('boots and renders the primitives gallery', async ({ app, window }) => { - await expect(window.getByRole('heading', { name: 'Primitives' })).toBeVisible() - await expect(window.getByRole('button', { name: 'Brew plan' })).toBeVisible() - await app.screenshot('primitives-light') +test('boots into the shell: rail, main, and Reginald panel', async ({ app, window }) => { + await app.expectLoaded() + await expect(window.getByText('Reginald', { exact: true })).toBeVisible() + // default view is Morning service + await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() + await app.screenshot('shell-light') }) -test('primitives cover core, forms, and feedback', async ({ window }) => { - // section headings prove each cluster ported and rendered - for (const section of ['Button', 'Badge', 'Tag', 'Card', 'Input & Select', 'Feedback']) { - await expect(window.getByRole('heading', { name: section, exact: true })).toBeVisible() - } - // a mono label chip renders verbatim - await expect(window.getByText('deadline/hard')).toBeVisible() +test('rail navigation switches the main view', async ({ app, window }) => { + await app.nav('The pot').click() + await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible() + + await app.nav('States').click() + await expect(window.getByRole('heading', { name: 'States' })).toBeVisible() + await expect(window.getByText('The pot is empty')).toBeVisible() + await app.screenshot('shell-states') }) -test('dark theme toggle flips the document theme', async ({ app, window }) => { - await window.getByRole('button', { name: 'Switch to dark' }).click() +test('Evening service toggle flips the document theme', async ({ app, window }) => { + // the switch's real input is visually hidden — click the label text to toggle + await window.getByText('Evening service', { exact: true }).click() await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') - await app.screenshot('primitives-dark') + await expect(window.getByRole('switch', { name: 'Evening service' })).toBeChecked() + await app.screenshot('shell-dark') }) -test('dialog opens on demand and closes on Escape', async ({ window }) => { - await window.getByRole('button', { name: 'Open dialog' }).click() - const dialog = window.getByRole('dialog') - await expect(dialog).toBeVisible() - await expect(dialog.getByText('Withdraw directive?')).toBeVisible() - await window.keyboard.press('Escape') - await expect(dialog).toBeHidden() +test('offline sim shows the banner and disables the composer', async ({ window }) => { + await window.getByRole('button', { name: /Connection/ }).click() + await expect(window.getByText(/Gitea isn.t answering/)).toBeVisible() + await expect(window.getByRole('textbox')).toBeDisabled() +}) + +test('chat composer echoes a canned reply (fixture)', async ({ window }) => { + await window.getByRole('textbox').fill('Push pilots first') + await window.keyboard.press('Enter') + await expect(window.getByText('Push pilots first')).toBeVisible() + await expect(window.getByText(/Noted and logged as a directive/)).toBeVisible() +}) + +test('issue drill-in and back-stack of one', async ({ window }) => { + await window.getByRole('button', { name: 'Preview an issue page' }).click() + await expect(window.getByRole('heading', { name: 'Issue' })).toBeVisible() + await window.getByRole('button', { name: 'Back' }).click() + // returns to the view we drilled in from + await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() }) test('exposes the commitea preload API', async ({ app }) => { diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index 0434ef1..64be805 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -1,5 +1,5 @@ -import { PrimitivesGallery } from './components/gallery.js' +import { AppShell } from './components/shell/app-shell.js' export function App() { - return + return } diff --git a/apps/desktop/src/renderer/src/components/gallery.tsx b/apps/desktop/src/renderer/src/components/gallery.tsx index bd5c814..ebb2761 100644 --- a/apps/desktop/src/renderer/src/components/gallery.tsx +++ b/apps/desktop/src/renderer/src/components/gallery.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { Badge, @@ -33,31 +33,20 @@ function Section({ title, children }: { title: string; children: React.ReactNode * themes via the toggle. */ export function PrimitivesGallery() { - const [dark, setDark] = useState(false) const [tab, setTab] = useState('board') const [dialogOpen, setDialogOpen] = useState(false) const [checked, setChecked] = useState(true) const [radio, setRadio] = useState('a') const [on, setOn] = useState(true) - useEffect(() => { - document.documentElement.dataset.theme = dark ? 'dark' : 'light' - }, [dark]) - return ( -
-
-
-
-

Primitives

-

15 components · light + dark

-
- setDark((d) => !d)} - /> +
+
+
+

Primitives

+

+ 15 components · toggle Evening service in the rail for dark +

diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx new file mode 100644 index 0000000..b798312 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -0,0 +1,278 @@ +import React, { useEffect, useState } from 'react' + +import logoIcon from '../../design/assets/logo-icon.png' +import { PrimitivesGallery } from '../gallery.js' +import { Icon, Switch } from '../ui/index.js' +import { ChatPanel } from './chat-panel.js' +import { PlaceholderScreen } from './placeholder-screen.js' +import { OfflineBanner, StatesScreen } from './states.js' + +type View = + | 'standup' + | 'focus' + | 'inbox' + | 'capture' + | 'board' + | 'runway' + | 'directives' + | 'settings' + | 'states' + | 'firstrun' + | 'primitives' + | 'issue' + | 'calibration' + | 'milestone' + +interface NavEntry { + id: View + label: string + icon: string + count?: number | null +} + +// which phase builds each not-yet-real view (shown on its placeholder) +const PHASE: Partial> = { + standup: 'P3-3', + focus: 'P3-3', + inbox: 'P3-6', + capture: 'P3-7', + board: 'P3-4', + runway: 'P3-5', + directives: 'P3-8', + settings: 'P3-8', + firstrun: 'P3-8', + issue: 'P3-6', + calibration: 'P3-5', + milestone: 'P3-5', +} + +const TITLE: Partial> = { + standup: 'Standup', + focus: 'Morning service', + inbox: 'Inbox', + capture: 'Capture', + board: 'The pot', + runway: 'Runway', + directives: 'Directives', + settings: 'Settings', + firstrun: 'First run', + issue: 'Issue', + calibration: 'Calibration', + milestone: 'Milestone', +} + +const INBOX_UNREAD = 3 + +export function AppShell() { + const [view, setView] = useState('focus') + const [prevView, setPrevView] = useState('focus') + const [dark, setDark] = useState(false) + const [offline, setOffline] = useState(false) + + useEffect(() => { + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') + }, [dark]) + + const openIssue = (n: number) => { + if (view !== 'issue') setPrevView(view) + void n + setView('issue') + } + + const NAV: NavEntry[] = [ + { id: 'standup', label: 'Standup', icon: 'sun' }, + { id: 'focus', label: 'Morning service', icon: 'coffee' }, + { id: 'inbox', label: 'Inbox', icon: 'bell', count: INBOX_UNREAD || null }, + { id: 'capture', label: 'Capture', icon: 'plus' }, + { id: 'board', label: 'The pot', icon: 'square-kanban' }, + { id: 'runway', label: 'Runway', icon: 'chart-line' }, + { id: 'directives', label: 'Directives', icon: 'flag' }, + ] + + const NavItem = ({ item }: { item: NavEntry }) => { + const active = + view === item.id || + (view === 'issue' && prevView === item.id) || + ((view === 'calibration' || view === 'milestone') && item.id === 'runway') + return ( + + ) + } + + const renderScreen = () => { + switch (view) { + case 'states': + return setView('capture')} /> + case 'primitives': + return + case 'issue': + return ( +
+ + +
+ ) + default: + return ( + openIssue(1)} + /> + ) + } + } + + return ( +
+ {/* left rail */} + + + {/* main */} +
+
+ {offline ? : null} + {renderScreen()} +
+
+ + setView('directives')} offline={offline} /> +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx new file mode 100644 index 0000000..fde4e09 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useRef, useState } from 'react' + +import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js' +import { Icon, IconButton } from '../ui/index.js' + +/** + * Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2 + * fixture shell: it echoes a canned reply so the layout + interactions are real, + * but no model is wired. P4 replaces `send` with the model router + tools. + */ +export interface ChatPanelProps { + onOpenDirectives?: () => void + offline?: boolean +} + +export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { + const [msgs, setMsgs] = useState(CHAT) + const [text, setText] = useState('') + const [thinking, setThinking] = useState(false) + const scrollRef = useRef(null) + + useEffect(() => { + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, [msgs, thinking]) + + const send = () => { + const t = text.trim() + if (!t) return + setMsgs((m) => [...m, { from: 'user', text: t }]) + setText('') + setThinking(true) + setTimeout(() => { + setThinking(false) + setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }]) + }, 900) + } + + return ( +