diff --git a/README.md b/README.md index 4e3e700..8ec6237 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ One row per touched file — read, written, or searched — aggregated up to whi ![Agent Network with seven subagents](https://raw.githubusercontent.com/bowenliang123/dsh-context/main/docs/agent-network.png) -The current agent, its parents, and every subagent — one card per agent: its title, live context size and window occupancy, a composition bar, and how long it has run for and how many steps it has taken. Edges fan out from each parent in the lineage's color — hover a card to light its whole chain and read the full breakdown in the detail strip; click to jump into that session's own Context tab. Running agents breathe with a green pulse on their incoming edge, and narrow panes fold the family into tidy rows — never a horizontal scrollbar. +The current agent, its parents, and every subagent — one card per agent: its title, live context size and window occupancy, a composition bar, and how long it has run for and how many steps it has taken. Edges fan out from each parent in the lineage's color — hover a card to light its whole chain and read the full breakdown in the detail strip; click to jump into that session's own Context tab. Running agents breathe with a green pulse on their incoming edge and turn over the harness's own rolling spinner beside their title, which settles into the same green dot the header's subagent list uses once the work is done; narrow panes fold the family into tidy rows — never a horizontal scrollbar. ## ⌨️ `/context` command diff --git a/docs/compatibility.md b/docs/compatibility.md index be39818..1e07b58 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -107,7 +107,7 @@ These checks prove source-level seam compatibility, statistical parity with the The skill-injection category (issue #66) bumps the timeline projection's `stateVersion` (18 → 19): skill content — the `` catalog digest, a user-explicit `/name` invocation's instructions message, and the content a `skill`-tool load returns — now folds into its own `skill` composition bucket instead of `inject`/`tool`. The re-bucketing changes the fold's per-category sums, so on upgrade every cached per-session row is invalidated and re-folded from the durable log, which rebuilds them under the new categories. The bump orphans the `contextTimeline` key for idle sessions, which have no refresh channel until they go live again — such a session re-folds when its log receives its next event. New sessions are exact from their first event. -The Agent network card covers the orphaned relatives without waiting for a visit: a session listed without a `contextTimeline` row (composition absent, occupancy only) fetches its slim head from the plugin's `/api/dsh-context/detail` fetch route (mounted through Connection's exact-route registry, behind the same authenticated `/api` fence), which folds the durable log on demand — the node's composition bar fills in completely the first time the card opens. The same backfill serves sessions whose cache predates the plugin's installation entirely. Where the route is unavailable (the baseline gate, a connection service without the exact-route registry) — or the cold-read gate below skips the read under heap pressure — the card degrades to the pressure-only occupancy fill as before. Sibling order follows the parent row's own `subagentCatalog` projection (parent catalog event order — the same sequence the harness header's subagent popup lists) whenever the list block's projection cache carries it; a deployment or baseline without the catalog projection keeps the running-first/updatedAt heuristic, and children absent from a cached catalog sort after its entries under that heuristic. +The Agent network card covers the orphaned relatives without waiting for a visit: a session listed without a `contextTimeline` row (composition absent, occupancy only) fetches its slim head from the plugin's `/api/dsh-context/detail` fetch route (mounted through Connection's exact-route registry, behind the same authenticated `/api` fence), which folds the durable log on demand — the node's composition bar fills in completely the first time the card opens. The same backfill serves sessions whose cache predates the plugin's installation entirely. Where the route is unavailable (the baseline gate, a connection service without the exact-route registry) — or the cold-read gate below skips the read under heap pressure — the card degrades to the pressure-only occupancy fill as before. Sibling order follows the parent row's own `subagentCatalog` projection (parent catalog event order — the same sequence the harness header's subagent popup lists) whenever the list block's projection cache carries it; a deployment or baseline without the catalog projection keeps the running-first/updatedAt heuristic, and children absent from a cached catalog sort after its entries under that heuristic. A card's work state rides the same signal the header popup's own state dot does: `subagentTiming.lastTurnCompleted` (folded since `0.1.7-alpha.1`, so on every supported line) marks a subagent's last closed turn finished, and a running session is never done either way. The list block's row carries no completion flag at all — `SessionSummary` has never had one — and a top-level session never carries a `subagentTiming` projection, so a card that is not a subagent falls back to its own folded step count against a stopped session. That fallback is a proxy, not the harness's own statement: it cannot tell a turn that ended from one the user stopped, and a session with no steps yet draws no state glyph at all. The session cards' last-user-message preview (`lastUser` on the `contextTimeline` head) bumps the timeline projection's `stateVersion` (19 → 20): a stale row can never gain the field while its session is idle (it only lands when a user message folds), so on upgrade every cached timeline row is invalidated and refolded from the durable log. To keep the bump from orphaning idle sessions' rows, the warm-up (`src/host/backfill.ts`) probes BOTH insight rows — `contextActivity` AND `contextTimeline` — and cold-refolds every session whose either row is missing or version-stale, so previews (and every other head figure) are exact for the whole session list the first time the page opens after the upgrade. A deployment without the cold-path services — or whose client cannot mount the warm-up's trigger route — degrades to the per-session refold on the session's next activity, the same as pre-warm-up upgrades. diff --git a/src/client/agentTree.ts b/src/client/agentTree.ts index e3ab8d2..2cd4482 100644 --- a/src/client/agentTree.ts +++ b/src/client/agentTree.ts @@ -24,7 +24,6 @@ export interface AgentRow { parentId?: string origin?: string running: boolean - completed: boolean blank: boolean updatedAt: number projections?: Record @@ -106,7 +105,6 @@ export function agentRowOf(value: unknown): AgentRow | null { ...(typeof rec.parentId === 'string' ? { parentId: rec.parentId } : {}), ...(typeof rec.origin === 'string' ? { origin: rec.origin } : {}), running: rec.running === true, - completed: rec.completed === true, blank: rec.blank === true, updatedAt: numOf(rec.updatedAt), ...(projections !== null ? { projections } : {}), @@ -133,6 +131,13 @@ export function agentDurationOf(value: unknown): number | null { return total > 0 ? total : null } +/** The harness's own done signal (its subagent header popup's green dot): the last closed turn + * after the child's descriptor finished normally. A running session is never done, whatever it + * last turned in. */ +export function agentTurnCompletedOf(value: unknown): boolean { + return asRecord(value)?.lastTurnCompleted === true +} + /** Fold one row's projection values into render-ready stats. The composition * prefers the plugin's own `contextTimeline`; a pressure-only row still yields an occupancy ring without slices. */ export function agentStatsOf(values: Record | undefined): AgentStats { @@ -222,7 +227,7 @@ export function agentForestOf( if (row !== null && (!row.blank || key === currentId)) rows.set(key, row) } if (!rows.has(currentId)) { - rows.set(currentId, { running: false, completed: false, blank: false, updatedAt: 0 }) + rows.set(currentId, { running: false, blank: false, updatedAt: 0 }) } // Topmost known ancestor; a lineage cycle anchors at the first repeated id. @@ -287,6 +292,14 @@ export function agentForestOf( ? { ...values, contextTimeline: fetchedHead } : values) const identity = stats.identity + if (id === currentId && self !== undefined) { + stats.head = self.head ?? stats.head + stats.billed = self.billed ?? stats.billed + stats.requests = self.requests > 0 ? self.requests : stats.requests + stats.costUsage = self.costUsage ?? stats.costUsage + stats.durationMs = self.durationMs ?? stats.durationMs + } + const subagent = row.origin === 'subagent' || identity !== null const node: AgentNode = { ...stats, id, @@ -296,15 +309,12 @@ export function agentForestOf( family, isCurrent: id === currentId, running: row.running, - completed: row.completed, - subagent: row.origin === 'subagent' || identity !== null, - } - if (node.isCurrent && self !== undefined) { - node.head = self.head ?? node.head - node.billed = self.billed ?? node.billed - node.requests = self.requests > 0 ? self.requests : node.requests - node.costUsage = self.costUsage ?? node.costUsage - node.durationMs = self.durationMs ?? node.durationMs + // Done, by the harness popup's own rule: a stopped agent only, and the signal is the + // last closed subagent turn finishing normally. A session that is not a subagent never + // carries that projection, so its own folded steps stand in for it. + completed: !row.running + && (agentTurnCompletedOf(values?.subagentTiming) || (!subagent && stats.requests > 0)), + subagent, } nodes.push(node) if (parentId !== undefined) edges.push({ from: parentId, to: id }) diff --git a/src/client/components/agentGraph.tsx b/src/client/components/agentGraph.tsx index 41cbe8d..ae546ec 100644 --- a/src/client/components/agentGraph.tsx +++ b/src/client/components/agentGraph.tsx @@ -20,6 +20,7 @@ */ import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactElement } from 'react' +import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { CATS } from '../categories' import type { AgentHeads } from '../agentHeads' import { makeAgentHeads, useSessionsSnapshot } from '../agentHeads' @@ -307,8 +308,6 @@ function AgentCard(props: CardProps): ReactElement { if (node.requests > 0) meta.push(props.t('agents.steps', { n: node.requests })) const cls = 'lc-agent-card animate-lc-agent-in motion-reduce:animate-none' + (node.isCurrent ? ' lc-agent-self' : '') - + (node.running ? ' lc-agent-running' : '') - + (node.completed && !node.running ? ' lc-agent-done' : '') // lc-agent-hover carries no rule of its own — the hover/focus wash rides // :hover/:focus-visible; the class stays as the specs' state anchor. + (props.hovered ? ' lc-agent-hover' : '') @@ -328,7 +327,9 @@ function AgentCard(props: CardProps): ReactElement { onBlur={() => { props.onHover(null) }} >
- {node.running || node.completed ? : null} + {node.running || node.completed + ? + : null}
{node.label}
{/* The badge sits OUTSIDE the clamped label: an inline badge would be clipped away whenever a long title claims both lines. The lc-agent-self-badge class carries no diff --git a/src/client/styles/agentGraph.css b/src/client/styles/agentGraph.css index 010d336..003c721 100644 --- a/src/client/styles/agentGraph.css +++ b/src/client/styles/agentGraph.css @@ -50,10 +50,8 @@ border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, var(--color-blue-500)) 55%, var(--dsw-alias-border-l2)); } .lc-agent-card-head { display: flex; align-items: flex-start; gap: 6px; } -.lc-agent-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; margin-top: 4px; box-sizing: border-box; } -.lc-agent-running .lc-agent-dot { background: var(--dsw-alias-state-success-primary); animation: lc-agent-glow 1.8s ease-in-out infinite; } -.lc-agent-done .lc-agent-dot { border: 1.5px solid var(--dsw-alias-state-success-primary); } -@media (prefers-reduced-motion: reduce) { .lc-agent-running .lc-agent-dot { animation: none; } } +/* The harness's own StateDot (the header popup's glyph), nudged onto the title's first line. */ +.lc-agent-state { margin-top: 2px; } /* Fixed two-line title box: every card's metric and bar sit at the same height, whether the title wraps or not. */ .lc-agent-label { flex: 1; min-width: 0; height: 30px; font-size: 11.5px; line-height: 1.3; color: var(--dsw-alias-label-primary); overflow-wrap: break-word; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } diff --git a/src/client/styles/tailwind.css b/src/client/styles/tailwind.css index 809f0fb..ef0d9bd 100644 --- a/src/client/styles/tailwind.css +++ b/src/client/styles/tailwind.css @@ -38,13 +38,11 @@ --animate-lc-stacked-in: lc-stacked-in 0.4s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--lc-i, 0) * 40ms) backwards; --animate-lc-bar-in: lc-bar-in 0.35s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--lc-i, 0) * 15ms) backwards; --animate-lc-agent-flow: lc-agent-flow 0.9s linear infinite; - --animate-lc-agent-glow: lc-agent-glow 1.8s ease-in-out infinite; --animate-lc-agent-in: lc-agent-in 0.35s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--lc-i, 0) * 35ms) backwards; @keyframes lc-donut-in { from { stroke-dasharray: 0 100; } } @keyframes lc-stacked-in { from { transform: scaleX(0); } } @keyframes lc-bar-in { from { transform: scaleY(0); } } @keyframes lc-agent-flow { to { stroke-dashoffset: -9; } } - @keyframes lc-agent-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes lc-agent-in { from { opacity: 0; transform: translateY(6px); } } } diff --git a/tests/client/agentTree.spec.ts b/tests/client/agentTree.spec.ts index f6374b6..acf582f 100644 --- a/tests/client/agentTree.spec.ts +++ b/tests/client/agentTree.spec.ts @@ -8,6 +8,7 @@ import { agentForestOf, agentIdentityOf, agentRowOf, + agentTurnCompletedOf, agentStatsOf, barSegments, familyHue, @@ -52,22 +53,22 @@ describe('agentRowOf', () => { assert.equal(agentRowOf(null), null) assert.equal(agentRowOf('row'), null) assert.deepEqual(agentRowOf({}), { - running: false, completed: false, blank: false, updatedAt: 0, + running: false, blank: false, updatedAt: 0, }) assert.deepEqual(agentRowOf({ displayTitle: 'Main', title: 'T', parentId: 'p', origin: 'subagent', - running: true, completed: true, blank: true, updatedAt: 42, + running: true, blank: true, updatedAt: 42, projectionValues: { title: 'x' }, }), { displayTitle: 'Main', title: 'T', parentId: 'p', origin: 'subagent', - running: true, completed: true, blank: true, updatedAt: 42, + running: true, blank: true, updatedAt: 42, projections: { title: 'x' }, }) // Wrong-typed members drop; non-numeric updatedAt zeroes; non-record projections drop. assert.deepEqual(agentRowOf({ displayTitle: 1, title: null, parentId: 2, origin: false, running: 'yes', updatedAt: 'soon', projectionValues: 7, - }), { running: false, completed: false, blank: false, updatedAt: 0 }) + }), { running: false, blank: false, updatedAt: 0 }) }) }) @@ -96,6 +97,19 @@ describe('agentDurationOf', () => { }) }) +describe('agentTurnCompletedOf', () => { + test('only the harness\'s own true flag counts as a finished turn', () => { + assert.equal(agentTurnCompletedOf(null), false) + assert.equal(agentTurnCompletedOf('x'), false) + assert.equal(agentTurnCompletedOf({}), false) + assert.equal(agentTurnCompletedOf({ lastTurnCompleted: 'yes' }), false) + // An open turn carries no flag, so the subagent is still mid-work. + assert.equal(agentTurnCompletedOf({ settledMs: 5000 }), false) + assert.equal(agentTurnCompletedOf({ lastTurnCompleted: false }), false) + assert.equal(agentTurnCompletedOf({ lastTurnCompleted: true }), true) + }) +}) + describe('agentStatsOf', () => { test('absent values degrade to empty stats', () => { assert.deepEqual(agentStatsOf(undefined), { @@ -183,6 +197,60 @@ describe('agentForestOf', () => { assert.deepEqual(forest.edges, []) }) + test('a finished subagent turn marks the node done, and a running one never is', () => { + const forest = agentForestOf(snap({ + root: row({ running: true }), + kid: row({ parentId: 'root', projectionValues: { subagentTiming: { settledMs: 5000, lastTurnCompleted: true } } }), + open: row({ parentId: 'root', projectionValues: { subagentTiming: { settledMs: 1000, active: { since: 10, through: 99 } } } }), + }), 'root') + assert.ok(forest !== null) + const byId = new Map(forest.nodes.map(n => [n.id, n])) + assert.equal(byId.get('kid')?.completed, true) + // A turn still open carries no completion flag, and an agent with nothing done yet has + // no steps either — neither claims to be finished. + assert.equal(byId.get('open')?.completed, false) + // A running session is never done, whatever its last closed turn said. + const live = agentForestOf(snap({ + root: row({ running: true }), + kid: row({ parentId: 'root', running: true, projectionValues: { subagentTiming: { settledMs: 5000, lastTurnCompleted: true } } }), + }), 'root') + assert.ok(live !== null) + assert.equal(live.nodes.find(n => n.id === 'kid')?.completed, false) + }) + + test('a session that is not a subagent is done once it has folded steps and stopped running', () => { + // A top-level session never carries a subagent-timing projection, so its own folded steps + // are the only proof of finished work it can offer. Reached from a subagent's own tab, the + // main agent is an ancestor node, not the current one — the same rule has to hold there. + const idle = agentForestOf(snap({ root: row({}) }), 'root', { head: null, billed: null, requests: 9 }) + assert.ok(idle !== null) + assert.equal(idle.nodes[0].completed, true) + const ancestor = agentForestOf(snap({ + root: row({ projectionValues: { contextTimeline: timeline(10, 3) } }), + kid: row({ parentId: 'root', origin: 'subagent' }), + }), 'kid') + assert.ok(ancestor !== null) + assert.equal(ancestor.nodes.find(n => n.id === 'root')?.completed, true) + // A session that has not run a step yet claims nothing. + const fresh = agentForestOf(snap({ root: row({}) }), 'root', { head: null, billed: null, requests: 0 }) + assert.ok(fresh !== null) + assert.equal(fresh.nodes[0].completed, false) + // And a live parent is never done, however many steps it has folded. + const live = agentForestOf(snap({ root: row({ running: true }) }), 'root', { head: null, billed: null, requests: 9 }) + assert.ok(live !== null) + assert.equal(live.nodes[0].completed, false) + }) + + test('a subagent without the completion flag is never done on its steps alone', () => { + // Steps alone would call an aborted subagent finished; only the harness flag may. + const forest = agentForestOf(snap({ + root: row({ running: true }), + kid: row({ parentId: 'root', origin: 'subagent', projectionValues: { contextTimeline: timeline(10, 3) } }), + }), 'root') + assert.ok(forest !== null) + assert.equal(forest.nodes.find(n => n.id === 'kid')?.completed, false) + }) + test('merges live self stats onto the current node', () => { const head = { tokens: 9, window: 10, pct: 90, parts: [] } const selfCost: SessionCostUsage = { p: { m: { peak: { uncached: 5, cacheRead: 0, cacheWrite: 0, output: 0 } } } } diff --git a/tests/client/components/agentGraph.spec.ts b/tests/client/components/agentGraph.spec.ts index 9cff505..5da3960 100644 --- a/tests/client/components/agentGraph.spec.ts +++ b/tests/client/components/agentGraph.spec.ts @@ -98,7 +98,7 @@ function family(): Record { projectionValues: { contextTimeline: timeline(200, 2) }, }, worker: { - parentId: 'root', origin: 'subagent', running: true, completed: false, updatedAt: 8, + parentId: 'root', origin: 'subagent', running: true, updatedAt: 8, projectionValues: { contextTimeline: timeline(800, 5), subagent: { mode: 'continuable', label: 'worker-bee' }, @@ -107,10 +107,11 @@ function family(): Record { }, }, done: { - parentId: 'root', origin: 'subagent', running: false, completed: true, updatedAt: 6, + parentId: 'root', origin: 'subagent', running: false, updatedAt: 6, projectionValues: { contextPressure: { projectedTokens: 950, contextWindow: 1000 }, subagent: { mode: 'one-shot' }, + subagentTiming: { settledMs: 9000, lastTurnCompleted: true }, }, }, } @@ -196,18 +197,22 @@ describe('AgentGraph — the family tree', () => { assert.equal(query(self, '.lc-agent-meta-text').textContent, '6m27s · 3 steps') // Every card shares one pitch — the root claims no extra room. assert.equal(self.style.width, worker.style.width) - assert.ok(worker.classList.contains('lc-agent-running')) // Timeline composition → one bar segment per non-empty category. assert.equal(worker.querySelectorAll('.lc-agent-bar-seg').length, 3) const done = query(m.container, '[data-agent="done"]') - assert.ok(done.classList.contains('lc-agent-done')) + // The work state rides the harness's own glyphs: the popup's spinner while an agent runs, + // its green dot once it is done. + assert.equal(query(self, '.lc-agent-state').getAttribute('data-state'), 'ongoing') + assert.equal(query(worker, '.lc-agent-state').getAttribute('data-state'), 'ongoing') + assert.equal(query(done, '.lc-agent-state').getAttribute('data-state'), 'done') // Pressure-only: a single threshold-colored fill; no consumption data → a dash headline, // and the meta row carries just the occupancy at its right. assert.equal(done.querySelectorAll('.lc-agent-bar-seg').length, 1) assert.ok(text(done).includes('—')) - assert.equal(query(done, '.lc-agent-meta').textContent, '95%') - assert.equal(query(done, '.lc-agent-meta-text').textContent, '') + // Its settled turn gives it the duration too; the occupancy keeps the right edge. + assert.equal(query(done, '.lc-agent-meta-text').textContent, '9s') + assert.equal(query(done, '.lc-agent-meta').textContent, '9s95%') const inspector = query(m.container, '.lc-agents-inspector') assert.ok(text(inspector).includes('Main Agent')) @@ -231,6 +236,25 @@ describe('AgentGraph — the family tree', () => { await m.unmount() }) + test('the current card goes green once its own session stops running', async () => { + // A parent session never carries a subagent-timing projection, so the tab's own folded + // steps are the only proof of finished work it can offer. + const idle = family() as { root: { running: boolean } } + idle.root.running = false + const View = makeView(new FakeSessions(idle)) + const m = await mount(h(View, { sessionId: 'root', self: selfStats() })) + assert.equal(query(m.container, '.lc-agent-card.lc-agent-self .lc-agent-state').getAttribute('data-state'), 'done') + await m.unmount() + + // A session that has never folded a step claims nothing at all. + const fresh = await mount(h( + makeView(new FakeSessions({ root: { displayTitle: 'Main Agent', running: false, updatedAt: 10 } })), + { sessionId: 'root', self: { head: null, billed: null, requests: 0 } }, + )) + assert.equal(queryAll(fresh.container, '.lc-agent-state').length, 0) + await fresh.unmount() + }) + test('hover moves the inspector, click/Enter opens the session', async () => { const face = new FakeSessions(family()) const View = makeView(face) diff --git a/tests/client/components/contextView.agentNetwork.spec.ts b/tests/client/components/contextView.agentNetwork.spec.ts index f49c579..c72cb22 100644 --- a/tests/client/components/contextView.agentNetwork.spec.ts +++ b/tests/client/components/contextView.agentNetwork.spec.ts @@ -14,7 +14,7 @@ import { makeView, projectionsFor, richTimeline } from './contextViewHarness' * getSnapshot hands back the SAME object every call — the card reads it through * useSyncExternalStore, which loops on a fresh snapshot per read. */ function selfOnly() { - const snapshot = { byId: { 'sv-self': { displayTitle: 'Main Agent', running: false, completed: false, blank: false, updatedAt: 1 } } } + const snapshot = { byId: { 'sv-self': { displayTitle: 'Main Agent', running: false, blank: false, updatedAt: 1 } } } return { list: { getSnapshot: (): unknown => snapshot, subscribe: (): (() => void) => () => {} } } }