diff --git a/packages/framework-dashboard/components/ChoicePanel.tsx b/packages/framework-dashboard/components/ChoicePanel.tsx index 17de265..260ed9c 100644 --- a/packages/framework-dashboard/components/ChoicePanel.tsx +++ b/packages/framework-dashboard/components/ChoicePanel.tsx @@ -11,8 +11,17 @@ import { cn } from '../lib/utils.js' // multi-select checklist (#332), and the single-select list (#304). When Autopilot is on // it auto-accepts the recommended pick after a countdown (#433), which any mouse movement // cancels. The panel clears itself when the resulting `choice-resolved` event streams in -// (pendingChoice drops it); mount it with `key={choice.id}` so a re-fired gate resets state. -export function ChoicePanel({ projectId, choice }: { projectId: string; choice: ChoiceRequest }) { +// (pendingChoices drops it); mount it with `key={choice.id}` so a re-fired gate resets state. +// `active` (the first gate in the right rail, #440) binds Ctrl+Enter to Accept. +export function ChoicePanel({ + projectId, + choice, + active = false, +}: { + projectId: string + choice: ChoiceRequest + active?: boolean +}) { const [busy, setBusy] = useState(false) const [checked, setChecked] = useState>( () => new Set(choice.multi ? choice.options.filter(o => o.default).map(o => o.id) : []), @@ -36,9 +45,10 @@ export function ChoicePanel({ projectId, choice }: { projectId: string; choice: const approveId = choice.recommended ?? choice.options[0]?.id const declineId = choice.options.find(o => o.id !== approveId)?.id ?? approveId - // What the countdown accepts: the checked subset for a multi-select, else the - // recommended option (an Approve for a confirm gate). + // What Accept picks: the checked subset for a multi-select, else the recommended option + // (an Approve for a confirm gate). Shared by the button, the countdown, and Ctrl+Enter. const autoPick = (): string | string[] => (choice.multi ? [...checked] : (approveId ?? '')) + const accept = (by: 'user' | 'autopilot' = 'user') => post(autoPick(), by) // Any mouse movement cancels the auto-accept — the human is here, so let them pick. useEffect(() => { @@ -47,6 +57,21 @@ export function ChoicePanel({ projectId, choice }: { projectId: string; choice: return () => window.removeEventListener('mousemove', cancel) }, []) + // Ctrl+Enter accepts the recommended pick (page.ts parity, #440). Only the active gate + // (the first in the rail) binds it, so the shortcut is unambiguous with several gates open. + useEffect(() => { + if (!active) return + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !busy) { + e.preventDefault() + accept() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, busy, checked]) + // The countdown: tick down once a second while autopilot is on and uncancelled, then // auto-accept. Restarts if autopilot is toggled back on before a pick is made. useEffect(() => { @@ -61,7 +86,7 @@ export function ChoicePanel({ projectId, choice }: { projectId: string; choice: if (left <= 0) { clearInterval(timer) setSecondsLeft(null) - post(autoPick(), 'autopilot') + accept('autopilot') } else { setSecondsLeft(left) } @@ -145,6 +170,7 @@ export function ChoicePanel({ projectId, choice }: { projectId: string; choice: : secondsLeft !== null && `● Auto accept in ${secondsLeft}s — move the mouse to cancel`} )} + {active && !busy && Ctrl+Enter to accept} ) diff --git a/packages/framework-dashboard/components/ChoicesRail.tsx b/packages/framework-dashboard/components/ChoicesRail.tsx new file mode 100644 index 0000000..99911db --- /dev/null +++ b/packages/framework-dashboard/components/ChoicesRail.tsx @@ -0,0 +1,52 @@ +import { useRef } from 'react' +import type { ChoiceRequest } from '@gemstack/framework' +import { ChoicePanel } from './ChoicePanel.js' + +// The choice-gates rail (#440, part of #314): every gate the run is currently parked on, +// shown at once in the right rail as a long scroll instead of one inline gate. A sticky +// top-nav jumps between the sets; the first (topmost) gate is `active`, so Ctrl+Enter +// accepts it unambiguously. Gates clear themselves as their `choice-resolved` events stream +// in (pendingChoices drops them); an empty list means there is nothing to decide right now. +export function ChoicesRail({ projectId, choices }: { projectId: string; choices: ChoiceRequest[] }) { + const scroller = useRef(null) + const panels = useRef(new Map()) + + if (choices.length === 0) { + return

