Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion WORKBOOK_v6.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ schema_version: 6
product: ordinary-user-loop-os
version_target: loop-os-v1
current_phase: V6-P6 # V6-P0..P6,见 §3;P4/P5 代码随本 PR 落地
current_substep: v6p3_hold_real_proof_credentials # P3 真证明仍待操作员(真 Draft PR + 真判词)
current_substep: overnight_p1_p3_p4_p5_p6_done_p2_hold_planner_auth # P3 真证明仍待操作员(真 Draft PR + 真判词)
last_session_id: s_v6_0003
open_holds: 0
blocked_on: operator_real_proof
Expand Down
32 changes: 32 additions & 0 deletions apps/dashboard/src/pages/Cockpit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,38 @@ describe('Draft PR gate card · GEMINI_NOT_CONFIGURED is humanized', () => {
})
})

// overnight-p3 — the loop card is an operator console: the daemon's
// primaryAction renders ON the card (cockpit-card-action) through the SAME
// handler plumbing as the guidance buttons, and the agent strip shows who is
// working — no logs needed.
describe('CockpitPage · loop card carries the primary action + agent strip (overnight-p3)', () => {
it('renders primaryAction on the card and clicking it calls the existing handler', async () => {
const session = { id: 's1', missionId: 'm1', title: 'M', prompt: 'p', status: 'approved', createdAt: '2026-01-01', updatedAt: '2026-01-01' }
apiMock.getLatestOperatorSession.mockResolvedValue({ session, messages: [] })
const overview = makeOverview()
overview.operatorView!.primaryAction = { id: 'start-execution', label: 'Start Execution · 启动执行', kind: 'primary' }
overview.operatorView!.card = {
type: 'progress',
title: '可以开始执行 · Ready to start',
next_step: '点击启动执行后才会动手 · Work starts when you click start.',
machine: { user_state: 'ready_to_execute', stage: 'approved', hold_code: null, pr_gate_code: null },
current_phase: '可以开始执行 · Ready to start',
current_action: '等待启动 · Waiting for your start.',
evidence_links: [],
tests_run: [],
}
apiMock.getMissionOverview.mockResolvedValue(overview)
apiMock.startOperatorSession.mockResolvedValue({ session, overview })
render(<CockpitPage />)
const btn = await screen.findByTestId('cockpit-card-action')
expect(btn.textContent).toContain('Start Execution')
expect(btn.getAttribute('data-action-id')).toBe('start-execution')
expect(screen.getByTestId('cockpit-card-agents')).toBeTruthy()
fireEvent.click(btn)
await waitFor(() => expect(apiMock.startOperatorSession).toHaveBeenCalledWith('s1'))
})
})

