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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
One agent's feed: the overview plus the live or replayed event log — with a waiting placeholder before anything streams, and a banner while the live stream is down, so a dead connection never reads as the agent going quiet.
One agent's feed: the live or replayed event log — with a waiting placeholder before anything streams, and a banner while the live stream is down, so a dead connection never reads as the agent going quiet.

## Before modifying/creating SPEC.md files

Expand Down
31 changes: 11 additions & 20 deletions packages/the-framework/dashboard/components/AgentFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,14 @@ import type { ReactNode } from 'react'
import type { FrameworkEvent } from '../../src/index.js'
import { TriangleAlert } from 'lucide-react'
import { EventList } from './EventList.js'
import { AgentOverview } from './AgentOverview.js'

// One agent's feed: the agent overview plus the live/replayed event log, or a waiting placeholder
// before anything has streamed. Rendered by the agent's own view (AgentView, which shows the
// session link in its action bar instead — `showSessionLink={false}`). `lost` is the live
// channel's health (#948): while the stream is down the feed is behind reality, and saying so
// beats letting "the agent went quiet" and "the connection died" look identical.
// One agent's feed: the live/replayed event log, or a waiting placeholder before anything has
// streamed. Rendered by the agent's own view (AgentView), whose action bar already carries the
// session link, the session name and the status. `lost` is the live channel's health (#948):
// while the stream is down the feed is behind reality, and saying so beats letting "the agent
// went quiet" and "the connection died" look identical.
export function AgentFeed({
events,
showSessionLink = true,
showName = true,
showStatus = true,
lost = false,
stick = true,
openAt,
Expand All @@ -23,16 +19,11 @@ export function AgentFeed({
agentId: agentId,
}: {
events: FrameworkEvent[]
/** The feed's own project/run (#1455 item 6): with a projectId the log's `choice` rows become
* the interaction (inline panels/answered cards). The relay watch passes nothing — read-only. */
projectId?: string | undefined
/** The feed's own project/run (#1455 item 6): the log's `choice` rows are the interaction
* (inline panels/answered cards). Required, so no caller can silently downgrade an open gate
* to log text — a gate rendered as text is a run parked with nothing to answer it (#846). */
projectId: string
agentId?: string | null | undefined
showSessionLink?: boolean
/** The agent's own view sets this false: its action bar's breadcrumb already names the session. */
showName?: boolean
/** The agent's own view sets this false: its action bar carries the status beside the ⋮ menu. */
showStatus?: boolean
/** The agent's own view sets this false: its right rail pins the loop's verdict under the tabs. */
lost?: boolean
/** A finished log is static (#1026): it does not follow new output, and opens at its end. */
stick?: boolean
Expand All @@ -59,13 +50,13 @@ export function AgentFeed({
return (
<>
{lostBanner}
<AgentOverview events={events} showSessionLink={showSessionLink} showName={showName} showStatus={showStatus} />
<EventList
events={events}
stick={stick}
{...(openAt ? { openAt } : {})}
{...(tail ? { tail } : {})}
{...(projectId ? { projectId, agentId: agentId } : {})}
projectId={projectId}
agentId={agentId}
/>
</>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
The agent-overview cards projected from the event stream — the status line and an honestly-labelled link to the live session — each rendered only once its data has arrived, with embedding views opting out of the parts their own chrome already shows.
The agent-overview cards projected from the event stream — the status line and an honestly-labelled link to the live session — each rendered only once its data has arrived.

## Before modifying/creating SPEC.md files

Expand Down
27 changes: 5 additions & 22 deletions packages/the-framework/dashboard/components/AgentOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,15 @@ import { cn } from '../lib/utils.js'
// in @gemstack/the-framework) — the agent's status and a link
// to the live session. Cards render only when their data has arrived, so an early agent
// shows nothing extra.
export function AgentOverview({
events,
showSessionLink = true,
showName = true,
showStatus = true,
}: {
events: FrameworkEvent[]
showSessionLink?: boolean
/** The agent's own view sets this false: its action bar already names the session in the breadcrumb,
* so the status line just shows the state (and reads the same whether or not the agent reported a
* name). The relay watch and project home keep it, since they have no breadcrumb. */
showName?: boolean
/** The agent's own view sets this false too: the status is a label in its toolbar, beside the ⋮
* menu, rather than a banner over the feed. The relay watch and project home have no toolbar,
* so they keep the line. */
showStatus?: boolean
}) {
export function AgentOverview({ events }: { events: FrameworkEvent[] }) {
const session = sessionInfo(events)
const progress = agentProgress(events)
const status = showStatus ? agentStatusPill(events) : null
const status = agentStatusPill(events)

// The "Open session" link, labeled honestly: a headless Claude Code run has no per-session
// URL, so the generic app entry (claude.ai/code) is shown as "Open Claude Code" with the id
// surfaced separately, not as a deep link to that id. See {@link describeSessionLink}. The
// run's own view moves this into its action bar, so it opts out via `showSessionLink={false}`.
const sessionLink = showSessionLink ? describeSessionLink(session) : null
// surfaced separately, not as a deep link to that id. See {@link describeSessionLink}.
const sessionLink = describeSessionLink(session)

if (!sessionLink && !status) return null

Expand All @@ -43,7 +26,7 @@ export function AgentOverview({
{status && (
<div className="flex items-center gap-2 text-sm md:col-span-2">
<span className={cn('h-2.5 w-2.5 shrink-0 rounded-full', status.dot)} aria-hidden />
{showName && progress.sessionName && <span className="font-medium">{progress.sessionName}</span>}
{progress.sessionName && <span className="font-medium">{progress.sessionName}</span>}
<span className={cn('text-xs', status.tone)}>{status.label}</span>
</div>
)}
Expand Down
3 changes: 0 additions & 3 deletions packages/the-framework/dashboard/components/AgentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,6 @@ export function AgentView({
events={shown}
projectId={projectId}
agentId={agentId}
showSessionLink={false}
showName={false}
showStatus={false}
lost={lost}
{...(feedLive ? {} : { stick: false, openAt: 'end' as const, emptyLabel: 'This agent has no events.' })}
// A web agent's log dead-ends at the hand-off (#1265): the mirror box rides the tail of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe('EventList inline choice rows (#1455 item 6)', () => {
expect(sendChoice).toHaveBeenCalledWith('p1', 'gate-1', 'work', 'user', 'r1')
})

test('without a projectId the row keeps the formatter text (the read-only relay watch)', () => {
test('without a projectId the row keeps the formatter text', () => {
render(<EventList events={[gate()]} stick={false} />)
expect(screen.queryByRole('button', { name: /Work on it/ })).toBeNull()
expect(screen.getByText(/Start the next backlog item\?/)).toBeTruthy()
Expand Down Expand Up @@ -231,7 +231,7 @@ describe('EventList inline choice rows (#1455 item 6)', () => {
})

// The latest `browser` row hosts the live inline preview (#1455 item 6b); earlier rows and the
// read-only relay watch keep the formatter's text, and an ended agent's pane degrades (#1359).
// a log rendered without them keeps the formatter's text, and an ended agent's pane degrades (#1359).
describe('EventList inline browser rows (#1455 item 6b)', () => {
const browser = (url = 'https://app.test/'): FrameworkEvent => ({ kind: 'browser', url })

Expand Down Expand Up @@ -259,7 +259,7 @@ describe('EventList inline browser rows (#1455 item 6b)', () => {
expect(screen.getByText(/browser · https:\/\/a\.test\//)).toBeTruthy()
})

test('without a agentId the row keeps the formatter text (the read-only relay watch)', () => {
test('without a agentId the row keeps the formatter text', () => {
render(<EventList events={[browser()]} stick={false} projectId="p1" />)
expect(screen.queryByAltText("The agent's browser")).toBeNull()
expect(screen.getByText(/browser: https:\/\/app\.test\//)).toBeTruthy()
Expand Down
8 changes: 4 additions & 4 deletions packages/the-framework/dashboard/components/EventList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,15 @@ export function EventList({
* live mirror box — that must scroll (and stick) with the log rather than float over it. */
tail?: ReactNode
/** The log's own project (#1455 item 6): with it, a `choice` row IS the interaction — an open
* gate renders the inline ChoicePanel, a resolved one the collapsed ✓ card. Absent (the
* read-only relay watch), every row keeps the formatter's text. */
* gate renders the inline ChoicePanel, a resolved one the collapsed ✓ card. Without it, every
* row keeps the formatter's text. */
projectId?: string | undefined
/** Which run an inline pick resolves (#749), forwarded to the panel with projectId. */
agentId?: string | null | undefined
}) {
const choiceRows = useMemo(() => (projectId ? foldChoiceRows(events) : undefined), [projectId, events])
// The inline pane needs both halves of the proxy path, so the read-only relay watch (no
// projectId/agentId) keeps every browser row as formatter text.
// The inline pane needs both halves of the proxy path, so a log rendered without a
// projectId/agentId keeps every browser row as formatter text.
const browserRows = useMemo(() => (projectId && agentId ? foldBrowserRows(events) : undefined), [projectId, agentId, events])
const shown = promptFirst(events).filter(e => !choiceRows?.hidden.has(e) && !browserRows?.hidden.has(e))
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The project panel's file tree — a context picker, not an editor: clicking a fi
- Per-file git-status marks, read from the selected agent's own checkout and refreshed as it edits, roll up to folders so dirty work is spottable even while a folder is closed. A file says which change it is; a folder only says that something under it changed.
- A filter box narrows to matching files, and zero matches say so instead of rendering an empty pane that reads as broken.
- Every file previews on hover — its diff when changed, its contents when not — with the tree's own status deciding which.
- Localhost-only: the relay — watching an agent that executes on another machine — has no checkout to list here, so the tree renders nothing.
- With no files to list, the tree renders nothing rather than an empty frame.

## Rationales

Expand Down
3 changes: 1 addition & 2 deletions packages/the-framework/dashboard/components/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ const EMPTY_STATUS: Record<string, FileGitStatus> = {}
// The project panel's file tree (#492): a lazy, collapsible tree built from the flat
// `git ls-files` list (onProjectFiles, shared with the `#` picker #504). It is a file-level
// CONTEXT PICKER, not an editor — clicking a file toggles it in the agent Context, the same
// set the `#` chips and the whole-repo Context selector feed. Localhost-only: no files (the
// relay has no checkout) renders nothing.
// set the `#` chips and the whole-repo Context selector feed. With no files, it renders nothing.
//
// Folders are native `<details>`: open/closed state, keyboard operation and the disclosure
// semantics come from the browser. This used to be 1,225 lines of vendored animate-ui — a copied
Expand Down
3 changes: 1 addition & 2 deletions packages/the-framework/dashboard/components/GitStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { cn } from '../lib/utils.js'
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'

// The checkout in play (#491, part of #488): active branch, a clean/dirty dot, the linked PR.
// Polled, so it tracks an agent committing or branching. Hidden when there is no git repo (or on
// the relay, which has no local checkout).
// Polled, so it tracks an agent committing or branching. Hidden when there is no git repo.
//
// One component for both pages (#809). With a `agentId` it reads that session's own worktree, which
// also carries its size on disk and the path it lives at; without one it reads the project's
Expand Down
4 changes: 2 additions & 2 deletions packages/the-framework/dashboard/components/RoutineWork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ export function RoutineWork({
setSweepNote(null)
const result = await sendAutoPmSweep().catch(() => ({ ok: false as const }))
setSweeping(false)
// A host with no loop is the honest failure here, and the only one: the relay serves this
// same dashboard, and there the button has nothing to fire.
// A host with no loop is the honest failure here, and the only one: a dashboard served by
// something that does not run the sweep has nothing for this button to fire.
if (!result.ok) setSweepNote('This dashboard is not running the sweep, so there is nothing to trigger here.')
else setSweepNote(describeOutcomes('outcomes' in result ? result.outcomes : undefined))
}
Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/dashboard/lib/favicon.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
The tab icon follows the work: the still logo while nothing is running, the animated one while an agent is working — and a view that cannot know (the relay) leaves the icon alone.
The tab icon follows the work: the still logo while nothing is running, the animated one while an agent is working.

## Before modifying/creating SPEC.md files

Expand Down
6 changes: 0 additions & 6 deletions packages/the-framework/dashboard/lib/favicon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,6 @@ describe('useFavicon', () => {
expect(icon()).toBe(WORKING_FAVICON)
})

test('leaves the tab alone when it is not the caller\'s to set', () => {
document.head.innerHTML = `<link rel="icon" href="${IDLE_FAVICON}" />`
renderHook(() => useFavicon(true, false))
expect(icon()).toBe(IDLE_FAVICON)
})

test('names the two icon files', () => {
expect(faviconHref(true)).toBe(WORKING_FAVICON)
expect(faviconHref(false)).toBe(IDLE_FAVICON)
Expand Down
13 changes: 4 additions & 9 deletions packages/the-framework/dashboard/lib/favicon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,10 @@ export function faviconHref(working: boolean): string {
return working ? WORKING_FAVICON : IDLE_FAVICON
}

/**
* Point the tab icon at {@link faviconHref} (client-only).
*
* `enabled` is false where the caller is not the one that knows: the shell hands the tab over to
* the relay view, which reads a single agent's feed rather than the project registry.
*/
export function useFavicon(working: boolean, enabled = true): void {
/** Point the tab icon at {@link faviconHref} (client-only). */
export function useFavicon(working: boolean): void {
useEffect(() => {
if (!enabled || typeof document === 'undefined') return
if (typeof document === 'undefined') return
// `rel~=` because the emitted rel can carry more than one token.
let link = document.querySelector<HTMLLinkElement>('link[rel~="icon"]')
if (!link) {
Expand All @@ -35,5 +30,5 @@ export function useFavicon(working: boolean, enabled = true): void {
// Guarded: writing the same href re-fetches the icon in some browsers, which restarts the
// animation on every render.
if (link.getAttribute('href') !== href) link.setAttribute('href', href)
}, [working, enabled])
}, [working])
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Covers the liveness probe: an answering daemon reads healthy, a failing one flips to down, and the shared watch view (which has no daemon of its own) never probes.
Covers the liveness probe: an answering daemon reads healthy, and a failing one flips to down.

## Before modifying/creating SPEC.md files

Expand Down
11 changes: 2 additions & 9 deletions packages/the-framework/dashboard/lib/use-daemon-health.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ afterEach(() => {
onProjects.mockReset()
})

function Probe({ enabled = true }: { enabled?: boolean }) {
return <span>{useDaemonHealth(enabled) ? 'healthy' : 'down'}</span>
function Probe() {
return <span>{useDaemonHealth() ? 'healthy' : 'down'}</span>
}

// #948: a dead daemon froze every surface silently — the probe is what lets the shell say so.
Expand All @@ -29,11 +29,4 @@ describe('useDaemonHealth', () => {
render(<Probe />)
await waitFor(() => expect(screen.getByText('down')).toBeTruthy())
})

test('disabled (the relay) never probes and stays healthy', async () => {
render(<Probe enabled={false} />)
await new Promise(resolve => setTimeout(resolve, 50))
expect(onProjects).not.toHaveBeenCalled()
expect(screen.getByText('healthy')).toBeTruthy()
})
})
5 changes: 2 additions & 3 deletions packages/the-framework/dashboard/lib/use-daemon-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ const PROBE_MS = 5000
// from a quiet agent. One cheap read on a fixed cadence turns "unreachable" into a fact the
// shell can say out loud. Recovery needs no action here: the channels reconcile and the polls
// resume on their own once the daemon answers again.
export function useDaemonHealth(enabled = true): boolean {
export function useDaemonHealth(): boolean {
const [healthy, setHealthy] = useState(true)

useEffect(() => {
if (!enabled) return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | undefined
const probe = () => {
Expand All @@ -36,7 +35,7 @@ export function useDaemonHealth(enabled = true): boolean {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [enabled])
}, [])

return healthy
}
6 changes: 3 additions & 3 deletions packages/the-framework/dashboard/lib/use-working.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { usePolled } from './use-async.js'
/** Stable initial, so the poll does not churn on every render. */
const IDLE: Overview = { active: [], queueOpen: 0, recent: [] }

/** True while any project has a running agent. `enabled` false skips the poll and answers false. */
export function useWorking(enabled = true): boolean {
const { value } = usePolled<Overview>(enabled ? onOverview : null, IDLE, 5000, [enabled])
/** True while any project has a running agent. */
export function useWorking(): boolean {
const { value } = usePolled<Overview>(onOverview, IDLE, 5000, [])
return value.active.length > 0
}
Loading