No choices to make right now.

+ } + + const jump = (id: string) => panels.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + + return ( +
+ {choices.length > 1 && ( + + )} +
+ {choices.map((c, i) => ( +
{ + if (el) panels.current.set(c.id, el) + else panels.current.delete(c.id) + }} + > + +
+ ))} +
+
+ ) +} diff --git a/packages/framework-dashboard/components/EventList.tsx b/packages/framework-dashboard/components/EventList.tsx index d570274..37c246c 100644 --- a/packages/framework-dashboard/components/EventList.tsx +++ b/packages/framework-dashboard/components/EventList.tsx @@ -20,7 +20,7 @@ export function EventList({ events, stick = true }: { events: FrameworkEvent[]; {events.map((e, i) => (
  • {e.kind} - {formatFrameworkEvent(e).trim()} + {(formatFrameworkEvent(e) ?? '').trim()}
  • ))} diff --git a/packages/framework-dashboard/components/EventStream.tsx b/packages/framework-dashboard/components/EventStream.tsx index 14fb10c..186f985 100644 --- a/packages/framework-dashboard/components/EventStream.tsx +++ b/packages/framework-dashboard/components/EventStream.tsx @@ -1,48 +1,31 @@ -import { useEffect, useState } from 'react' import type { FrameworkEvent } from '@gemstack/framework' -import type { ClientChannel } from 'telefunc' -import { onEvents } from '../server/events.telefunc.js' import { sendStop } from '../server/control.telefunc.js' -import { pendingChoice, isRunActive } from '../lib/live-state.js' +import { isRunActive } from '../lib/live-state.js' import { EventList } from './EventList.js' -import { ChoicePanel } from './ChoicePanel.js' import { StartRunForm } from './StartRunForm.js' import { RunOverview } from './RunOverview.js' import { Button } from './ui/button.js' -// The live event stream (#405/#314): a projection of the selected project's -// `.the-framework/events.jsonl`, streamed over a Telefunc Channel -// (server/events.telefunc.ts) that pushes one `FrameworkEvent` per new line. The same -// projection drives the write side (#405): the interactive gate the run parks on and -// a Stop button, both posted back over Telefunc (server/control.telefunc.ts). -export function EventStream({ projectId, readOnly = false }: { projectId: string | null; readOnly?: boolean }) { - const [events, setEvents] = useState([]) - - useEffect(() => { - setEvents([]) - if (!projectId) return - let channel: ClientChannel | undefined - let cancelled = false - void onEvents(projectId).then(ch => { - if (cancelled) { - void ch.close() - return - } - channel = ch - ch.listen(event => setEvents(prev => [...prev, event])) - }) - return () => { - cancelled = true - void channel?.close() - } - }, [projectId]) - +// The live event view (#405/#314): a projection of the selected project's +// `.the-framework/events.jsonl`, streamed over a Telefunc Channel by the shell's +// `useLiveEvents` hook and passed in as `events`. The main column shows the run overview, +// the event feed, and the Stop/Start controls; the interactive choice gates the run parks +// on now live in the right rail (#440), read from this same stream. +export function EventStream({ + projectId, + events, + readOnly = false, +}: { + projectId: string | null + events: FrameworkEvent[] + readOnly?: boolean +}) { if (!projectId) { return
    Select a project to watch its live run.
    } - // Read-only watch mode (the relay, #426): no steering (no Stop/Start, no interactive - // gate), just the run overview + the live event feed, both projected from the stream. + // Read-only watch mode (the relay, #426): no steering (no Stop/Start), just the run + // overview + the live event feed, both projected from the stream. if (readOnly) { if (events.length === 0) { return
    Waiting for the run to start…
    @@ -56,7 +39,6 @@ export function EventStream({ projectId, readOnly = false }: { projectId: string } const active = isRunActive(events) - const choice = pendingChoice(events) return ( <> {active ? ( @@ -68,7 +50,6 @@ export function EventStream({ projectId, readOnly = false }: { projectId: string ) : ( )} - {choice && } {events.length > 0 ? ( <> diff --git a/packages/framework-dashboard/components/RelayView.tsx b/packages/framework-dashboard/components/RelayView.tsx index e135a62..2cb8674 100644 --- a/packages/framework-dashboard/components/RelayView.tsx +++ b/packages/framework-dashboard/components/RelayView.tsx @@ -1,11 +1,13 @@ import { EventStream } from './EventStream.js' import { Badge } from './ui/badge.js' +import { useLiveEvents } from '../lib/use-live-events.js' // The shared-run watch view (#426/#230): when the dashboard is opened on the relay at // `/?run=`, it shows one run read-only, streamed from the relay's in-memory event // feed over the same Telefunc `onEvents` Channel the daemon uses. No Projects/Runs/Docs // rails and no steering — a teammate with the link watches, they do not drive. export function RelayView({ runId }: { runId: string }) { + const events = useLiveEvents(runId) return (
    @@ -14,7 +16,7 @@ export function RelayView({ runId }: { runId: string }) { read-only shared run
    - +
    ) diff --git a/packages/framework-dashboard/components/RightRail.tsx b/packages/framework-dashboard/components/RightRail.tsx index 1e1c445..d84b033 100644 --- a/packages/framework-dashboard/components/RightRail.tsx +++ b/packages/framework-dashboard/components/RightRail.tsx @@ -1,32 +1,57 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' +import type { ChoiceRequest } from '@gemstack/framework' import { DocsPanel } from './DocsPanel.js' import { ProjectLogPanel } from './ProjectLogPanel.js' +import { ChoicesRail } from './ChoicesRail.js' +import { Badge } from './ui/badge.js' import { Button } from './ui/button.js' import { cn } from '../lib/utils.js' -// The right sidebar (#314 third rail): tabs between the surfaced docs (PLAN/TODO) and -// the committed project log. Both are Telefunc-backed reads of the selected project. -export function RightRail({ projectId }: { projectId: string | null }) { - const [tab, setTab] = useState<'docs' | 'log'>('docs') +type Tab = 'choices' | 'docs' | 'log' + +// The right sidebar (#314 third rail): the interactive choice gates the run parks on +// (#440), the surfaced docs (PLAN/TODO), and the committed project log. Docs/log are +// Telefunc-backed reads of the selected project; the choices come from the live event +// stream, passed down from the shell. The rail jumps to Choices whenever a gate opens so +// the decision is in view. +export function RightRail({ projectId, choices }: { projectId: string | null; choices: ChoiceRequest[] }) { + const [tab, setTab] = useState('docs') + const hasChoices = choices.length > 0 + + // A gate opening pulls the rail to Choices; when the last one clears, fall back to Docs. + useEffect(() => { + setTab(hasChoices ? 'choices' : 'docs') + }, [hasChoices]) + if (!projectId) return null + const tabs: Tab[] = hasChoices ? ['choices', 'docs', 'log'] : ['docs', 'log'] + const label = (t: Tab) => (t === 'choices' ? 'Choices' : t === 'docs' ? 'Docs' : 'Project log') + return ( ) diff --git a/packages/framework-dashboard/lib/live-state.ts b/packages/framework-dashboard/lib/live-state.ts index e84e9b6..871f927 100644 --- a/packages/framework-dashboard/lib/live-state.ts +++ b/packages/framework-dashboard/lib/live-state.ts @@ -9,25 +9,30 @@ import type { FrameworkEvent, ChoiceRequest } from '@gemstack/framework' type ChoiceEvent = { kind: 'choice' } & ChoiceRequest /** - * The choice gate the run is currently parked on, or null. A `choice` event opens a - * gate; a matching `choice-resolved` (same id) closes it. Later events win, so a - * re-fired gate (#324 loops the plan approval with a fresh id) supersedes an earlier - * one, and a resolved gate never lingers. + * Every choice gate the run is currently parked on, in fire order. A `choice` event + * opens a gate; a matching `choice-resolved` (same id) closes it. A re-fired gate (a new + * `choice` with an id already open) replaces the earlier one in place; a resolved gate + * never lingers. The run can park on several gates at once (#440 shows them all at once + * in the right rail), so this returns the list rather than just the latest. */ -export function pendingChoice(events: readonly FrameworkEvent[]): ChoiceRequest | null { - const resolved = new Set() - for (let i = events.length - 1; i >= 0; i--) { - const event = events[i]! +export function pendingChoices(events: readonly FrameworkEvent[]): ChoiceRequest[] { + const open = new Map() + for (const event of events) { if (event.kind === 'choice-resolved') { - resolved.add(event.id) + open.delete(event.id) continue } - if (event.kind === 'choice' && !resolved.has(event.id)) { + if (event.kind === 'choice') { const { kind: _kind, ...request } = event as ChoiceEvent - return request + open.set(event.id, request) } } - return null + return [...open.values()] +} + +/** The single gate the run is currently parked on (the most recent), or null. */ +export function pendingChoice(events: readonly FrameworkEvent[]): ChoiceRequest | null { + return pendingChoices(events).at(-1) ?? null } /** diff --git a/packages/framework-dashboard/lib/use-live-events.ts b/packages/framework-dashboard/lib/use-live-events.ts new file mode 100644 index 0000000..1869c3f --- /dev/null +++ b/packages/framework-dashboard/lib/use-live-events.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' +import type { FrameworkEvent } from '@gemstack/framework' +import type { ClientChannel } from 'telefunc' +import { onEvents } from '../server/events.telefunc.js' + +// The live run feed (#405), shared. The dashboard is a projection of the selected project's +// `.the-framework/events.jsonl`, streamed over a Telefunc Channel that pushes one +// `FrameworkEvent` per new line. Both the main event view and the right rail's choice gates +// (#440) read this same stream, so the subscription lives here and each consumer owns one +// channel for its project rather than opening a second. +export function useLiveEvents(projectId: string | null): FrameworkEvent[] { + const [events, setEvents] = useState([]) + + useEffect(() => { + setEvents([]) + if (!projectId) return + let channel: ClientChannel | undefined + let cancelled = false + void onEvents(projectId).then(ch => { + if (cancelled) { + void ch.close() + return + } + channel = ch + ch.listen(event => setEvents(prev => [...prev, event])) + }) + return () => { + cancelled = true + void channel?.close() + } + }, [projectId]) + + return events +} diff --git a/packages/framework-dashboard/pages/index/+Page.tsx b/packages/framework-dashboard/pages/index/+Page.tsx index 44a18db..4fc5f19 100644 --- a/packages/framework-dashboard/pages/index/+Page.tsx +++ b/packages/framework-dashboard/pages/index/+Page.tsx @@ -6,6 +6,8 @@ import { RunReplay } from '../../components/RunReplay.js' import { RightRail } from '../../components/RightRail.js' import { RelayView } from '../../components/RelayView.js' import { Badge } from '../../components/ui/badge.js' +import { useLiveEvents } from '../../lib/use-live-events.js' +import { pendingChoices } from '../../lib/live-state.js' // The dashboard shell (#405 phase 2): Projects | Runs | main (live event stream or a // past-run replay) | Docs/Log rail. Everything over the wire is Telefunc — the @@ -21,6 +23,11 @@ export default function Page() { setRunId(null) // switching projects always returns to live } + // The live run feed is owned here so both the main view and the right rail's choice gates + // (#440) read one shared Telefunc Channel. Hooks run before the relay early return below. + const events = useLiveEvents(projectId) + const choices = projectId ? pendingChoices(events) : [] + // On the relay (#426), the URL carries `?run=` and there is no local registry or // files — show that one run read-only. `window` is absent during prerender (ssr:false), // so this resolves to the full shell at build time and only flips in the browser. Hooks @@ -39,9 +46,9 @@ export default function Page() {
    - {projectId && runId ? : } + {projectId && runId ? : }
    - + )