feat(desktop): app shell — rail, chat panel, routing, states (#15)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,19 @@ const SCREENS_DIR = join(here, '..', '.artifacts', 'screens')
|
|||||||
export class AppPage {
|
export class AppPage {
|
||||||
constructor(readonly page: Page) {}
|
constructor(readonly page: Page) {}
|
||||||
|
|
||||||
get heading(): Locator {
|
/** The rail wordmark — present on every in-app view. */
|
||||||
return this.page.getByRole('heading', { name: 'CommiTea' })
|
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. */
|
/** Assert the shell has rendered. */
|
||||||
async expectLoaded(): Promise<void> {
|
async expectLoaded(): Promise<void> {
|
||||||
await expect(this.heading).toBeVisible()
|
await expect(this.wordmark).toBeVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the `commitea` preload API surface from the renderer. */
|
/** Read the `commitea` preload API surface from the renderer. */
|
||||||
|
|||||||
@@ -1,33 +1,50 @@
|
|||||||
import { expect, test } from './fixtures.js'
|
import { expect, test } from './fixtures.js'
|
||||||
|
|
||||||
test('boots and renders the primitives gallery', async ({ app, window }) => {
|
test('boots into the shell: rail, main, and Reginald panel', async ({ app, window }) => {
|
||||||
await expect(window.getByRole('heading', { name: 'Primitives' })).toBeVisible()
|
await app.expectLoaded()
|
||||||
await expect(window.getByRole('button', { name: 'Brew plan' })).toBeVisible()
|
await expect(window.getByText('Reginald', { exact: true })).toBeVisible()
|
||||||
await app.screenshot('primitives-light')
|
// 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 }) => {
|
test('rail navigation switches the main view', async ({ app, window }) => {
|
||||||
// section headings prove each cluster ported and rendered
|
await app.nav('The pot').click()
|
||||||
for (const section of ['Button', 'Badge', 'Tag', 'Card', 'Input & Select', 'Feedback']) {
|
await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible()
|
||||||
await expect(window.getByRole('heading', { name: section, exact: true })).toBeVisible()
|
|
||||||
}
|
await app.nav('States').click()
|
||||||
// a mono label chip renders verbatim
|
await expect(window.getByRole('heading', { name: 'States' })).toBeVisible()
|
||||||
await expect(window.getByText('deadline/hard')).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 }) => {
|
test('Evening service toggle flips the document theme', async ({ app, window }) => {
|
||||||
await window.getByRole('button', { name: 'Switch to dark' }).click()
|
// 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 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 }) => {
|
test('offline sim shows the banner and disables the composer', async ({ window }) => {
|
||||||
await window.getByRole('button', { name: 'Open dialog' }).click()
|
await window.getByRole('button', { name: /Connection/ }).click()
|
||||||
const dialog = window.getByRole('dialog')
|
await expect(window.getByText(/Gitea isn.t answering/)).toBeVisible()
|
||||||
await expect(dialog).toBeVisible()
|
await expect(window.getByRole('textbox')).toBeDisabled()
|
||||||
await expect(dialog.getByText('Withdraw directive?')).toBeVisible()
|
})
|
||||||
await window.keyboard.press('Escape')
|
|
||||||
await expect(dialog).toBeHidden()
|
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 }) => {
|
test('exposes the commitea preload API', async ({ app }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PrimitivesGallery } from './components/gallery.js'
|
import { AppShell } from './components/shell/app-shell.js'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return <PrimitivesGallery />
|
return <AppShell />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -33,31 +33,20 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|||||||
* themes via the toggle.
|
* themes via the toggle.
|
||||||
*/
|
*/
|
||||||
export function PrimitivesGallery() {
|
export function PrimitivesGallery() {
|
||||||
const [dark, setDark] = useState(false)
|
|
||||||
const [tab, setTab] = useState('board')
|
const [tab, setTab] = useState('board')
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [checked, setChecked] = useState(true)
|
const [checked, setChecked] = useState(true)
|
||||||
const [radio, setRadio] = useState('a')
|
const [radio, setRadio] = useState('a')
|
||||||
const [on, setOn] = useState(true)
|
const [on, setOn] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
|
||||||
}, [dark])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-paper-0 font-sans text-ink-1">
|
<main className="bg-paper-0 font-sans text-ink-1">
|
||||||
<div className="mx-auto max-w-[1120px] px-7 py-10">
|
<div className="mx-auto max-w-[1120px]">
|
||||||
<header className="flex items-baseline justify-between border-b-[3px] border-double border-ink-1 pb-4">
|
<header className="border-b-[3px] border-double border-ink-1 pb-4">
|
||||||
<div>
|
<h1 className="font-display text-4xl">Primitives</h1>
|
||||||
<h1 className="font-display text-4xl">Primitives</h1>
|
<p className="mt-1 font-mono text-sm text-ink-3">
|
||||||
<p className="mt-1 font-mono text-sm text-ink-3">15 components · light + dark</p>
|
15 components · toggle Evening service in the rail for dark
|
||||||
</div>
|
</p>
|
||||||
<IconButton
|
|
||||||
icon={dark ? 'sun' : 'moon'}
|
|
||||||
label={dark ? 'Switch to light' : 'Switch to dark'}
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDark((d) => !d)}
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Section title="Button">
|
<Section title="Button">
|
||||||
|
|||||||
278
apps/desktop/src/renderer/src/components/shell/app-shell.tsx
Normal file
278
apps/desktop/src/renderer/src/components/shell/app-shell.tsx
Normal file
@@ -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<Record<View, string>> = {
|
||||||
|
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<Record<View, string>> = {
|
||||||
|
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<View>('focus')
|
||||||
|
const [prevView, setPrevView] = useState<View>('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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(item.id)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
font: `${active ? 600 : 500} 13.5px/1 var(--font-sans)`,
|
||||||
|
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
|
||||||
|
background: active ? 'var(--paper-2)' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-2)',
|
||||||
|
padding: '9px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background var(--duration-fast) var(--ease-out)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} size={16} style={{ color: active ? 'var(--accent-text)' : 'var(--ink-3)' }} />
|
||||||
|
{item.label}
|
||||||
|
{item.count ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
font: '500 10.5px/16px var(--font-mono)',
|
||||||
|
color: 'var(--accent-text)',
|
||||||
|
background: 'var(--spruce-2)',
|
||||||
|
borderRadius: 'var(--radius-round)',
|
||||||
|
padding: '0 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderScreen = () => {
|
||||||
|
switch (view) {
|
||||||
|
case 'states':
|
||||||
|
return <StatesScreen onCapture={() => setView('capture')} />
|
||||||
|
case 'primitives':
|
||||||
|
return <PrimitivesGallery />
|
||||||
|
case 'issue':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(prevView)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
font: 'var(--text-small)',
|
||||||
|
color: 'var(--ink-2)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size={15} /> Back
|
||||||
|
</button>
|
||||||
|
<PlaceholderScreen title={TITLE.issue!} phase={PHASE.issue!} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<PlaceholderScreen
|
||||||
|
title={TITLE[view] ?? view}
|
||||||
|
phase={PHASE[view] ?? 'a later phase'}
|
||||||
|
onOpenIssue={() => openIssue(1)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-screen-label={`app-${view}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: '100vh',
|
||||||
|
minWidth: 1280,
|
||||||
|
background: 'var(--surface-app)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* left rail */}
|
||||||
|
<nav
|
||||||
|
aria-label="Primary"
|
||||||
|
style={{
|
||||||
|
width: 208,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
padding: '18px 12px 14px',
|
||||||
|
borderRight: '1px solid var(--line-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '0 12px 16px' }}>
|
||||||
|
<img src={logoIcon} width="24" height="24" alt="" style={{ borderRadius: 6, display: 'block' }} />
|
||||||
|
<span style={{ font: '400 21px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
|
||||||
|
Commi<span style={{ color: 'var(--accent-text)' }}>Tea</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{NAV.map((n) => (
|
||||||
|
<NavItem key={n.id} item={n} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
|
||||||
|
<NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} />
|
||||||
|
<NavItem item={{ id: 'firstrun', label: 'First run', icon: 'play' }} />
|
||||||
|
<NavItem item={{ id: 'states', label: 'States', icon: 'circle-dashed' }} />
|
||||||
|
<NavItem item={{ id: 'primitives', label: 'Primitives', icon: 'layers' }} />
|
||||||
|
|
||||||
|
<div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOffline((o) => !o)}
|
||||||
|
title="Toggle the connection (demo)"
|
||||||
|
aria-label={offline ? 'Connection: offline' : 'Connection: online'}
|
||||||
|
style={{
|
||||||
|
font: '400 11px var(--font-mono)',
|
||||||
|
color: 'var(--ink-3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: offline ? 'var(--danger)' : 'var(--ok)',
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
gitea.stephenmann.io
|
||||||
|
</button>
|
||||||
|
<Switch
|
||||||
|
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}
|
||||||
|
checked={dark}
|
||||||
|
onChange={(e) => setDark(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* main */}
|
||||||
|
<main style={{ flex: 1, minWidth: 0, overflowY: 'auto', padding: '24px 28px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 1120,
|
||||||
|
margin: '0 auto',
|
||||||
|
height: view === 'board' ? '100%' : 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{offline ? <OfflineBanner /> : null}
|
||||||
|
{renderScreen()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ChatPanel onOpenDirectives={() => setView('directives')} offline={offline} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
167
apps/desktop/src/renderer/src/components/shell/chat-panel.tsx
Normal file
167
apps/desktop/src/renderer/src/components/shell/chat-panel.tsx
Normal file
@@ -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<ChatMessage[]>(CHAT)
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [thinking, setThinking] = useState(false)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width: 330,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'var(--surface-card)',
|
||||||
|
borderLeft: '1px solid var(--line-1)',
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--line-1)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
|
||||||
|
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
|
||||||
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
|
||||||
|
{offline ? 'offline · queueing' : 'gemma-4b · local'}
|
||||||
|
</span>
|
||||||
|
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ flex: 1, overflowY: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0 }}
|
||||||
|
>
|
||||||
|
{msgs.map((m, i) =>
|
||||||
|
m.from === 'agent' ? (
|
||||||
|
<div key={i} style={{ font: 'var(--text-agent)', color: 'var(--ink-1)', lineHeight: 1.55 }}>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
maxWidth: '85%',
|
||||||
|
background: 'var(--paper-2)',
|
||||||
|
borderRadius: '10px 10px 2px 10px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
font: 'var(--text-small)',
|
||||||
|
color: 'var(--ink-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{offline ? (
|
||||||
|
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>
|
||||||
|
The model is away from its desk. Reads still work; writes will wait their turn.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering…</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
background: 'var(--paper-0)',
|
||||||
|
border: '1px solid var(--line-2)',
|
||||||
|
borderRadius: 'var(--radius-2)',
|
||||||
|
padding: '8px 8px 8px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={offline ? 'Writes wait for the connection…' : 'Tell me what to do…'}
|
||||||
|
disabled={offline}
|
||||||
|
rows={1}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
resize: 'none',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
font: 'var(--text-body)',
|
||||||
|
color: 'var(--ink-1)',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
maxHeight: 96,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={send}
|
||||||
|
aria-label="Send"
|
||||||
|
disabled={offline}
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 'var(--radius-2)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: offline ? 'not-allowed' : 'pointer',
|
||||||
|
background: offline ? 'var(--paper-3)' : 'var(--accent)',
|
||||||
|
color: offline ? 'var(--ink-3)' : 'var(--ink-inverse)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="send" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 2px 0' }}>
|
||||||
|
Chat is the write-path. Destructive changes are proposed, never assumed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Button } from '../ui/index.js'
|
||||||
|
import { EmptyState } from './states.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in for views not yet built (Focus, Board, Runway, …). Keeps navigation
|
||||||
|
* live so the shell is demonstrable before the real screens land in P3-3+.
|
||||||
|
* `onOpenIssue`, when given, exercises the shell's issue drill-in + back-stack;
|
||||||
|
* real screens replace this with actual issue rows in P3-3+.
|
||||||
|
*/
|
||||||
|
export function PlaceholderScreen({
|
||||||
|
title,
|
||||||
|
phase,
|
||||||
|
onOpenIssue,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
phase: string
|
||||||
|
onOpenIssue?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
||||||
|
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>{title}</h1>
|
||||||
|
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
|
||||||
|
arrives in {phase} · the shell and navigation are live
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<EmptyState
|
||||||
|
icon="coffee"
|
||||||
|
title="Not yet poured"
|
||||||
|
line="This view is on the boil for a later phase. The rail, routing, theme, and Reginald’s panel are already working around it."
|
||||||
|
/>
|
||||||
|
{onOpenIssue ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Button variant="ghost" iconRight="arrow-right" onClick={onOpenIssue}>
|
||||||
|
Preview an issue page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
232
apps/desktop/src/renderer/src/components/shell/states.tsx
Normal file
232
apps/desktop/src/renderer/src/components/shell/states.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Badge, Button, Icon } from '../ui/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared empty/trouble states + the States gallery screen, ported from the
|
||||||
|
* handoff. EmptyState/OfflineBanner/ModelAwayState are reused across real
|
||||||
|
* screens as they land; StatesScreen is the specimen gallery.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EmptyStateProps {
|
||||||
|
icon: string
|
||||||
|
title: React.ReactNode
|
||||||
|
line: React.ReactNode
|
||||||
|
action?: string
|
||||||
|
onAction?: () => void
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon, title, line, action, onAction, compact }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: compact ? '28px 20px' : '52px 24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--paper-2)',
|
||||||
|
color: 'var(--ink-3)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size={19} />
|
||||||
|
</span>
|
||||||
|
<div style={{ font: '400 20px/1.25 var(--font-serif-display)', color: 'var(--ink-1)' }}>{title}</div>
|
||||||
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0, maxWidth: 380 }}>{line}</p>
|
||||||
|
{action ? (
|
||||||
|
<Button style={{ marginTop: 6 }} onClick={onAction}>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineBannerProps {
|
||||||
|
retryIn?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfflineBanner({ retryIn }: OfflineBannerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
background: 'var(--warn-tint)',
|
||||||
|
border: '1px solid var(--warn)',
|
||||||
|
borderRadius: 'var(--radius-2)',
|
||||||
|
padding: '9px 14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--warn)', display: 'inline-flex' }}>
|
||||||
|
<Icon name="triangle-alert" size={15} />
|
||||||
|
</span>
|
||||||
|
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}>
|
||||||
|
Gitea isn’t answering. I’ll keep trying and say nothing more about it.
|
||||||
|
</span>
|
||||||
|
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>
|
||||||
|
retry in {retryIn || '0:12'} · reads from cache
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelAwayState() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '14px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ color: 'var(--ink-3)', display: 'inline-flex' }}>
|
||||||
|
<Icon name="sparkles" size={15} />
|
||||||
|
</span>
|
||||||
|
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-2)' }}>Reginald</span>
|
||||||
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>model offline</span>
|
||||||
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
|
<Badge>queued: 1 directive</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
|
The model is away from its desk. Reads still work; writes will wait their turn.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{label}</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px dashed var(--line-2)',
|
||||||
|
borderRadius: 'var(--radius-3)',
|
||||||
|
background: 'var(--surface-card)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatesScreen({ onCapture }: { onCapture?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
||||||
|
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>States</h1>
|
||||||
|
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
|
||||||
|
empty & trouble · specimens as wired in the app
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
font: 'var(--text-overline)',
|
||||||
|
letterSpacing: 'var(--letter-spacing-wide)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--ink-3)',
|
||||||
|
margin: '4px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Empty
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||||
|
<Specimen label="the pot · no issues">
|
||||||
|
<EmptyState
|
||||||
|
compact
|
||||||
|
icon="inbox"
|
||||||
|
title="The pot is empty"
|
||||||
|
line="Tell me what you’re planning and I’ll draw up the tickets."
|
||||||
|
action="Start a capture"
|
||||||
|
onAction={onCapture}
|
||||||
|
/>
|
||||||
|
</Specimen>
|
||||||
|
<Specimen label="morning service · nothing scheduled">
|
||||||
|
<EmptyState
|
||||||
|
compact
|
||||||
|
icon="coffee"
|
||||||
|
title="Nothing to pour"
|
||||||
|
line="Capture some work, or enjoy the silence — it never lasts."
|
||||||
|
/>
|
||||||
|
</Specimen>
|
||||||
|
<Specimen label="directives · no entries">
|
||||||
|
<EmptyState
|
||||||
|
compact
|
||||||
|
icon="flag"
|
||||||
|
title="No directives yet"
|
||||||
|
line="When you overrule the scheduler, it goes on the record here — who, when, what, why."
|
||||||
|
/>
|
||||||
|
</Specimen>
|
||||||
|
<Specimen label="board search · no match">
|
||||||
|
<EmptyState
|
||||||
|
compact
|
||||||
|
icon="search"
|
||||||
|
title="Nothing by that name"
|
||||||
|
line="The pot holds 24 issues; none of them answer to that."
|
||||||
|
/>
|
||||||
|
</Specimen>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
font: 'var(--text-overline)',
|
||||||
|
letterSpacing: 'var(--letter-spacing-wide)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--ink-3)',
|
||||||
|
margin: '8px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Trouble
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<Specimen label="gitea unreachable · banner above main content (toggle the rail’s connection dot to see it live)">
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
<OfflineBanner />
|
||||||
|
</div>
|
||||||
|
</Specimen>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||||
|
<Specimen label="reginald’s panel · model offline">
|
||||||
|
<ModelAwayState />
|
||||||
|
</Specimen>
|
||||||
|
<Specimen label="webhooks down · poll fallback">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px' }}>
|
||||||
|
<Badge tone="warn" dot>
|
||||||
|
webhooks down
|
||||||
|
</Badge>
|
||||||
|
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)' }}>
|
||||||
|
polling every 2 min · updates may lag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Specimen>
|
||||||
|
</div>
|
||||||
|
<Specimen label="first reconcile failed · full-screen">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 20px' }}>
|
||||||
|
<EmptyState
|
||||||
|
compact
|
||||||
|
icon="refresh-cw"
|
||||||
|
title="The reconcile failed"
|
||||||
|
line="Gitea answered, then hung up mid-sentence. Your cache is intact; nothing human is lost."
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: -6 }}>
|
||||||
|
<Button>Try again</Button>
|
||||||
|
<Button variant="ghost">Work from cache</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Specimen>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
apps/desktop/src/renderer/src/data/fixtures.ts
Normal file
26
apps/desktop/src/renderer/src/data/fixtures.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Demo fixtures mirrored from the design handoff's `data.js`. Stand-ins until
|
||||||
|
* P1 fills the SQLite cache and P4 wires the real agent. Replace, don't extend
|
||||||
|
* into production code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
from: 'agent' | 'user'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAT: ChatMessage[] = [
|
||||||
|
{ from: 'agent', text: 'Morning service. Two things drifted overnight; one needs your opinion.' },
|
||||||
|
{
|
||||||
|
from: 'agent',
|
||||||
|
text: '#84 grew a dependency on #92 — I’ve reordered. And #87 has been steeping for four days; it blocks two others. Worth a look.',
|
||||||
|
},
|
||||||
|
{ from: 'user', text: 'Push the calibration work to next week, pilots come first' },
|
||||||
|
{
|
||||||
|
from: 'agent',
|
||||||
|
text: 'Done — #78 moves to next week. Milestone Beta is unmoved; the cone doesn’t care for calibration either. Logged as a directive.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CANNED_REPLY =
|
||||||
|
'Noted and logged as a directive. The scheduler is re-running — I’ll show you the consequence diff in a moment.'
|
||||||
1
apps/desktop/src/renderer/src/vite-env.d.ts
vendored
Normal file
1
apps/desktop/src/renderer/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user