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>
422 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|