describe('buildGuidance · inline decision flow (#2: no Approve/Start after Generate)', () => {
const base = {
overview: null,
Expand Down
73 changes: 54 additions & 19 deletions apps/dashboard/src/pages/Cockpit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,36 @@ const ACTION_TEST_IDS: Record<string, string> = {
'start-over': 'cockpit-start-over',
}

/** The single handler set behind every primary-action surface (guidance + loop card). */
interface PrimaryActionHandlers {
onBrainstorm: () => void
onRoadmap: () => void
onApprove: () => void
onStart: () => void
onPause: () => void
onResume: () => void
onDraftPr: () => void
onReset: () => void
}

/**
* overnight-p3 — ONE id→handler map shared by the guidance buttons and the
* on-card action button (LoopCard `onAction`), so the card never duplicates
* the action plumbing.
*/
export function resolvePrimaryActionHandler(id: string, h: PrimaryActionHandlers): (() => void) | undefined {
return ({
'generate-plan': h.onRoadmap,
'approve-roadmap': h.onApprove,
'start-execution': h.onStart,
'check-draft-pr-gate': h.onDraftPr,
resume: h.onResume,
pause: h.onPause,
'start-over': h.onReset,
'start-brainstorm': h.onBrainstorm,
} as Record<string, () => void>)[id]
}

export function CockpitPage({ onNavigate }: { onNavigate?: (tab: string) => void } = {}) {
const sse = useSSE('/api/events/stream')
const [pendingApprovals, setPendingApprovals] = useState(0)
Expand Down Expand Up @@ -200,14 +230,9 @@ export function CockpitPage({ onNavigate }: { onNavigate?: (tab: string) => void
}
}

const guidance = buildGuidance({
session,
overview,
busy,
messages,
latestHold,
sseConnected: sse.connected,
operatorView: overview?.operatorView,
// overnight-p3 — the ONE handler set behind every action surface: the
// guidance buttons and the loop card's on-card action share this plumbing.
const actionHandlers = {
onBrainstorm: () => action('create', () => api.createOperatorSession({ repoId, title, prompt }), (x) => { setSession(x.session); setMessages(x.messages); setOverview(null); setDraftPrStatus(null) }),
onRoadmap: () => session && action('roadmap', () => api.generateRoadmap(session.id), (x) => { setSession(x.session); setMessages(x.messages); if (x.mission) void api.getMissionOverview(x.mission.id).then(setOverview) }),
onApprove: () => session && action('approve', () => api.approveRoadmap(session.id), (x) => { setSession(x.session); setOverview(x.overview) }),
Expand All @@ -217,6 +242,16 @@ export function CockpitPage({ onNavigate }: { onNavigate?: (tab: string) => void
onDraftPr: () => session && action('draft-pr', () => api.createDraftPr(session.id), (x) => { setOverview(x.overview); setDraftPrStatus({ status: x.status, code: x.code, reason: x.reason, url: x.pr?.url, number: x.pr?.number }) }),
onReset: resetMission,
onStop: () => session?.missionId && action('stop', () => api.stopOperatorSession(session.id), (x) => { setSession(x.session); setOverview(x.overview) }),
}
const guidance = buildGuidance({
session,
overview,
busy,
messages,
latestHold,
sseConnected: sse.connected,
operatorView: overview?.operatorView,
...actionHandlers,
})

const hasSession = Boolean(session)
Expand Down Expand Up @@ -351,7 +386,16 @@ export function CockpitPage({ onNavigate }: { onNavigate?: (tab: string) => void
)}
{notice && <div className="ck-banner notice">{notice}</div>}

{loopCard && <LoopCard card={loopCard} />}
{loopCard && (
<LoopCard
card={loopCard}
action={operatorView?.primaryAction && resolvePrimaryActionHandler(operatorView.primaryAction.id, actionHandlers) ? operatorView.primaryAction : undefined}
onAction={(a) => resolvePrimaryActionHandler(a.id, actionHandlers)?.()}
busy={Boolean(busy)}
prGate={operatorView?.safetySummary.prGate}
lastActivityPhase={operatorView?.lastActivity?.phase}
/>
)}

<ChatThread
messages={messages}
Expand Down Expand Up @@ -608,16 +652,7 @@ export function buildGuidance(opts: {
}
if (opts.operatorView?.primaryAction) {
const action = opts.operatorView.primaryAction
const click = ({
'generate-plan': opts.onRoadmap,
'approve-roadmap': opts.onApprove,
'start-execution': opts.onStart,
'check-draft-pr-gate': opts.onDraftPr,
resume: opts.onResume,
pause: opts.onPause,
'start-over': opts.onReset,
'start-brainstorm': opts.onBrainstorm,
} as Record<string, () => void>)[action.id]
const click = resolvePrimaryActionHandler(action.id, opts)
return {
kicker: opts.operatorView.stageLabel,
title: opts.operatorView.summary,
Expand Down
180 changes: 178 additions & 2 deletions apps/dashboard/src/pages/cockpit/LoopCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
* - the `machine` sub-object is NEVER rendered as visible text — raw codes
* (HOLD-*, gate codes, stage tokens) live only in data-* attributes.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, cleanup } from '@testing-library/react'
import { describe, it, expect, afterEach, vi } from 'vitest'
import { render, cleanup, fireEvent } from '@testing-library/react'
import { LoopCard } from './LoopCard.js'
import type {
ApiBlockerLoopCard,
Expand Down Expand Up @@ -174,6 +174,182 @@ describe('LoopCard — per-type content', () => {
})
})

// ---- overnight-p3 — operator console upgrades -------------------------------
// The five cards must tell a new user what the system is doing and what to do
// next WITHOUT logs: operator vocabulary titles, an agent strip, the primary
// action ON the card, evidence entries, and PR-gate transparency.

function progressAt(stage: string): ApiProgressLoopCard {
const card = progress()
card.machine = { ...card.machine, stage }
return card
}

function gateBlocker(code: string): ApiBlockerLoopCard {
const card = blocker()
card.machine = { user_state: 'blocked', stage: 'pr_blocked', hold_code: null, pr_gate_code: code }
card.human_explanation = '为了安全,PR 暂时不能开 · For safety the draft PR cannot be opened yet.'
return card
}

describe('LoopCard — operator vocabulary titles (理解/计划/构建/验证/合并)', () => {
it('maps card types to the operator stage vocabulary', () => {
expect(renderCard(understanding()).textContent).toContain('理解 · Understand')
cleanup()
expect(renderCard(plan()).textContent).toContain('计划 · Plan')
cleanup()
expect(renderCard(prReady(false)).textContent).toContain('合并 · PR·Merge')
})

it('progress splits VISUALLY into Build (running) vs Verify (evidence/validating); data-card-type stays progress', () => {
const build = renderCard(progressAt('running'))
expect(build.textContent).toContain('构建 · Build')
expect(build.textContent).not.toContain('验证 · Verify')
expect(build.getAttribute('data-card-type')).toBe('progress')
cleanup()
for (const stage of ['evidence_ready', 'validating', 'validators_missing', 'validators_ready']) {
const verify = renderCard(progressAt(stage))
expect(verify.textContent).toContain('验证 · Verify')
expect(verify.textContent).not.toContain('构建 · Build')
expect(verify.getAttribute('data-card-type')).toBe('progress')
cleanup()
}
})
})

describe('LoopCard — agent strip: who is working (cockpit-card-agents)', () => {
function strip(root: HTMLElement): HTMLElement {
const el = root.querySelector('[data-testid="cockpit-card-agents"]') as HTMLElement
expect(el).toBeTruthy()
return el
}

it('every card lists the four-agent team (Claude/Codex/Gemini/GitHub)', () => {
const cards: ApiLoopCard[] = [understanding(), plan(), progress(), blocker(), prReady(false)]
for (const card of cards) {
const el = strip(renderCard(card))
for (const name of ['Claude', 'Codex', 'Gemini', 'GitHub']) expect(el.textContent).toContain(name)
cleanup()
}
})

it('highlights the active agent per card type and machine stage', () => {
expect(strip(renderCard(understanding())).getAttribute('data-active-agent')).toBe('claude')
cleanup()
expect(strip(renderCard(plan())).getAttribute('data-active-agent')).toBe('claude')
cleanup()
expect(strip(renderCard(progressAt('running'))).getAttribute('data-active-agent')).toBe('codex')
cleanup()
expect(strip(renderCard(progressAt('validating'))).getAttribute('data-active-agent')).toBe('gemini')
cleanup()
expect(strip(renderCard(prReady(true))).getAttribute('data-active-agent')).toBe('github')
})

it('blocker: falls back to the last activity phase for the active agent', () => {
const { container } = render(<LoopCard card={blocker()} lastActivityPhase="executing" />)
const el = container.querySelector('[data-testid="cockpit-card-agents"]') as HTMLElement
expect(el.getAttribute('data-active-agent')).toBe('codex')
})
})

describe('LoopCard — next-step action button ON the card (cockpit-card-action)', () => {
const action = { id: 'approve-roadmap', label: 'Approve Roadmap · 批准路线' }

it('renders the primary action as the card action button and forwards clicks', () => {
const onAction = vi.fn()
const { container } = render(<LoopCard card={plan()} action={action} onAction={onAction} />)
const btn = container.querySelector('[data-testid="cockpit-card-action"]') as HTMLButtonElement
expect(btn).toBeTruthy()
expect(btn.textContent).toContain('Approve Roadmap')
expect(btn.getAttribute('data-action-id')).toBe('approve-roadmap')
fireEvent.click(btn)
expect(onAction).toHaveBeenCalledWith(action)
})

it('renders no card action button without a primary action', () => {
const { container } = render(<LoopCard card={plan()} />)
expect(container.querySelector('[data-testid="cockpit-card-action"]')).toBeNull()
})

it('disables the card action while busy', () => {
const { container } = render(<LoopCard card={plan()} action={action} onAction={() => undefined} busy />)
expect((container.querySelector('[data-testid="cockpit-card-action"]') as HTMLButtonElement).disabled).toBe(true)
})
})

describe('LoopCard — blocker recovery actions: list with the recommended one emphasized', () => {
it('marks the recommended recovery action', () => {
const root = renderCard(blocker())
const list = root.querySelector('[data-testid="cockpit-card-recovery"]') as HTMLElement
expect(list).toBeTruthy()
const items = Array.from(list.querySelectorAll('li'))
expect(items.length).toBe(2)
expect(items[0]?.getAttribute('data-recommended')).toBe('true')
expect(items[0]?.querySelector('strong')).toBeTruthy()
expect(items[1]?.getAttribute('data-recommended')).toBe('false')
expect(items[1]?.querySelector('strong')).toBeNull()
})
})

describe('LoopCard — evidence entries (cockpit-card-evidence)', () => {
it('progress: evidence links render as clickable-looking entries', () => {
const root = renderCard(progress())
const entries = root.querySelectorAll('[data-testid="cockpit-card-evidence"]')
expect(entries.length).toBe(1)
expect(entries[0]?.textContent).toContain('evidence/run-1/gate.json')
})

it('pr_ready: changed files render as evidence entries', () => {
const root = renderCard(prReady(false))
const entries = root.querySelectorAll('[data-testid="cockpit-card-evidence"]')
expect(entries.length).toBe(1)
expect(entries[0]?.textContent).toContain('src/a.ts')
})
})

describe('LoopCard — PR gate transparency: why / who / next, never raw codes', () => {
it('blocker via the Gemini gate shows the three lines and credits Gemini', () => {
const root = renderCard(gateBlocker('GEMINI_NOT_PASS'))
const gate = root.querySelector('[data-testid="cockpit-card-pr-gate"]') as HTMLElement
expect(gate).toBeTruthy()
expect(gate.textContent).toContain('为什么')
expect(gate.textContent).toContain('谁说的')
expect(gate.textContent).toContain('下一步')
expect(gate.textContent).toContain('Gemini')
expect(gate.textContent).toContain('For safety the draft PR cannot be opened yet')
expect(root.textContent).not.toContain('GEMINI_NOT_PASS')
})

it('blocker via the policy gate credits the safety gate (安全门), never the raw code', () => {
const root = renderCard(gateBlocker('REMOTE_WRITES_DISABLED'))
const gate = root.querySelector('[data-testid="cockpit-card-pr-gate"]') as HTMLElement
expect(gate.textContent).toContain('安全门')
expect(root.textContent).not.toContain('REMOTE_WRITES_DISABLED')
})

it('a HOLD blocker does not pretend to be a PR-gate decision', () => {
const root = renderCard(blocker()) // hold_code present → the hold, not the gate, blocks
expect(root.querySelector('[data-testid="cockpit-card-pr-gate"]')).toBeNull()
})

it('pr_ready with a checked gate explains why it can open, who said so and what is next', () => {
const { container } = render(
<LoopCard card={prReady(true)} prGate={{ status: 'created', reason: 'Draft PR URL is recorded on the mission.' }} />,
)
const gate = container.querySelector('[data-testid="cockpit-card-pr-gate"]') as HTMLElement
expect(gate).toBeTruthy()
expect(gate.textContent).toContain('为什么')
expect(gate.textContent).toContain('谁说的')
expect(gate.textContent).toContain('下一步')
expect(gate.textContent).toContain('Draft PR URL is recorded')
})

it('pr_ready without any gate info renders no gate section', () => {
const root = renderCard(prReady(false))
expect(root.querySelector('[data-testid="cockpit-card-pr-gate"]')).toBeNull()
})
})

describe('LoopCard — blocker card: human explanation, never raw codes', () => {
it('shows human_explanation, why_it_matters and recovery actions', () => {
const root = renderCard(blocker())
Expand Down
Loading
Loading