Files
commitea/apps/desktop/src/renderer/src/components/shell/app-shell.tsx
Croissant Le Doux 0dd26b8b05 feat: config & onboarding — each teammate connects their own Gitea (Phase A)
The foundation for a shareable team build. Replaces the .env.local-only dev config
with a real, per-teammate connection flow.

main:
- config-store.ts: token encrypted at rest via Electron safeStorage (OS keychain),
  config JSON in userData. Token lives only in main; renderer gets everything but.
- resolveConfig: saved config > .env.local (dev) > null; ignored under COMMITEA_E2E.
  pm-state repo defaults to `${repo}-pm-state`. resetClients() re-reads on change so
  saving config takes effect without a restart. gitea:status gains `demo` (e2e).
- IPC: config:get (no token), config:test (authed read validates token+repo),
  config:set (encrypt+save+reset), config:clear. Model bridge reads config.modelUrl
  and probes reachability — chat is "configured" only if a model actually answers;
  localhost default is dev-only (app.isPackaged gate).

renderer:
- ConnectScreen: real onboarding form (URL/owner/repo/PAT/optional model) → test →
  save. AppShell gates on it: demo → shell (fixtures/e2e); configured → shell (real);
  else → connect. Settings Connection card is real (repo/url/model/sidecar) with
  Reconfigure + Disconnect. Chat cleanly disables with a "no model" state instead of
  the scripted canned reply.

Verified: main + desktop typecheck clean, 14 fixture e2e green (demo mode unchanged),
live onboarding e2e: fresh app → connect form → validated PAT → real board (24 done /
10 open). COMMITEA_NO_ENV_LOCAL + COMMITEA_USERDATA are test hooks for the onboarding path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:09:53 -04:00

422 lines
14 KiB
TypeScript

import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueChange } from '@commitea/core'
import type { PublicConfig } from '../../global.js'
import type { IssueRef } from '../../data/fixtures.js'
import { ConnectScreen } from '../screens/connect-screen.js'
import {
backlogCalibration,
capacityWorkers,
forecastBacklog,
issuesToBoardColumns,
milestoneView,
runwayView,
scheduleFocus,
} from '../../lib/backlog.js'
import { useBacklog } from '../../lib/use-backlog.js'
import { useCapacity } from '../../lib/use-capacity.js'
import { PrimitivesGallery } from '../gallery.js'
import { BoardScreen } from '../screens/board-screen.js'
import { CalibrationScreen } from '../screens/calibration-screen.js'
import { CaptureScreen } from '../screens/capture-screen.js'
import { DirectivesScreen } from '../screens/directives-screen.js'
import { FocusScreen } from '../screens/focus-screen.js'
import { InboxScreen } from '../screens/inbox-screen.js'
import { IssueScreen } from '../screens/issue-screen.js'
import { MilestoneScreen } from '../screens/milestone-screen.js'
import { OnboardingScreen } from '../screens/onboarding-screen.js'
import { RunwayScreen } from '../screens/runway-screen.js'
import { SettingsScreen } from '../screens/settings-screen.js'
import { StandupScreen } from '../screens/standup-screen.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)
const [issue, setIssue] = useState<IssueRef | null>(null)
const [readIds, setReadIds] = useState<number[]>([])
const [milestoneId, setMilestoneId] = useState<number | null>(null)
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
const [backlog, refetchBacklog] = useBacklog()
const capacityMembers = useCapacity()
const workers = capacityWorkers(capacityMembers)
const boardColumns =
backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
const focus =
backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined
const calibration =
backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined
const forecast =
backlog.status === 'ready'
? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model, workers) ?? undefined)
: undefined
const runwayMilestones =
backlog.status === 'ready'
? runwayView(backlog.issues, backlog.milestones, backlog.deps, new Date(), workers)
: undefined
const milestone =
backlog.status === 'ready' && milestoneId != null
? (milestoneView(
milestoneId,
backlog.issues,
backlog.milestones,
backlog.deps,
backlog.timelines,
calibration?.model,
new Date(),
workers,
) ?? undefined)
: undefined
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
}, [dark])
// Gate: e2e/demo → the shell (fixtures); configured → the shell (real data);
// otherwise → the connect screen (each teammate brings their own token).
useEffect(() => {
window.commitea.gitea
.status()
.then((s) => setGate(s.demo || s.configured ? 'ready' : 'connect'))
.catch(() => setGate('connect'))
window.commitea.config.get().then(setPubConfig).catch(() => {})
}, [])
const openIssue = (ref: IssueRef) => {
if (view !== 'issue') setPrevView(view)
setIssue(ref)
setView('issue')
}
// The write path: apply through the bridge, reflect the new labels on the open
// issue immediately, and re-reconcile so the board + forecast catch up.
const applyChange = async (change: IssueChange) => {
const res = await window.commitea.gitea.applyChange(change)
if (res.ok) {
setIssue((cur) => (cur && cur.id === res.issue.number ? { ...cur, labels: res.issue.labels } : cur))
refetchBacklog()
}
return res
}
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 'focus':
return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} />
case 'standup':
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
case 'board':
return (
<BoardScreen
onOpenIssue={openIssue}
columns={boardColumns}
loading={backlog.status === 'loading'}
/>
)
case 'runway':
return (
<RunwayScreen
onOpenCalibration={() => setView('calibration')}
onOpenMilestone={(id) => {
setMilestoneId(id ?? null)
setView('milestone')
}}
calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined}
milestones={runwayMilestones}
capacity={capacityMembers}
/>
)
case 'calibration':
return <CalibrationScreen onBack={() => setView('runway')} data={calibration?.data} />
case 'milestone':
return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} data={milestone} />
case 'inbox':
return (
<InboxScreen
onOpenIssue={openIssue}
onOpenDirectives={() => setView('directives')}
readIds={readIds}
setReadIds={setReadIds}
/>
)
case 'capture':
return <CaptureScreen onDone={() => setView('focus')} />
case 'directives':
return <DirectivesScreen />
case 'settings':
return (
<SettingsScreen
dark={dark}
setDark={setDark}
connection={pubConfig}
onReconnect={() => setGate('connect')}
onDisconnect={() => {
void window.commitea.config.clear().then(() => {
setPubConfig(null)
setGate('connect')
})
}}
/>
)
case 'issue':
return issue ? (
<IssueScreen
issue={issue}
onBack={() => setView(prevView)}
onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'}
onApplyChange={applyChange}
/>
) : null
case 'states':
return <StatesScreen onCapture={() => setView('capture')} />
case 'primitives':
return <PrimitivesGallery />
default:
return <PlaceholderScreen title={TITLE[view] ?? view} phase={PHASE[view] ?? 'a later phase'} />
}
}
// Connection gate (real onboarding) comes before everything else.
if (gate === 'checking') {
return <div style={{ height: '100vh', background: 'var(--surface-app)' }} />
}
if (gate === 'connect') {
return (
<ConnectScreen
existing={pubConfig}
onConnected={() => {
window.commitea.config.get().then(setPubConfig).catch(() => {})
setGate('ready')
refetchBacklog()
}}
/>
)
}
// First run is full-window — no rail, no chat panel
if (view === 'firstrun') {
return <OnboardingScreen onDone={(dest) => setView(dest)} />
}
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} onApplyChange={applyChange} />
</div>
)
}