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
36 changes: 31 additions & 5 deletions packages/framework-dashboard/components/ChoicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>(
() => new Set(choice.multi ? choice.options.filter(o => o.default).map(o => o.id) : []),
Expand All @@ -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(() => {
Expand All @@ -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(() => {
Expand All @@ -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)
}
Expand Down Expand Up @@ -145,6 +170,7 @@ export function ChoicePanel({ projectId, choice }: { projectId: string; choice:
: secondsLeft !== null && `● Auto accept in ${secondsLeft}s — move the mouse to cancel`}
</span>
)}
{active && !busy && <span className="ml-auto">Ctrl+Enter to accept</span>}
</div>
</section>
)
Expand Down
52 changes: 52 additions & 0 deletions packages/framework-dashboard/components/ChoicesRail.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const panels = useRef(new Map<string, HTMLDivElement>())

if (choices.length === 0) {
return <p className="p-4 text-sm text-muted-foreground">No choices to make right now.</p>
}

const jump = (id: string) => panels.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })

return (
<div className="flex min-h-0 flex-1 flex-col">
{choices.length > 1 && (
<nav className="sticky top-0 z-10 flex flex-wrap gap-1 border-b border-border bg-background/95 p-2 backdrop-blur">
{choices.map((c, i) => (
<button
key={c.id}
type="button"
onClick={() => jump(c.id)}
title={c.title}
className="max-w-[10rem] truncate rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
{i + 1}. {c.title}
</button>
))}
</nav>
)}
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto">
{choices.map((c, i) => (
<div
key={c.id}
ref={el => {
if (el) panels.current.set(c.id, el)
else panels.current.delete(c.id)
}}
>
<ChoicePanel projectId={projectId} choice={c} active={i === 0} />
</div>
))}
</div>
</div>
)
}
2 changes: 1 addition & 1 deletion packages/framework-dashboard/components/EventList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function EventList({ events, stick = true }: { events: FrameworkEvent[];
{events.map((e, i) => (
<li key={i} className="flex items-start gap-2">
<Badge className="mt-0.5 shrink-0 text-[10px] uppercase text-muted-foreground">{e.kind}</Badge>
<span className="whitespace-pre-wrap break-words text-foreground">{formatFrameworkEvent(e).trim()}</span>
<span className="whitespace-pre-wrap break-words text-foreground">{(formatFrameworkEvent(e) ?? '').trim()}</span>
</li>
))}
</ol>
Expand Down
53 changes: 17 additions & 36 deletions packages/framework-dashboard/components/EventStream.tsx
Original file line number Diff line number Diff line change
@@ -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<FrameworkEvent[]>([])

useEffect(() => {
setEvents([])
if (!projectId) return
let channel: ClientChannel<never, FrameworkEvent> | 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 <div className="grid flex-1 place-items-center text-sm text-muted-foreground">Select a project to watch its live run.</div>
}

// 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 <div className="grid flex-1 place-items-center text-sm text-muted-foreground">Waiting for the run to start…</div>
Expand All @@ -56,7 +39,6 @@ export function EventStream({ projectId, readOnly = false }: { projectId: string
}

const active = isRunActive(events)
const choice = pendingChoice(events)
return (
<>
{active ? (
Expand All @@ -68,7 +50,6 @@ export function EventStream({ projectId, readOnly = false }: { projectId: string
) : (
<StartRunForm projectId={projectId} />
)}
{choice && <ChoicePanel key={choice.id} projectId={projectId} choice={choice} />}
{events.length > 0 ? (
<>
<RunOverview events={events} />
Expand Down
4 changes: 3 additions & 1 deletion packages/framework-dashboard/components/RelayView.tsx
Original file line number Diff line number Diff line change
@@ -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=<id>`, 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 (
<div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
Expand All @@ -14,7 +16,7 @@ export function RelayView({ runId }: { runId: string }) {
<span className="ml-auto text-xs text-muted-foreground">read-only shared run</span>
</header>
<main className="flex min-w-0 flex-1 flex-col">
<EventStream projectId={runId} readOnly />
<EventStream projectId={runId} events={events} readOnly />
</main>
</div>
)
Expand Down
43 changes: 34 additions & 9 deletions packages/framework-dashboard/components/RightRail.tsx
Original file line number Diff line number Diff line change
@@ -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<Tab>('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 (
<aside className="flex w-80 shrink-0 flex-col border-l border-border">
<div className="flex gap-1 border-b border-border p-2">
{(['docs', 'log'] as const).map(t => (
{tabs.map(t => (
<Button
key={t}
variant="ghost"
size="sm"
className={cn('h-7 text-xs capitalize', tab === t && 'bg-accent text-accent-foreground')}
className={cn('h-7 gap-1.5 text-xs', tab === t && 'bg-accent text-accent-foreground')}
onClick={() => setTab(t)}
>
{t === 'docs' ? 'Docs' : 'Project log'}
{label(t)}
{t === 'choices' && <Badge className="border-primary/40 text-primary">{choices.length}</Badge>}
</Button>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col">
{tab === 'docs' ? <DocsPanel projectId={projectId} /> : <ProjectLogPanel projectId={projectId} />}
{tab === 'choices' && hasChoices ? (
<ChoicesRail projectId={projectId} choices={choices} />
) : tab === 'log' ? (
<ProjectLogPanel projectId={projectId} />
) : (
<DocsPanel projectId={projectId} />
)}
</div>
</aside>
)
Expand Down
29 changes: 17 additions & 12 deletions packages/framework-dashboard/lib/live-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]!
export function pendingChoices(events: readonly FrameworkEvent[]): ChoiceRequest[] {
const open = new Map<string, ChoiceRequest>()
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
}

/**
Expand Down
34 changes: 34 additions & 0 deletions packages/framework-dashboard/lib/use-live-events.ts
Original file line number Diff line number Diff line change
@@ -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<FrameworkEvent[]>([])

useEffect(() => {
setEvents([])
if (!projectId) return
let channel: ClientChannel<never, FrameworkEvent> | 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
}
Loading
Loading