From 7cb0a9fa467da19f4ec1ec35fb96aa459211e8a5 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Sat, 10 Oct 2026 00:26:03 +0800 Subject: [PATCH] refactor(comments): trim client comments to the value the code cannot carry Apply the repo's comments guideline to src/client/**: delete comments that restate JSX, Tailwind utilities or standard React behaviour, narrate history, duplicate docs/, or describe what the next line does; keep only container-query and containment traps, portal requirements, reference-stability rules with a real re-render consequence, CSS<->TS constant parities, harness seat and payload contracts. Align the page's name with the shipped UI label ("Context Insights", not "Context Dashboard"), unify drifted phrasings, drop the last non-English fragments, and rewrap survivors onto fewer, fuller lines within the 140-col limit. Comment lines 5,183 -> 1,822. No code change: comment-stripped content is identical per file; the client bundle differs only by dead Tailwind utilities that existed because deleted comments contained those words. --- src/client/agentHeads.ts | 24 +- src/client/agentTree.ts | 204 +++------- src/client/assemble.ts | 51 +-- src/client/balance.ts | 80 +--- src/client/brief.ts | 30 +- src/client/callSummary.ts | 15 +- src/client/categories.ts | 66 +--- src/client/command.ts | 29 +- src/client/components/agentGraph.tsx | 20 +- src/client/components/balanceCapsule.tsx | 27 +- src/client/components/browser.tsx | 361 +++-------------- src/client/components/contextJump.tsx | 34 +- src/client/components/contextModal.tsx | 35 +- src/client/components/contextView.tsx | 228 +++-------- src/client/components/dateRange.tsx | 79 +--- src/client/components/detailNote.tsx | 13 +- src/client/components/donut.tsx | 19 +- src/client/components/errorBoundary.tsx | 8 +- src/client/components/escapeClose.ts | 18 +- src/client/components/events.tsx | 24 +- src/client/components/fetchOnMiss.tsx | 27 +- src/client/components/fileCard.tsx | 71 +--- src/client/components/heatmap.tsx | 24 +- src/client/components/hoverTip.tsx | 48 +-- src/client/components/images.tsx | 21 +- src/client/components/nodes.tsx | 2 - src/client/components/overviewCard.tsx | 29 +- src/client/components/overviewPanel.tsx | 106 +---- src/client/components/overviewSkills.tsx | 36 +- src/client/components/overviewTokens.tsx | 7 +- src/client/components/overviewUsage.tsx | 8 +- src/client/components/pluginInfo.tsx | 20 +- src/client/components/requestDetail.tsx | 54 +-- src/client/components/richText.tsx | 51 +-- src/client/components/settingsCard.tsx | 18 +- src/client/components/sliceList.tsx | 17 +- src/client/components/stackedBar.tsx | 33 +- src/client/components/statsContext.tsx | 212 ++-------- src/client/components/statsTiming.tsx | 31 +- src/client/components/statsTokens.tsx | 9 +- src/client/components/timingStrip.tsx | 3 +- src/client/components/trendChart.tsx | 383 ++++-------------- src/client/components/upgradeGate.tsx | 23 +- src/client/cost.ts | 170 ++------ src/client/dna.ts | 69 +--- src/client/dockMeasure.ts | 19 +- src/client/fileActivity.ts | 146 ++----- src/client/format.ts | 31 +- src/client/globals.d.ts | 13 +- src/client/headline.ts | 61 +-- src/client/historyPage.ts | 171 ++------ src/client/i18n.ts | 13 +- src/client/icon.tsx | 68 +--- src/client/index.ts | 86 +--- src/client/insightPage.ts | 47 +-- src/client/latestVersion.ts | 12 +- src/client/meta.ts | 6 +- src/client/modalStore.ts | 18 +- src/client/modelPrices.ts | 48 +-- src/client/overscroll.ts | 20 +- src/client/overview.ts | 357 ++++------------- src/client/placement.ts | 21 +- src/client/primitives.ts | 24 +- src/client/revealScroll.ts | 27 +- src/client/services.ts | 484 +++++------------------ src/client/settings.ts | 48 +-- src/client/settingsJump.ts | 38 +- src/client/sidebar.ts | 65 +-- src/client/skills.ts | 47 +-- src/client/styles/agentGraph.css | 20 +- src/client/styles/attachments.css | 1 - src/client/styles/base.css | 131 ++---- src/client/styles/browser.css | 44 +-- src/client/styles/dateRange.css | 29 +- src/client/styles/detailSections.css | 46 +-- src/client/styles/events.css | 4 +- src/client/styles/fileCard.css | 15 +- src/client/styles/jump.css | 3 +- src/client/styles/modal.css | 8 +- src/client/styles/overview.css | 222 ++--------- src/client/styles/requestDetail.css | 27 +- src/client/styles/settings.css | 3 +- src/client/styles/stackedBar.css | 23 +- src/client/styles/stats.css | 146 ++----- src/client/styles/tailwind.css | 72 +--- src/client/styles/trendChart.css | 116 +----- src/client/timelineSource.ts | 207 +++------- src/client/viewFocus.ts | 45 +-- src/client/viewkit.ts | 2 - 89 files changed, 1212 insertions(+), 4659 deletions(-) diff --git a/src/client/agentHeads.ts b/src/client/agentHeads.ts index 9675cb5..0be66c5 100644 --- a/src/client/agentHeads.ts +++ b/src/client/agentHeads.ts @@ -1,16 +1,7 @@ -/** - * The sessions-feed plumbing shared by the session-list consumers — the - * Agent network card (agentGraph.tsx) and the stats board's subagent-cost - * cell (statsContext.tsx): the list-snapshot React seat, and the page-scope - * cache of cold-relative slim-head fetches. - * - * The cache: one in-flight-or-settled promise per session id for the - * factory's lifetime (page scope). A settled null — transport failure, - * hostile payload, route absent, session left the live set — is sticky, so a - * broken relative never retries per snapshot tick. One cache serves every - * consumer, so a relative's head is fetched once no matter how many cards - * read it. - */ +/** The sessions-feed plumbing shared by the session-list consumers: the + * list-snapshot React seat plus a page-scope cache holding one + * in-flight-or-settled slim-head promise per session id, so a broken relative + * never retries per snapshot tick and every consumer shares one fetch. */ import { useCallback, useSyncExternalStore } from 'react' import type { ContextTimeline } from '../shared/types' @@ -18,11 +9,9 @@ import type { SessionsFaceLike } from './agentTree' import { makeDetailFetcher } from './timelineSource' export interface AgentHeads { - /** The session's slim head off the (deduplicated) detail route; null when absent or failed. */ headOf(id: string): Promise } -/** One page-scope fetch cache, shared by every consumer the caller wires it into. */ export function makeAgentHeads(): AgentHeads { const heads = new Map>() return { @@ -37,17 +26,16 @@ export function makeAgentHeads(): AgentHeads { } } -/** The session-list snapshot as a React seat (null without the face). */ export function useSessionsSnapshot(face: SessionsFaceLike | null): unknown { const subscribe = useCallback((fn: () => void) => { if (face === null) return () => {} - /* v8 ignore next 2 -- sessionsFaceOf returns a face only after proving list.subscribe. */ + /* v8 ignore next 2 -- unreachable: sessionsFaceOf proved list.subscribe before returning the face. */ if (face.list === undefined) return () => {} return face.list.subscribe(fn) }, [face]) const getSnapshot = useCallback(() => { if (face === null) return null - /* v8 ignore next 2 -- sessionsFaceOf proves list before returning the face. */ + /* v8 ignore next 2 -- unreachable: sessionsFaceOf proved list before returning the face. */ if (face.list === undefined) return null return face.list.getSnapshot() }, [face]) diff --git a/src/client/agentTree.ts b/src/client/agentTree.ts index 8c90b1b..bae0c5c 100644 --- a/src/client/agentTree.ts +++ b/src/client/agentTree.ts @@ -1,22 +1,6 @@ -/** - * Agent-graph derivation — the pure model behind the Agent network card at - * the foot of the Context tab. - * - * The harness session-list snapshot (`ctx.sessions.list`) already carries - * everything the card needs, with zero extra RPC: lineage rows (`parentId`, - * `origin: 'subagent'`, `running`) plus each session's live projection - * values — the plugin's own `contextTimeline`, token-meter's - * `contextPressure`/`contextBreakdown`/`tokenUsage`, and the subagent - * domain's `subagent` identity / `subagentTiming`. This module folds that - * snapshot into the current session's agent family: the topmost known - * ancestor's whole subtree, per-node context stats, and a tidy - * depth-column/DFS-row layout for the SVG stage. - * - * Every read is defensive at the boundary (rows and projection values are - * re-proven field by field), so an older harness — missing service, missing - * keys, pre-lineage row shapes — degrades to fewer stats, never a render - * error. - */ +/** Agent-graph derivation — the pure model behind the Agent network card. The + * session-list snapshot carries everything the card needs, and this folds it + * into the current session's family: the topmost known ancestor's whole subtree, per-node stats, and a depth/DFS layout. */ import type { PartsPart } from './categories' import type { ContextTimeline, SessionCostUsage } from '../shared/types' @@ -24,14 +8,8 @@ import { mergeCostUsage } from './cost' import { headlineOf, occupancyPercent, type Headline } from './headline' import { asRecord, contextBreakdownOf, contextPressureOf, numOf, timelineOf, tokenUsageOf } from './services' -/** - * The outward `ctx.sessions` client face, minimally re-typed for the card: - * the list snapshot feed and direct-child catalog refresh. The real - * interface lives in the harness runtime; absence of any member degrades the - * corresponding feature (no live tree). Session navigation is NOT on this - * face (the 0.1.6 selection refactor moved it to the view owner) — the card - * jumps through services.ts's {@link openSessionVia}. - */ +/** The outward `ctx.sessions` client face. Session navigation is NOT here: the + * view owner's `openSession` is (services.ts `openSessionVia`). */ export interface SessionsFaceLike { list?: { getSnapshot(): unknown @@ -40,7 +18,6 @@ export interface SessionsFaceLike { refreshSubagents?(parentSessionId: string): Promise } -/** One session-list row, as far as the card consumes it (all reads re-proven). */ export interface AgentRow { displayTitle?: string title?: string @@ -59,33 +36,25 @@ export interface AgentIdentity { label?: string } -/** Per-node context stats, folded from the row's projection values. */ export interface AgentStats { - /** Occupancy + composition parts (null = nothing known about this agent's context). */ head: Headline | null - /** Retained request records of the session's timeline. */ requests: number - /** Total billed tokens across the whole session log (null = no usage reported yet). */ billed: number | null - /** Active-turn milliseconds (settled + open turn), null for non-subagent sessions. */ durationMs: number | null - /** Descriptor identity of a subagent session (null for the root agent). */ identity: AgentIdentity | null } -/** Live stats of the CURRENT session, fed by the tab's own projections (fresher than any list row). */ +/** Live stats of the CURRENT session, fresher than any list row. */ export interface AgentSelfStats { head: Headline | null billed: number | null requests: number } -/** One render-ready tree node. */ export interface AgentNode extends AgentStats { id: string label: string parentId?: string - /** Layout column (root = 0), assigned during the DFS. */ depth: number /** Which level-1 subtree this node belongs to (root = -1) — drives the family link hue. */ family: number @@ -99,9 +68,7 @@ export interface AgentForest { /** DFS pre-order (parents ahead of their children). */ nodes: AgentNode[] edges: { from: string; to: string }[] - /** Subtree members dropped by AGENT_TREE_LIMIT. */ overflow: number - /** True when the current agent stands alone (no relatives visible). */ solo: boolean } @@ -111,21 +78,17 @@ export const AGENT_TREE_LIMIT = 25 /** Donut geometry: node disc radius and the fused composition/occupancy ring radius (SVG units). */ export const AGENT_NODE_R = 26 export const AGENT_RING_R = 20 -/* Horizontal cell pitch adapts to the stage width between these bounds; each - node owns one cell, so the caption box (cell minus an 8px gutter) can never - clip at a neighbor or the stage edge. */ +/** Horizontal cell pitch adapts to the stage width between these bounds; each + * node owns one cell, so the caption box (cell minus an 8px gutter) can never clip at a neighbor or the stage edge. */ const SLOT_MAX = 184 const SLOT_MIN = 112 const CAPTION_GUTTER = 8 -/* Vertical pitch between depth levels: node radius + caption zone (up to 3 - wrapped lines + the tokens line) + a dedicated 28px link channel below it. */ +/** Vertical pitch between depth levels: node radius + caption zone + a dedicated 28px link channel below it. */ const LEVEL_H = 154 -/* Bottom edge of a node cell — links exit here, below the caption zone, so a - connector never crosses a label. */ +/** Bottom edge of a node cell — links exit here, below the caption zone, so a connector never crosses a label. */ const CELL_H = AGENT_NODE_R + 64 const PAD_Y = 56 -/** Narrow one list-row value; null when it is not a row at all. */ export function agentRowOf(value: unknown): AgentRow | null { const rec = asRecord(value) if (rec === null) return null @@ -143,7 +106,6 @@ export function agentRowOf(value: unknown): AgentRow | null { } } -/** Narrow the subagent identity projection value (null = not a descriptor-backed subagent). */ export function agentIdentityOf(value: unknown): AgentIdentity | null { const rec = asRecord(value) if (rec === null) return null @@ -154,7 +116,6 @@ export function agentIdentityOf(value: unknown): AgentIdentity | null { } } -/** Narrow the subagent timing projection value into a single duration (null = absent/malformed). */ export function agentDurationOf(value: unknown): number | null { const rec = asRecord(value) if (rec === null) return null @@ -165,13 +126,8 @@ export function agentDurationOf(value: unknown): number | null { return total > 0 ? total : null } -/** - * Fold one row's projection values into render-ready stats. The composition - * prefers the plugin's own `contextTimeline` (six buckets, provider-anchored - * by token-meter's pressure/breakdown — the exact headlineOf derivation the - * overview card shows); a pressure-only row (plugin host half absent for that - * session) still yields an occupancy ring without slices. - */ +/** 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 { const timeline = timelineOf(values?.contextTimeline) const pressure = contextPressureOf(values?.contextPressure) @@ -201,9 +157,7 @@ export function agentStatsOf(values: Record | undefined): Agent : null return { head, - // The split-generation wire head carries the tally precomputed (the - // request records ride the detail channel); the inline generation's rows - // count their served records. + // The split-generation wire head carries the tally precomputed; the inline generation's rows count their served records. requests: timeline !== null ? (timeline.counts?.steps ?? timeline.requests.length) : 0, billed, durationMs: agentDurationOf(values?.subagentTiming), @@ -217,19 +171,14 @@ interface AgentChild { } /** - * Build the current session's agent family from a session-list snapshot: - * walk up `parentId` to the topmost known ancestor, then DFS its whole - * subtree (blank placeholder rows excluded). Null when there is no anchor — - * no current session, or a snapshot without a `byId` table (older harness). - * The current session synthesizes a row when the list has not delivered it - * yet, so the card can still show its live self stats. + * Build the current session's agent family: walk up `parentId` to the topmost + * known ancestor, then DFS its whole subtree (blank rows excluded). Null when + * there is no anchor; the current session synthesizes a row when the list has + * not delivered it yet. * @param fetched - detail-channel heads for COLD rows, keyed by session id. - * The host's list block serves projection values only from the projection - * cache, so a relative that never attached since the timeline unit last - * changed lists pressure-only (no composition row) and its ring would - * degrade to the occupancy arc; the card fetches those heads on demand. - * A row's own value always wins — the injection lands only on rows whose - * `contextTimeline` is absent. + * The list block serves projection values only from the projection cache, so + * these fill in relatives that would otherwise list pressure-only; a row's + * own value always wins. */ export function agentForestOf( snapshot: unknown, @@ -243,16 +192,14 @@ export function agentForestOf( const rows = new Map() for (const key of Object.keys(byId)) { const row = agentRowOf(byId[key]) - // Blank placeholder sessions (never engaged) are not agents — the - // sidebar hides them too; the current session always stays. + // Blank placeholder sessions (never engaged) are not agents; the current session always stays. 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 }) } - // Topmost known ancestor (chain guard: a lineage cycle anchors at the - // first repeated id instead of looping). + // Topmost known ancestor; a lineage cycle anchors at the first repeated id. let root = currentId const chain = new Set([currentId]) for (;;) { @@ -262,8 +209,7 @@ export function agentForestOf( root = parent } const rootRow = rows.get(root) - /* v8 ignore next 2 -- root is currentId (inserted above) or a parent - verified with rows.has, so its row always exists. */ + /** v8 ignore next 2 -- root is currentId (inserted above) or a parent verified with rows.has, so its row always exists. */ if (rootRow === undefined) return null const childrenOf = new Map() @@ -273,7 +219,6 @@ export function agentForestOf( list.push({ id, row }) childrenOf.set(row.parentId, list) } - // Sibling order: running agents first, then freshest activity, id as the stable tiebreak. for (const kids of childrenOf.values()) { kids.sort((a, b) => { const runDelta = Number(b.row.running) - Number(a.row.running) @@ -283,8 +228,7 @@ export function agentForestOf( }) } - // Subtree size first (same seen-set semantics as the DFS), so the overflow - // note is exact even when the cap cuts the walk short. + // Measured before the DFS so the overflow note is exact even when the cap cuts the walk short. const measure = (id: string, seen: Set): number => { if (seen.has(id)) return 0 seen.add(id) @@ -318,14 +262,12 @@ export function agentForestOf( subagent: row.origin === 'subagent' || identity !== null, } if (node.isCurrent && self !== undefined) { - // The tab's own projections are the freshest cut of the current session. node.head = self.head ?? node.head node.billed = self.billed ?? node.billed node.requests = self.requests > 0 ? self.requests : node.requests } nodes.push(node) if (parentId !== undefined) edges.push({ from: parentId, to: id }) - // A level-1 child's index seeds the family hue; deeper nodes inherit it. const kids = childrenOf.get(id) ?? [] kids.forEach((kid, ki) => { visit(kid.id, kid.row, id, depth + 1, seen, depth === 0 ? ki : family) @@ -336,26 +278,17 @@ export function agentForestOf( return { nodes, edges, overflow: Math.max(0, total - nodes.length), solo: nodes.length === 1 } } -/** The current session's subagent-subtree cost fold (see {@link subagentCostFoldOf}). */ export interface SubagentCostFold { - /** Merged billed-token usage across the whole subtree (null = nothing reported yet). */ usage: SessionCostUsage | null /** Descendants with no timeline row and no landed head — the slim-head fetch targets. */ cold: string[] - /** Descendant sessions discovered in the list snapshot (blank placeholders are not agents). */ count: number } -/** - * Merge the current session's SUBAGENT subtree cost: every descendant - * session's own cumulative billed-token usage, warm rows' `contextTimeline` - * cost first, fetched slim heads (the `landed` map, keyed by session id) - * standing in for rows that carry no timeline value. Blank placeholder rows - * are not agents (the family forest's rule); a lineage cycle cannot loop the - * walk (seen-set). The current session itself is never counted — the caller - * already holds its own usage. `count` is the discovered descendant tally - * (cold ones included — a subagent is one whether or not its usage priced). - */ +/** Merge the current session's SUBAGENT subtree cost: warm rows' + * `contextTimeline` cost first, fetched slim heads (`landed`) standing in for + * rows with no timeline value. Blank rows are not agents; the current session + * itself is never counted — the caller already holds its own usage. */ export function subagentCostFoldOf( snapshot: unknown, currentId: string | undefined, @@ -383,9 +316,8 @@ export function subagentCostFoldOf( const head = landed?.get(kid) const values = agentRowOf(byId[kid])?.projections if (values?.contextTimeline === undefined) { - // A cold relative's usage rides its fetched slim head; until one - // lands the id stays a fetch target (the cache dedups re-attaches). - // A landed head is final — costless or not, it is no fetch target. + // A cold relative's usage rides its fetched slim head; until one lands + // the id stays a fetch target, and a landed head is final either way. if (head === undefined) cold.push(kid) else if (head.cost !== undefined) parts.push(head.cost) } else { @@ -408,7 +340,6 @@ export interface AgentPoint { export interface AgentLink { to: string running: boolean - /** Family hue of the child's level-1 subtree — parents are told apart by color. */ color: string x1: number y1: number @@ -419,24 +350,16 @@ export interface AgentLink { export interface AgentLayout { width: number height: number - /** Caption box width for this layout — follows the resolved slot pitch. */ captionW: number points: AgentPoint[] links: AgentLink[] } -/** - * Tidy top-down tree layout: one row per depth level, siblings claim leaf - * slots, parents center over their children. Fully responsive to the stage's - * visible width: the slot pitch stretches up to SLOT_MAX and compresses down - * to SLOT_MIN (captions wrap tighter); a level that still overflows wraps - * into bands of at most a per-level node count derived from the stage width — - * vertical room is cheaper than horizontal scrolling. Links exit a parent at - * its cell bottom (below the caption zone) and enter the child at its top, - * so a connector never crosses a label. - */ +/** Tidy top-down tree layout: siblings claim leaf slots and parents center over + * their children. The slot pitch adapts to the stage width between SLOT_MAX and + * SLOT_MIN; a level that still overflows wraps into bands, since vertical room is cheaper than horizontal scrolling. */ export function layoutForest(forest: AgentForest, stageWidth = 0): AgentLayout { - // Children lists in DFS order (nodes are DFS pre-order, so plain iteration appends in visit order). + // Nodes are DFS pre-order, so plain iteration appends children in visit order. const childrenOf = new Map() for (const n of forest.nodes) { if (n.parentId === undefined) continue @@ -445,7 +368,6 @@ export function layoutForest(forest: AgentForest, stageWidth = 0): AgentLayout { childrenOf.set(n.parentId, kids) } - // Tidy x: leaves claim successive slots; internal nodes center over their children. const slotOf = new Map() let leafSlots = 0 const place = (node: AgentNode): number => { @@ -467,28 +389,19 @@ export function layoutForest(forest: AgentForest, stageWidth = 0): AgentLayout { slotOf.set(node.id, slot) return slot } - /* v8 ignore next 1 -- a forest always holds at least the (possibly - synthesized) current node. */ + /** v8 ignore next 1 -- a forest always holds at least the (possibly synthesized) current node. */ if (forest.nodes.length > 0) place(forest.nodes[0]) - // Cell model: the layout is exactly `leafSlots` cells wide. While the cells - // fit the stage at the minimum pitch, the pitch simply adapts; beyond that, - // levels wrap into bands of `perLevel` cells — vertical room is cheaper - // than horizontal scrolling, and the stage never overflows. const perLevel = stageWidth > 0 ? Math.max(2, Math.floor(stageWidth / SLOT_MIN)) : 0 if (perLevel > 0 && leafSlots > perLevel) { - // Wrapped layout: bands of at most perLevel cells, interleaved by kinship - // — after each parent band come the bands of exactly those parents' - // children (sibling groups never split unless one group alone exceeds the - // band). DFS order is preserved at every level, so trunks from parents to - // child bands match monotonically and never cross. + // Wrapped layout: bands interleaved by kinship, so after each parent band + // come the bands of exactly those parents' children and sibling groups never split unless one group alone exceeds the band. const bandSlot = Math.min(SLOT_MAX, stageWidth / perLevel) const width = perLevel * bandSlot const points: AgentPoint[] = [] let row = 0 const emitBand = (nodes: AgentNode[], depth: number): void => { - // A short (last) band centers its cells instead of hugging the left edge. const inset = (width - nodes.length * bandSlot) / 2 nodes.forEach((node, i) => { points.push({ id: node.id, x: inset + (i + 0.5) * bandSlot, y: PAD_Y + row * LEVEL_H, depth }) @@ -523,12 +436,11 @@ export function layoutForest(forest: AgentForest, stageWidth = 0): AgentLayout { } } - // Tidy rows: adaptive pitch (0 = unmeasured stage → the natural maximum). + // Adaptive pitch; 0 = unmeasured stage (the natural maximum). const slot = stageWidth > 0 && leafSlots > 1 ? Math.min(SLOT_MAX, stageWidth / leafSlots) : SLOT_MAX const points: AgentPoint[] = forest.nodes.map(node => ({ id: node.id, - /* v8 ignore next 1 -- place() visits every node: the forest is exactly - the root's subtree by construction. */ + /** v8 ignore next 1 -- place() visits every node: the forest is exactly the root's subtree by construction. */ x: (slotOf.get(node.id) ?? 0) * slot + slot / 2, y: PAD_Y + node.depth * LEVEL_H, depth: node.depth, @@ -536,7 +448,6 @@ export function layoutForest(forest: AgentForest, stageWidth = 0): AgentLayout { const maxDepth = points.reduce((max, p) => Math.max(max, p.depth), 0) return { width: leafSlots * slot, - // The deepest level still carries its full caption cell below the node. height: PAD_Y + maxDepth * LEVEL_H + CELL_H + 28, captionW: slot - CAPTION_GUTTER, points, @@ -544,15 +455,12 @@ export function layoutForest(forest: AgentForest, stageWidth = 0): AgentLayout { } } -/** - * Family hue by level-1 subtree index: the golden angle keeps consecutive - * families maximally separated on the color wheel without a hand-tuned palette. - */ +/** Family hue by level-1 subtree index: the golden angle keeps consecutive + * families separated on the color wheel without a hand-tuned palette. */ export function familyHue(index: number): string { return `hsl(${Math.round(index * 137.508) % 360} 58% 52%)` } -/** Parent→child links: exit the parent's cell bottom, enter the child's top. */ function linksOf(forest: AgentForest, points: AgentPoint[]): AgentLink[] { const pointOf = new Map(points.map(p => [p.id, p])) const nodeOf = new Map(forest.nodes.map(n => [n.id, n])) @@ -561,8 +469,7 @@ function linksOf(forest: AgentForest, points: AgentPoint[]): AgentLink[] { for (const edge of forest.edges) { const from = pointOf.get(edge.from) const to = pointOf.get(edge.to) - /* v8 ignore next 2 -- edges are emitted only for visited parent/child - pairs, so both points always exist. */ + /** v8 ignore next 2 -- edges are emitted only for visited parent/child pairs, so both points always exist. */ if (from === undefined || to === undefined) continue links.push({ to: edge.to, @@ -580,24 +487,17 @@ function linksOf(forest: AgentForest, points: AgentPoint[]): AgentLink[] { export interface RingSeg { key: string - /** Segment color; unused for the free remainder (styled by its CSS class). */ + /** Segment color; the free remainder is styled by its CSS class instead. */ color: string - /** Arc length along the circle's circumference. */ len: number - /** Arc start, as a (negative) stroke dash offset. */ offset: number - /** True for the unoccupied-window remainder. */ free: boolean } -/** - * One fused ring per agent — the exact semantics of the chat composer's own - * context ring: the composition parts, scaled to the occupancy share of the - * window, fill the circle, and a neutral remainder marks the free window. - * With no known window the composition fills the whole circle; with no - * composition (pressure-only rows) a single threshold-colored arc carries - * the occupancy; a known window with zero occupancy draws the free outline. - */ +/** One fused ring per agent — the chat composer ring's own semantics: the + * composition parts, scaled to the occupancy share, fill the circle and a + * neutral remainder marks the free window. No known window fills the whole + * circle; no composition falls back to a single occupancy arc. */ export function ringSegments(parts: PartsPart[], pct: number | null, radius: number, fallbackColor: string): RingSeg[] { const circumference = 2 * Math.PI * radius const occ = pct === null ? 1 : Math.min(100, Math.max(0, pct)) / 100 @@ -614,7 +514,6 @@ export function ringSegments(parts: PartsPart[], pct: number | null, radius: num offset += len } } else if (pct !== null && occ > 0) { - // Pressure-only node: a solid occupancy arc in the threshold color. segs.push({ key: 'fill', color: fallbackColor, len: circumference * occ, offset: 0, free: false }) offset = circumference * occ } @@ -624,7 +523,6 @@ export function ringSegments(parts: PartsPart[], pct: number | null, radius: num return segs } -/** Narrow `ctx.get('sessions')` to the card's face (null = harness without the outward sessions service). */ export function sessionsFaceOf(ctx: { get(name: string): unknown }): SessionsFaceLike | null { const rec = asRecord(ctx.get('sessions')) if (rec === null) return null @@ -633,12 +531,8 @@ export function sessionsFaceOf(ctx: { get(name: string): unknown }): SessionsFac return rec } -/** - * Compact duration: `42s`, `3m05s`, `1h07m` (shared by both locales). - * Deliberately distinct from format.ts's `fmtDuration` (the timing card's - * `12.3s` / `3m25s`): the inspector's caption column needs whole-second, - * fixed-width text. - */ +/** Compact duration: `42s`, `3m05s`, `1h07m`. Distinct from format.ts's + * `fmtDuration`, whose decimal seconds the inspector's fixed-width caption column cannot use. */ export function fmtDurationCompact(ms: number): string { if (!Number.isFinite(ms) || ms < 0) return '—' const s = Math.round(ms / 1000) diff --git a/src/client/assemble.ts b/src/client/assemble.ts index df70e82..d0ae032 100644 --- a/src/client/assemble.ts +++ b/src/client/assemble.ts @@ -1,42 +1,21 @@ -/** - * Per-step context assembly — the pure reconstruction behind the Context - * browser card. - * - * A request record R was snapshotted by the Host exactly as dispatched: - * header in force + the model-visible surface BEFORE the response landed. - * Given the timeline's live `nodes` (newest tail) and `archive` (removed - * nodes stamped with `gone`), the surface assembled into R is: - * - * every node with seq < R.seq AND (gone undefined OR gone > R.seq) - * - * For the LIVE view (the next request's context) it is simply the current - * live nodes. Coverage is honest: when the served window cannot contain the - * full answer (older live nodes dropped, or removals older than the archive - * retention), the result carries the flags the UI turns into notices. - */ +/** Per-step context assembly — the pure reconstruction behind the Context browser card. A request record + * was snapshotted by the Host exactly as dispatched: header in force plus the model-visible surface + * BEFORE the response landed. Coverage is honest: when the served window cannot contain the full answer + * (older live nodes dropped, removals past archive retention), the result carries the flags the UI turns into notices. */ import type { ContextHeaders, ContextTimeline, HeaderRecord, SurfaceNode, SystemPromptNode } from '../shared/types' export interface Assembled { - /** True when browsing the live surface (the next request's context). */ live: boolean - /** The header epoch in force (null = headers projection absent or none yet). */ header: HeaderRecord | null - /** - * The system prompt in force at the shown step (null = none), resolved from - * the timeline's live `systems` nodes; on rows folded before that field - * existed the header epoch's own envelope figure stands in. - */ + /** Resolved from the timeline's live `systems` nodes; on rows folded before that field existed, the + * header epoch's own envelope figure stands in. */ system: SystemPromptNode | null - /** The assembled surface messages, in seq order. */ nodes: SurfaceNode[] - /** Live nodes outside the served window that are also part of the context. */ missingLive: number - /** True when removals this step depends on may exceed archive retention. */ approximate: boolean } -/** The header epoch in force at `seq` (last logged before it), or the newest. */ export function headerAt(headers: ContextHeaders | null, seq: number | null): HeaderRecord | null { if (headers === null || headers.headers.length === 0) return null if (seq === null) return headers.headers[headers.headers.length - 1] @@ -46,15 +25,9 @@ export function headerAt(headers: ContextHeaders | null, seq: number | null): He return null } -/** - * The system prompt in force at `seq` (null = none) — the LAST live system - * node at or before it carrying tokens, which is exactly the host fold's - * "last nonempty surviving system" rule and therefore agrees with the - * per-step `system` figure the fold recorded. Rows folded before `systems` - * existed carry none; the header epoch's own figure stands in for them (the - * legacy wire shape older plugin builds served — current epochs fold - * metadata-only, so the fallback only ever fires on those cached rows). - */ +/** The LAST live system node at or before `seq` carrying tokens — exactly the host fold's "last nonempty + * surviving system" rule, so it agrees with the per-step `system` figure the fold recorded. Rows folded + * before `systems` existed fall back to the header epoch's figure. */ export function systemAt( data: ContextTimeline, header: HeaderRecord | null, @@ -93,10 +66,8 @@ export function assemble(data: ContextTimeline, headers: ContextHeaders | null, let missingLive = 0 if (data.droppedNodes > 0) { - // Live: every dropped live node is part of the current context. A past - // step: the dropped slice sits at or below `surfaceFloor`, so a step - // after it contains them all (an older step's unknown subset is left - // unflagged rather than overstated). + // Live: every dropped live node is part of the current context. A past step: the dropped slice sits at + // or below `surfaceFloor`, so only a step after it is flagged (an older step's subset is not overstated). if (live || (data.surfaceFloor !== undefined && seq > data.surfaceFloor)) { missingLive = data.droppedNodes } diff --git a/src/client/balance.ts b/src/client/balance.ts index 8202973..818710e 100644 --- a/src/client/balance.ts +++ b/src/client/balance.ts @@ -1,31 +1,17 @@ -/** - * The DeepSeek platform balance behind the Context Dashboard's header - * capsule: the figure the previous open remembered (memory first, then - * storage) shown at once, plus one background read of the plugin's - * `/api/dsh-context/balance` fetch route (host/balance.ts) on every open, so - * the remembered total never survives a stale one for long. - * - * A remembered figure is a stale-while-revalidate display choice, not an - * error state: the platform's balance moves far slower than the dashboard - * opens, and a stale figure that a background read corrects beats a blank - * pill on every visit. An open that finds nothing remembered — and a route - * that answers nothing while nothing is remembered — resolves `null`, and - * the capsule renders nothing: a balance viewer shows a real number or it - * shows nothing at all, never a spinner or an error where a pill should be. - */ +/** The DeepSeek platform balance behind the Context Insights page's header capsule: the figure the previous + * open remembered paints at once, and one background read of the plugin's `/api/dsh-context/balance` + * route (host/balance.ts) corrects it. Nothing remembered and nothing answered renders nothing — never a + * spinner or an error where a pill should be. */ import type { PlatformBalance, PlatformBalanceEntry } from '../shared/types' import { asRecord } from './services' -// The balance route of host/balance.ts — re-declared here (the client bundle -// inlines every import, and the host module must never reach it). Same-origin -// POST under the harness's authenticated `/api` fence. +// The balance route of host/balance.ts, re-declared because the client bundle inlines every import; +// same-origin POST under the harness's authenticated `/api` fence. const BALANCE_ROUTE = '/api/dsh-context/balance' -/** Where the last live figure is remembered across opens, per browser. */ const STORAGE_KEY = 'dsh-context:platform-balance' -/** The slice of Web Storage this module consumes, so tests can swap it. */ export interface StorageFace { getItem(key: string): string | null setItem(key: string, value: string): void @@ -37,29 +23,20 @@ export function setPlatformBalanceStorage(next: StorageFace | null | undefined): storage = next } -/** Test seam: the key the remembered figure lives under. */ export const PLATFORM_BALANCE_STORAGE_KEY = STORAGE_KEY -/** One platform amount ('110.00', or an already-numeric producer variant), or null. */ function amountOf(value: unknown): number | null { const n = typeof value === 'string' ? Number(value) : value return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : null } -/** - * Narrow the delivered payload to a render-safe value (the same boundary - * rigor as `timelineOf`): each entry's currency and amounts re-proved, an - * entry failing the shape drops whole, and a payload with no valid entry is - * no balance — the caller renders nothing rather than half a figure. - */ export function platformBalanceOf(value: unknown): PlatformBalance | null { const data = asRecord(value) if (data === null) return null const infos = Array.isArray(data.balances) ? data.balances : [] const balances: PlatformBalanceEntry[] = [] for (const info of infos) { - // Bounded catch: a hostile entry may throw on property access — it drops - // whole, and the entries that prove their shape keep serving. + // A hostile entry may throw on property access; it drops whole, the entries that prove their shape serve on. try { const entry = asRecord(info) if (entry === null) continue @@ -79,11 +56,7 @@ export function platformBalanceOf(value: unknown): PlatformBalance | null { : null } -/** - * The entry matching the display currency (zh → CNY, en → USD), falling back - * to the account's first entry when the platform reports none in it — a - * number in the account's own currency beats no number. - */ +/** Display-currency entry, falling back to the account's first — a number in another currency beats none. */ export function balanceEntryOf( balance: PlatformBalance | null | undefined, currency: 'cny' | 'usd', @@ -96,34 +69,24 @@ export function balanceEntryOf( let cached: PlatformBalance | null = null /** The route read in flight, so a second open joins it instead of starting one. */ let refreshing: Promise | null = null -/** - * The landing callbacks of every open riding the in-flight read — all - * notified when it lands, so an open that JOINS mid-flight (a fast - * close-reopen, a second capsule) settles on the live figure too, exactly - * as this function's contract promises (issue #82). Cleared when the read - * settles: the next open starts a fresh read and a fresh set. - */ +/** Every open riding the in-flight read, joiners included; cleared when the read settles, so the next + * open starts a fresh read and a fresh set. */ const landingCallbacks = new Set<(value: PlatformBalance) => void>() -/** The storage this module reads and writes; swapped in tests. */ let storage: StorageFace | null | undefined function storageFn(): StorageFace | null { if (storage !== undefined) return storage try { - // Runtime-guarded: the browser always has it, but a host may not, and the - // report type says it is present either way. const found: unknown = globalThis.localStorage storage = found === null || found === undefined ? null : found as StorageFace } catch { - // Storage can throw on mere access (some privacy modes); the capsule then - // has no remembered figure — the same shape as a first-ever open. + // Storage can throw on mere access (some privacy modes): the capsule then has no remembered figure. storage = null } return storage } -/** The stored figure, or `null` when absent, unreadable, or no longer a valid balance. */ function storedBalance(): PlatformBalance | null { try { const raw = storageFn()?.getItem(STORAGE_KEY) @@ -137,8 +100,7 @@ function rememberBalance(value: PlatformBalance | null): void { try { storageFn()?.setItem(STORAGE_KEY, JSON.stringify(value)) } catch { - // A refused write (quota, privacy mode) costs only the next open's head - // start; the live figure still renders. + // A refused write (quota, privacy mode) costs only the next open's head start; the live figure renders. } } @@ -158,17 +120,10 @@ async function readRoute(): Promise { } } -/** - * One route read per open, with `onRefresh` once it lands on a figure: the - * capsule paints the remembered total immediately and settles on the live - * one. `refreshing` is assigned before anything awaits, so an open arriving - * while a read is still in flight joins it rather than starting a second one; - * its callback joins the landing set, so the read's landing reports to EVERY - * open riding it — the joiner included (issue #82). A failed or absent read - * notifies nobody and keeps serving the remembered figure, so an offline open - * never trades a number for a blank pill; `null` (whether pending, absent, or - * failed) means nothing to show yet. - */ +/** One route read per open, `onRefresh` once it lands on a figure. `refreshing` is assigned before anything + * awaits, so an open arriving mid-flight joins the read rather than starting a second one, and the read's + * landing reports to every open riding it. A failed or absent read notifies nobody and keeps serving the + * remembered figure; `null` means nothing to show yet. */ export function readPlatformBalance(onRefresh: (value: PlatformBalance) => void): PlatformBalance | null { landingCallbacks.add(onRefresh) if (refreshing === null) { @@ -186,7 +141,6 @@ export function readPlatformBalance(onRefresh: (value: PlatformBalance) => void) return cached ?? storedBalance() } -/** Test isolation: drop the cache, the storage contents, and the storage swap. */ export function resetPlatformBalance(): void { cached = null refreshing = null @@ -194,7 +148,7 @@ export function resetPlatformBalance(): void { try { storageFn()?.removeItem(STORAGE_KEY) } catch { - // Nothing to drop when storage is unreadable. + /* nothing to drop */ } storage = undefined } diff --git a/src/client/brief.ts b/src/client/brief.ts index b26c402..495741e 100644 --- a/src/client/brief.ts +++ b/src/client/brief.ts @@ -1,34 +1,19 @@ -/** - * Step brief — the semantic identity of one history bar ("what this step was - * about"), derived CLIENT-SIDE from the timeline's served nodes; no host or - * wire additions. A request record's seq IS its response's surface seq (the - * fold logs both on the same assistant/message event), so: - * - * response node = the node at exactly req.seq - * step inputs = nodes with (previous bar's seq) < seq < req.seq - * turn opener = the newest user message before this turn's first bar - * - * Coverage degrades silently: steps older than the nodes/archive retention - * window simply yield no rows. +/** Step brief — the identity of one history bar, derived from the timeline's + * served nodes. A request record's seq IS its response's surface seq (the fold logs both on the same assistant/message event). */ import type { ContextTimeline, RequestRecord, SurfaceNode } from '../shared/types' export interface StepBrief { - /** The user message that opened this turn (absent when outside retention). */ opener?: SurfaceNode - /** Nodes that entered the context since the previous bar (mid-turn steps only). */ inputs: SurfaceNode[] - /** This bar's own response node. */ response?: SurfaceNode } -/** Every served node (live tail + removed archive copies), seq-sorted. */ export function briefNodes(data: ContextTimeline): SurfaceNode[] { return [...data.nodes, ...data.archive].sort((a, b) => a.seq - b.seq) } -/** First index whose node seq is >= `seq` (lower bound over the sorted list). */ function lowerBound(nodes: SurfaceNode[], seq: number): number { let lo = 0 let hi = nodes.length @@ -40,11 +25,7 @@ function lowerBound(nodes: SurfaceNode[], seq: number): number { return lo } -/** - * Derive the brief for `requests[idx]` (the DISPLAY list — step records or - * turn aggregates; a turn aggregate is always a turn start, so its inputs row - * stays hidden and the opener/response carry the narrative). - */ +/** Derive the brief for `requests[idx]` (the display list: step records or turn aggregates). */ export function briefOf(nodes: SurfaceNode[], requests: RequestRecord[], idx: number): StepBrief | null { if (idx < 0 || idx >= requests.length) return null const req = requests[idx] @@ -54,8 +35,7 @@ export function briefOf(nodes: SurfaceNode[], requests: RequestRecord[], idx: nu const turnStart = idx === 0 || (requests[idx - 1].turn ?? 0) !== (req.turn ?? 0) - // Opener: the newest user message in (previous turn's last bar seq, this - // turn's first bar seq) — user messages only land at turn starts. + // Opener: the newest user message before this turn's first bar (user messages only land at turn starts). let firstIdx = idx while (firstIdx > 0 && (requests[firstIdx - 1].turn ?? 0) === (req.turn ?? 0)) firstIdx-- const upper = requests[firstIdx].seq @@ -69,7 +49,7 @@ export function briefOf(nodes: SurfaceNode[], requests: RequestRecord[], idx: nu const inputs: SurfaceNode[] = [] if (!turnStart) { - // idx > 0 here (a first bar is always a turn start): collect everything that landed since the previous bar. + // A first bar is always a turn start, so idx > 0 here. const prevSeq = requests[idx - 1].seq for (let i = lowerBound(nodes, prevSeq + 1); i < nodes.length && nodes[i].seq < req.seq; i++) { inputs.push(nodes[i]) diff --git a/src/client/callSummary.ts b/src/client/callSummary.ts index 4fd3498..3c13d24 100644 --- a/src/client/callSummary.ts +++ b/src/client/callSummary.ts @@ -1,13 +1,6 @@ -/** - * Tool-call argument summaries — shared between the Context browser (row - * previews) and the step brief (input chips / reply enrichment). A call's - * preview is its bash-style `description`, or the target path - * (`file_path`/`path`/`filePath`) for edit/read/write tools. - */ +/** Tool-call argument summaries shared by the Context browser's row previews and the step brief. */ import type { ConversationNodeLike } from './services' -// The argument parser lives in the shared layer (shared/fileOps.ts) since the -// op-log generation — the host fold parses the same raw strings there. import { parseCallArgs } from '../shared/fileOps' export { parseCallArgs } @@ -36,10 +29,8 @@ export function blockSummaryOf(conv: ConversationNodeLike | undefined): string | return null } -/** - * All tool-call names in an assistant conversation node, in order. The fold's surface node keeps `calls` only for TEXT-LESS replies, - * so a reply carrying both text and calls recovers its call breadcrumb here through the conversation join. - */ +/** All tool-call names in an assistant node: the fold keeps `calls` only for + * text-less replies, so a reply with both text and calls recovers its call breadcrumb through the conversation join. */ export function callNamesOf(conv: ConversationNodeLike | undefined): string[] { if (conv === undefined || !Array.isArray(conv.blocks)) return [] const names: string[] = [] diff --git a/src/client/categories.ts b/src/client/categories.ts index 3622751..3f1186a 100644 --- a/src/client/categories.ts +++ b/src/client/categories.ts @@ -1,28 +1,18 @@ -/** - * Category presentation config: the seven priced buckets (system, tool - * schemas, and the five surface categories) with their chart colors, plus - * the part builders behind the composition card. - * - * Two figures ride on every part, mirroring the official chat context - * meter's own split: `raw` is the heuristic count (the meter panel's `~` - * rows — identical to the ring panel by construction when the official - * `contextBreakdown` projection feeds it), while `value` is the - * provider-anchored bar width (the ring's fill proportioned by the - * heuristic ratios). Without a provider anchor the two are equal. - */ +/** Category presentation config. Every part carries two figures: `raw`, the + * heuristic count, and `value`, the provider-anchored bar width proportioned by + * the heuristic ratios (equal when no anchor applies). */ import type { Category, ContextBreakdown, RequestRecord, Snapshot, TokenUsage } from '../shared/types' export interface PartsPart { key: string color: string - /** Bar-width figure (provider-anchored when an anchor applies). */ value: number /** Heuristic count shown by the legend and tooltips (defaults to value). */ raw?: number - /** Tooltip display name (defaults to the category label of `key`) — DNA mode names individual items. */ + /** Tooltip display name, defaulting to the category label of `key`. */ label?: string - /** Shared-hover group: an incoming hover key equal to a part's group lights it (DNA bands light per category). */ + /** Shared-hover group: a hover key equal to a part's group lights it. */ group?: string } @@ -36,7 +26,6 @@ export const CATS: { key: Category | 'system' | 'tools'; color: string }[] = [ { key: 'tool', color: 'var(--color-teal-500)' }, ] -/** Category key → bar color, for per-item bands (the browser's DNA mode) that bypass the CATS-order part builders. */ export const CAT_COLOR = Object.fromEntries(CATS.map(c => [c.key, c.color])) as Record const MESSAGE_CATS: readonly (Category | 'system' | 'tools')[] = ['user', 'inject', 'skill', 'assistant', 'tool'] @@ -47,15 +36,8 @@ export function partsOf(breakdown: Snapshot['current'] | RequestRecord): PartsPa }) } -/** - * Build the pie-consistent raw parts: system/tools/messages take the - * OFFICIAL `contextBreakdown` figures when delivered (the exact counts the - * chat ring's panel shows), with the message bucket subdivided into the - * four surface categories by the fold's per-category ratios (rounding - * residue lands on the largest category, so the four always sum exactly to - * the official message figure). Absent the projection, the fold's own sums - * serve — the same fixed estimator, so identical on image-free sessions. - */ +/** Build the pie-consistent raw parts from the OFFICIAL `contextBreakdown` + * figures when delivered, subdividing messages by the fold's ratios; absent the projection the fold's own sums serve. */ export function officialParts( current: Snapshot['current'], breakdown: ContextBreakdown | null, @@ -74,8 +56,8 @@ export function officialParts( assigned += count if (current[cat as Category] > current[largest]) largest = cat as Category } - // Rounding residue lands on the largest category; clamp so a tiny - // message bucket with several rounded-up shares never goes negative. + // Rounding residue lands on the largest category; the clamp keeps a tiny + // message bucket with several rounded-up shares from going negative. shares[largest] = Math.max(0, shares[largest] + messages - assigned) } else { for (const cat of MESSAGE_CATS) shares[cat] = 0 @@ -83,21 +65,14 @@ export function officialParts( return CATS.map(c => ({ key: c.key, color: c.color, - /* v8 ignore next 1 -- `shares` is initialized with system/tools and both - foldSurface arms assign every MESSAGE_CATS key, so each key is always - defined; the fallback is defensive. */ + /** v8 ignore next 1 -- `shares` is initialized with system/tools and both + * foldSurface arms assign every MESSAGE_CATS key, so each key is always defined; the fallback is defensive. */ value: shares[c.key] ?? 0, })) } -/** - * Reproportion heuristic parts so they sum to a provider-anchored target — - * the same trick the official ContextMeter uses: the heuristic breakdown - * supplies the composition RATIOS, the provider sample the total. The - * anchored figure rides `value` (bar widths); the heuristic count stays on - * `raw` for the legend and tooltips. Returns the parts unchanged when no - * anchor applies. - */ +/** Reproportion heuristic parts to a provider-anchored target: the heuristic + * supplies the ratios, the provider sample the total; `value` takes the anchored figure and `raw` keeps the heuristic count. */ export function anchoredParts(parts: PartsPart[], target: number | null): PartsPart[] { const sourced = parts.map(p => ({ ...p, raw: p.raw ?? p.value })) if (target === null || target <= 0) return sourced @@ -109,18 +84,9 @@ export function anchoredParts(parts: PartsPart[], target: number | null): PartsP return sourced.map(p => ({ ...p, value: Math.round(p.raw * scale) })) } -/** - * The Token card's billed split, by WHAT the tokens are rather than by how - * the provider cached them: the six composition categories share the - * provider-reported prompt-side total (uncached + cache read + cache write — - * the chat stats line's billed input) by the composition card's own - * estimated ratios, and the provider's exact output count closes the ring as - * the seventh part. Only the per-category split is estimated — every - * category's sum and the output figure are provider-reported, so the parts - * total equals the chat line's whole-session token count by construction. A - * zero/negative prompt total (or a hostile negative output) never invents a - * split: the prompt parts zero out / the output clamps at 0. - */ +/** The Token card's billed split by WHAT the tokens are: the six composition + * categories share the provider-reported prompt total by the composition card's + * ratios, and the exact output count closes the ring, so the parts total the chat line's token count by construction. */ export function billedParts( current: Snapshot['current'], breakdown: ContextBreakdown | null, diff --git a/src/client/command.ts b/src/client/command.ts index e52b836..899340b 100644 --- a/src/client/command.ts +++ b/src/client/command.ts @@ -1,14 +1,6 @@ -/** - * `/context` — a client-owned slash command that opens the context modal - * (current composition + recent trend) in the center of the page. - * - * Implemented as the plugin's own '/' trigger source instead of a host - * command: nothing is dispatched to the host, no session log records are - * written, and nothing becomes model-visible — the invocation never enters - * the message history. Both paths answer `'handled'` and open the modal, - * leaving the `/context` token in the composer while it is open; the modal's - * close path consumes the token then (see modalStore.ts). - */ +/** `/context` runs as the plugin's own '/' trigger source, not a host command: nothing is dispatched, no + * session log records are written, nothing becomes model-visible. Both paths answer `'handled'`, open the + * modal, and leave the token for the close path. */ import { ContextIcon } from './icon' import { modalStoreOf, setPendingConsume } from './modalStore' @@ -19,10 +11,8 @@ const COMMAND = 'context' const LINE = '/' + COMMAND export function registerContextCommand(ctx: ClientCtx, kit: ViewKit): void { - // Wait for the SERVICE, not for module arrival order: dsh composes - // the client from finer modules, so `inputTriggers` may not be provided - // yet when this plugin applies. A harness without the service never fires - // the callback — the tab keeps working and only the command is absent. + // Wait for the SERVICE, not module arrival order: dsh composes the client from finer modules, so + // `inputTriggers` may not be provided yet; a harness without it never fires the callback. ctx.inject(['inputTriggers'], (ictx) => { const inputTriggers = (ictx as ClientCtx).get('inputTriggers') as InputTriggersFace | undefined if (inputTriggers === undefined || typeof inputTriggers.registerSource !== 'function') return @@ -34,11 +24,8 @@ export function registerContextCommand(ctx: ClientCtx, kit: ViewKit): void { if (req.position !== 'leading') return Promise.resolve([]) const query = req.query.trim().toLowerCase() if (query !== '' && !COMMAND.startsWith(query)) return Promise.resolve([]) - // A sectioned candidate replaces the menu's source-title row, which - // otherwise renders the raw, unlocalizable source name (`context` is - // not a key of the harness's own slash.menu dictionary). Label and - // glyph are pure display data: builds that predate their rendering - // keep showing the bare name. + // A sectioned candidate replaces the menu's source-title row, which otherwise renders the raw + // source name (`context` is not a key of the harness's `slash.menu`). return Promise.resolve([{ name: COMMAND, label: kit.t('cmd.label'), @@ -48,8 +35,6 @@ export function registerContextCommand(ctx: ClientCtx, kit: ViewKit): void { }]) }, onPick: (pick) => { - // Open and remember the token span: the modal's close path consumes - // it (span CAS — a draft changed meanwhile is left untouched). setPendingConsume(pick.session.sessionId, { kind: 'span', span: pick.span }) modalStoreOf(pick.session.sessionId).set(true) return 'handled' diff --git a/src/client/components/agentGraph.tsx b/src/client/components/agentGraph.tsx index 421ac23..79be3a0 100644 --- a/src/client/components/agentGraph.tsx +++ b/src/client/components/agentGraph.tsx @@ -61,9 +61,8 @@ export function makeAgentGraph( const { t, fmt, catLabel } = kit function AgentGraph(props: AgentGraphProps): ReactElement | null { - // Resolved lazily at mount (not at apply): the outward sessions service - // belongs to the client runtime's composition, and a deployment without - // it simply keeps the card hidden. + // Resolved lazily at mount (not at apply): the outward sessions service belongs to the client runtime's + // composition, and a deployment without it keeps the card hidden. const face = useMemo(() => sessionsFaceOf(ctx), []) const snapshot = useSessionsSnapshot(face) const sessionId = props.sessionId @@ -103,8 +102,7 @@ export function makeAgentGraph( }, [face, sessionId]) // Composition heads fetched for cold relatives (see the effect below): - // landed values re-fold the forest with the row's missing `contextTimeline` - // injected. + // landed values re-fold the forest with the row's missing `contextTimeline` injected. const [landed, setLanded] = useState>(new Map()) const built = useMemo(() => { @@ -118,14 +116,12 @@ export function makeAgentGraph( // composed. The current node is excluded: the tab's own projections // already feed it live. A remount (tab switch) resets this state but not // the cache, so a cached read REPLAYS into the fresh instance — - // otherwise a fetched relative would fall back to green on every - // remount, forever. + // otherwise a fetched relative would fall back to green on every remount, forever. useEffect(() => { if (built === null) return const attach = (pending: Promise, id: string): void => { void pending.then((head) => { - // Same value → same state: the identity bail-out keeps a settled - // replay on every snapshot tick from looping. + // Same value → same state: the identity bail-out keeps a settled replay on every snapshot tick from looping. if (head !== null) setLanded(prev => prev.get(id) === head ? prev : new Map(prev).set(id, head)) }).catch(() => {}) } @@ -138,8 +134,7 @@ export function makeAgentGraph( if (built === null) return null const { forest, layout } = built const byId = new Map(forest.nodes.map(n => [n.id, n])) - /* v8 ignore next 1 -- agentForestOf anchors the forest at the current - session, so a current node always exists. */ + /** v8 ignore next 1 -- agentForestOf anchors the forest at the current session, so a current node always exists. */ const current = forest.nodes.find(n => n.isCurrent) ?? forest.nodes[0] const inspected = (hoverId !== null ? byId.get(hoverId) : undefined) ?? current const runningCount = forest.nodes.filter(n => n.running).length @@ -201,8 +196,7 @@ export function makeAgentGraph( })} {forest.nodes.map((node) => { const point = layout.points.find(p => p.id === node.id) - /* v8 ignore next 2 -- layoutForest positions every forest node, - so the lookup never misses. */ + /** v8 ignore next 2 -- layoutForest positions every forest node, so the lookup never misses. */ if (point === undefined) return null return ( ReactElement | null { const { t } = kit return function BalanceCapsule(): ReactElement | null { - // The remembered figure seeds the first paint (client/balance.ts) while the - // background read runs; a landing figure re-renders through the callback. const [balance, setBalance] = useState(() => readPlatformBalance((v) =>{ setBalance(v) })) const locale = ctx.locale const active = typeof locale.getLocale === 'function' ? locale.getLocale().active : 'en' const entry = balanceEntryOf(balance, active === 'zh' ? 'cny' : 'usd') if (entry === null) return null const money = (amount: number): string => symbolOf(entry.currency) + amount.toFixed(2) - // The pill itself carries the total; the tooltip lists only the non-zero - // parts of the breakdown, and an all-zero account has nothing to break - // down — the pill then rides bare. + // The tooltip lists only the non-zero breakdown parts, so an all-zero account rides bare. const tipLines = [ ...(entry.toppedUp > 0 ? [t('balance.tip.toppedUp') + ': ' + money(entry.toppedUp)] : []), ...(entry.granted > 0 ? [t('balance.tip.granted') + ': ' + money(entry.granted)] : []), diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 8e1a19c..6c6b3d7 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -24,67 +24,28 @@ import type { ImageLoader, ImageRefLike } from '../services' export interface ContextBrowserProps { data: ContextTimeline headers: ContextHeaders | null - /** - * The conversation-window nodes, resolved by the caller from the `useChat` - * seat — the browser itself stays seat-free so the hook order lives in - * exactly one place. - */ + /** Resolved by the caller so the `useChat` seat's hook order lives in exactly one place. */ convNodes?: readonly ConversationNodeLike[] - /** - * Targeted full-content fetch for nodes outside the conversation window: - * one seq-anchored history read per expanded row (absent on older hosts — - * those keep the preview-plus-hint degradation). - */ fetchContent?: ContentFetcher - /** - * On-demand CONTENT fetch for the selected step's `contextHeaders` epoch - * (system prompt text, tool descriptions/schemas) — one seq-anchored - * history read per epoch, cached per session (absent on older hosts — - * those keep the metadata-only degradation). - */ fetchHeader?: HeaderFetcher /** Preview-seq: hover transiently previews that step; the picker's own selection resumes when the pointer leaves the chart. */ previewSeq?: number | null /** Pin-seq: a pin selects that step; pinSeq null returns the browser to the live surface. */ pinSeq?: number | null - /** - * DNA mode's on/off, LINKED across cards: the Context tab passes its trend card's toggle state - * so both DNA switches move as one. Controlled only when BOTH props arrive; absent (the - * /context modal) — the browser keeps its own mount-local toggle. - */ + /** Controlled only when BOTH props arrive; absent (the /context modal) — the browser keeps its own mount-local toggle. */ dna?: boolean onDnaChange?: (on: boolean) => void - /** - * One-shot reveal request (the step brief's locate, a trend-DNA band pick): select the step, open the - * item's category and element, scroll it into view. `key` is the band key both DNA surfaces share — - * 'sys' (system prompt), 'tool:' (a tool schema), 'n' (a message) — so the same bridge - * serves header bands and message nodes; handed back via `onNodeFocusHandled` so the same row can - * fire again. - */ + /** One-shot reveal request: `key` is the band key both DNA surfaces share — 'sys' (system prompt), + * 'tool:' (a tool schema), 'n' (a message) — so the same bridge serves header bands and message + * nodes; handed back via `onNodeFocusHandled` so the same row can fire again. */ nodeFocus?: { step: number | 'live'; key: string; cat: Category | 'system' | 'tools' } | null onNodeFocusHandled?: () => void - /** - * One-shot CATEGORY reveal (the stats card's figure cells): open the named - * category on the live surface, optionally with the assistant kind chip - * picked ('answer' for the Answers figure), the row lens cleared. No element - * row opens — the lens is the destination. Handed back via - * `onCatFocusHandled` so the same cell can fire again. - */ catFocus?: CatFocus | null onCatFocusHandled?: () => void hoverKey?: string | null onHoverKey?: (key: string | null) => void - /** - * Reports the open category (null once none opens): the Context tab focuses the trend chart's bars on it. - * Absent (the /context modal) — the accordion stays purely internal. - */ onOpenCat?: (cat: string | null) => void loadImage?: ImageLoader - /** - * The timeline source's detail state (split generation): while the first - * detail read is pending or settled without data, a note strip names the - * state (the picker/sections otherwise show a misleading empty surface). - */ detailState?: DetailState onDetailRetry?: () => void } @@ -138,10 +99,8 @@ function typeOf(p: ParamSchema): string { return 'unknown' } -/** - * Tool schemas nest parameters under `parameters`, `input_schema`, or `inputSchema` (producer-dependent), or bare when `type === 'object'` - * — `{type:'object', properties}` at the root is itself the parameter object. - */ +/** Tool schemas nest parameters under `parameters`, `input_schema`, or `inputSchema` (producer-dependent), or bare + * when `type === 'object'`. */ function paramsOf(schema: unknown): ParamSchema | null { if (schema === null || typeof schema !== 'object') return null const s = schema as Record @@ -177,10 +136,6 @@ function ParamRow(props: { ) } -/** - * Section — the ONE detail chrome of the browser: every expanded element is a stack of these (labeled head + body), so the reader scans one - * repeating anatomy per content kind. - */ function Section(props: { label: string labelClass?: string @@ -195,7 +150,6 @@ function Section(props: { return (
- {/* The title recovers an ellipsized label: long mono call names truncate under width pressure. */} {props.label} {right ? {props.meta}{props.actions} : null} {props.count !== undefined ? {props.count} : null} @@ -206,7 +160,6 @@ function Section(props: { } function lineCountOf(text: string): number { - // Count logical lines for LF, CRLF, and lone CR output alike. return text.split(/\r\n|\r|\n/).length } @@ -240,11 +193,7 @@ function TextSection(props: { ) } -/** - * The search text of one tool's raw schema. The schema is log data this view - * does not own — a hostile value (cyclic, throwing getters) degrades to no - * matchable text instead of taking the row, or the browser, down. - */ +/** The raw schema is untrusted log data: a cyclic or throwing value degrades to no matchable text. */ function schemaTextOf(schema: unknown): string { try { return JSON.stringify(schema ?? '') @@ -253,11 +202,7 @@ function schemaTextOf(schema: unknown): string { } } -/** - * The shared row-filter toolbar of a category body: a text input plus an - * optional trailing control group (the tools' size/name sort). Stays mounted - * on an empty match so the filter can always be cleared from the UI. - */ +/** Shared row-filter toolbar; it stays mounted on an empty match so the filter can always be cleared. */ function RowToolbar(props: { value: string placeholder: string @@ -282,10 +227,6 @@ function RowToolbar(props: { -/** - * Full tool-row body: description, parsed parameter table (when the schema carries one), raw JSON behind a per-row toggle — the JSON open - * state is per-row so two expanded tools stay independent. - */ function ToolSchema(props: { description: string | undefined schema: unknown @@ -318,9 +259,7 @@ function ToolSchema(props: { } return out }, [params]) - // Pretty-printed only while the row's JSON is open: a tools section lists - // dozens of schemas, and eager stringification of every collapsed row - // dominated the section's render cost. + // Pretty-printed only while open: eagerly stringifying every collapsed row dominated the section's render cost. const schemaJson = useMemo( () => jsonOpen ? JSON.stringify(props.schema, null, 2) : '', [props.schema, jsonOpen], @@ -361,11 +300,8 @@ interface DetailLabels { callState: (err: boolean, exit: number | null) => ReactNode } -/** - * Both block vocabularies normalize here — raw durable blocks (`type`: text/reasoning/tool-call/tool-result/image) and snapshot assistant - * blocks (`kind`: text/reasoning/tool-call/image, argsRaw). Consecutive images group into one grid; every - * rich text block carries the Raw/MD switch + line count; nested tool-result blocks flatten into the same flow. - */ +/** Both block vocabularies normalize here — raw durable blocks (`type`) and snapshot assistant blocks (`kind`). + * Consecutive images group into one grid; nested tool-result blocks flatten into the same flow. */ function BlocksBody(props: { blocks: readonly unknown[] textLabel: string @@ -434,20 +370,10 @@ function BlocksBody(props: { return <>{out} } -/** - * The trailing status markers dsh shell tools append at the END of a result's text while `isError` stays false - * (the status is result data, per tool-bash's render: "non-zero exits are reported, not errored"): - * - one-shot bash/pwsh: `[exit code: N]` (non-zero only), `[killed by signal: X]` - * - persistent shells: `[shell killed by signal: X]`, `[shell exited: code N]` — riding LAST, after the - * `[exit code: N]` of the command whose failure killed the shell, so the command marker is re-checked - * on the preceding text - * - job_output: `[status: killed]` / `[status: failed, detail]` — the tool-jobs status line always terminates - * the read; a killed/failed background job settles with `isError` false, so only the line flags the loss - * End-anchored like dsh's own parseExitStatus, so marker text quoted inside the output (e.g. a cat'ed log) - * is not a failure. A clean shell exit (code 0 or code-less) or a live/completed job status is a notice, - * not a failure. - * The parsed exit code feeds the FAILED run-state pill. - */ +/** dsh shell tools append trailing status markers while `isError` stays false: `[exit code: N]` (non-zero only), + * `[killed by signal: X]`, `[shell killed by signal: X]`, `[shell exited: code N]` (riding last, after the command's + * own `[exit code: N]`), and job_output's `[status: killed|failed]`. Parsed end-anchored like dsh's own + * parseExitStatus, so marker text quoted inside the output is not a failure; a clean exit is a notice. */ function tailStatusOf(conv: ConversationNodeLike | undefined): { fail: boolean; exit: number | null } { if (conv === undefined || !Array.isArray(conv.content)) return { fail: false, exit: null } for (const b of conv.content) { @@ -470,11 +396,8 @@ function tailStatusOf(conv: ConversationNodeLike | undefined): { fail: boolean; return { fail: false, exit: null } } -/** - * A tool result's failure: the fold-stamped `err` or the snapshot's `isError` (infrastructure failures — dsh stamps - * those) OR a trailing status marker (see tailStatusOf). dsh settles a failing COMMAND as a completed call, so the - * marker is the only failure signal — mirroring the chat row's terminalFailed. A timeout stays a notice, as in the chat. - */ +/** The fold-stamped `err`/`isError` (infrastructure failures) or the trailing status marker; dsh settles a failing + * COMMAND as a completed call, so the marker is the only failure signal — a timeout stays a notice, as in the chat. */ function toolErrOf(node: SurfaceNode, conv: ConversationNodeLike | undefined): { err: boolean; exit: number | null } { const tail = tailStatusOf(conv) const err = node.err === true || conv?.isError === true || tail.fail @@ -528,8 +451,6 @@ function NodeContent(props: { }): ReactElement { const { node, conv, rich, img, labels } = props if (conv === undefined) { - // The join missed (node outside the loaded window): the 80-char preview - // still shows as a plain content section, with the fetch-state note below. if (node.text === undefined || node.text === '') { return
{props.hint}
} @@ -578,24 +499,10 @@ function byCatOf(asm: Assembled): Partial> { return m } -/** - * The producer identity a fold-stamped injection node carries - * (shared/types.ts SurfaceNode.name): '' when absent or drift-typed — the - * delivered payload is untrusted, so rows re-prove it before rendering. - */ function nodeNameOf(n: SurfaceNode): string { return typeof n.name === 'string' ? n.name : '' } -/** - * The content kinds one assistant message proves, as the assistant category's - * kind chips count and filter them: thinking (a reasoning block — only the - * conversation join can prove it), tool calls (join blocks, else the fold's - * text-less-reply `calls` stamp), and answer text (join blocks, else the - * node's `text`). Legacy `content` joins classify the same way the body - * renders them. The flags are independent, so a combined reply lights - * several chips. - */ function msgKindsOf(n: SurfaceNode, conv: ConversationNodeLike | undefined): { think: boolean; tool: boolean; answer: boolean } { let think = false let tool = false @@ -619,11 +526,7 @@ function msgKindsOf(n: SurfaceNode, conv: ConversationNodeLike | undefined): { t } } -/** - * The reasoning texts one assistant row's conversation join proves, concatenated for the row filter's scan — - * the fold stamps no reasoning on the node, so this is the only scan face the thinking blocks expose. Both block - * vocabularies classify here (the body renders the same blocks as the Reasoning sections); malformed blocks drop. - */ +/** The only scan face for thinking blocks — the fold stamps no reasoning on the node. */ function reasoningTextOf(conv: ConversationNodeLike | undefined): string { if (conv === undefined) return '' let out = '' @@ -640,15 +543,11 @@ function reasoningTextOf(conv: ConversationNodeLike | undefined): string { return out } -/** The assistant rows' kind chips, in display order. */ const ROW_KINDS = ['think', 'tool', 'answer'] as const type RowKind = (typeof ROW_KINDS)[number] -/** A one-shot category-open request (the stats card's figure cells). */ export interface CatFocus { - /** The category to open ('skill' for Skill Loads, 'assistant' for Answers). */ cat: string - /** The assistant category's picked kind chip ('answer' from the Answers figure). */ kind?: RowKind } @@ -663,19 +562,10 @@ function lastOfTurn(requests: RequestRecord[], turn: number): RequestRecord | nu return null } -/** - * The turn/step stamp marking where a surface item entered the context: the - * FIRST request logged after the item's seq is the first request that carried - * it (assemble's inclusion rule is `n.seq < R.seq`), so that request's - * turn/step names the introducing step — the same next-request attachment the - * events card uses. Items with no following retained request (the live tail, - * not yet sent) stamp null, as does an introducing request without turn/step - * numbers (older hosts) — the true introduction step is then unknowable, and - * a later request's numbers would only mislabel it. - */ +/** The turn/step marking where a surface item entered the context: the FIRST request logged after the item's seq is + * the first that carried it (assemble's inclusion rule is `n.seq < R.seq`); no following request stamps null. */ export function stepStampOf(requests: RequestRecord[]): (itemSeq: number) => { turn: number; step: number } | null { - // Sort a copy: the wire arrives in append order, but the derivation must - // not depend on it (restored rows can re-order the list). + // Sort a copy: the wire arrives in append order, but the derivation must not depend on it (restored rows can re-order). const sorted = requests.slice().sort((a, b) => a.seq - b.seq) return (itemSeq) => { for (const r of sorted) { @@ -687,10 +577,8 @@ export function stepStampOf(requests: RequestRecord[]): (itemSeq: number) => { t } } -/** - * DNA bands keep at least this share of the occupied region, so a tiny item (a 25-token user message in a 40k - * context) stays a hoverable/clickable filament instead of a sub-pixel sliver. Tooltips still report true shares. - */ +/** Bands keep at least this share of the occupied region, so a tiny item stays a hoverable/clickable filament + * instead of a sub-pixel sliver; tooltips still report true shares. */ const DNA_MIN_BAND = 0.35 export function makeContextBrowser( @@ -703,8 +591,6 @@ export function makeContextBrowser( const nodeText = makeNodeText(kit) const rich = makeRichText(kit) const ImageCard = makeImageCard(kit) - // All rich text sections share the same line-count label; hoist it once so tool - // descriptions, system text, and message bodies stay in sync. const lineLabel = (n: number): string => t(n === 1 ? 'block.line' : 'block.lines', { n }) return function ContextBrowser(props: ContextBrowserProps): ReactElement { @@ -713,21 +599,11 @@ export function makeContextBrowser( const [sel, setSel] = useState<'live' | number>('live') const [openCat, setOpenCat] = useState(null) const [openElem, setOpenElem] = useState(null) - // The open category's row-filter text plus the tools' row order. The - // filter is a lens on the OPEN category: opening a different one resets - // it, while step picks (setOpenCat(null) below) keep it so the same lens - // compares epochs. + // The filter is a lens on the OPEN category: a category switch resets it, a step pick keeps it so the same lens compares epochs. const [rowQuery, setRowQuery] = useState('') - // The assistant category's picked kind chip (null = none). It follows the - // text filter's lens rules: reset on a category switch, kept across step - // picks. const [rowKind, setRowKind] = useState(null) - // Mount-time default from the plugin settings card; in-toolbar toggling - // stays mount-local and never writes back. + // Mount-time default; in-toolbar toggling stays mount-local and never writes back. const [toolSort, setToolSort] = useState(() => settings.defaultToolSort()) - // DNA mode: the composition bar redraws as ONE band per context item in prompt order (dna.ts), hovered/clicked per - // item. Parent-linked when both props arrive — the Context tab's trend toggle moves this one too; a lone - // prop is ignored — otherwise mount-local (the /context modal). const [dnaLocal, setDnaLocal] = useState(false) const dnaLinked = props.dna !== undefined && props.onDnaChange !== undefined const dna = dnaLinked ? props.dna === true : dnaLocal @@ -740,21 +616,13 @@ export function makeContextBrowser( } } const [dnaKey, setDnaKey] = useState(null) - // δ baseline toggle: 'step' diffs against the immediately preceding record, 'turn' against the - // previous turn's last step. Mount default from the plugin settings card; in-toolbar toggling - // stays mount-local and never writes back. const [deltaBase, setDeltaBase] = useState(() => settings.defaultDeltaBase()) - // Every open-category change (toggle, step pick, pin, brief reveal) reports outward so the Context tab - // can focus the trend chart on the open category. const onOpenCat = props.onOpenCat const setCat = (c: string | null): void => { setOpenCat(c) if (onOpenCat !== undefined) onOpenCat(c) } - // Full message content: the conversation-window join first (zero cost), - // plus nodes fetched on demand for seqs outside the window (node arrays - // are stable references per snapshot; the map memoizes over them). const convNodes = props.convNodes const convBySeq = useMemo(() => { const m = new Map() @@ -762,14 +630,9 @@ export function makeContextBrowser( return m }, [convNodes]) - // The open element's surface-node seq ('sys'/'tool:*' keys never join). const openSeq = openElem !== null && openElem.startsWith('n') ? Number(openElem.slice(1)) : null - // Fetch-on-miss: one targeted history read per expanded row whose seq the - // join missed (fetchOnMiss.tsx; landed values cache by seq — history is - // immutable). `failed` arms the retry button; `absent` means the page - // came back without the seq — it is not in the durable log. const fetchContent = props.fetchContent const miss = useFetchOnMiss( openSeq !== null && !convBySeq.has(openSeq) ? openSeq : null, @@ -782,18 +645,13 @@ export function makeContextBrowser( for (const [seq, n] of miss.values) if (!m.has(seq)) m.set(seq, n) return m }, [convBySeq, miss.values]) - // Pin linkage: a pinned bar selects its step (same accordion reset as a manual pick); unpin returns to live — a manual pick here is - // overridden only when a NEW pin lands. + // Pin linkage: a pinned bar selects its step; unpin returns to live, and only a NEW pin overrides a manual pick. const pinSeq = props.pinSeq useEffect(() => { setSel(pinSeq === null || pinSeq === undefined ? 'live' : pinSeq) setCat(null) setOpenElem(null) }, [pinSeq, onOpenCat]) - // Reveal (step brief / trend-DNA pick): select the owning step, open the item's category + - // element (the pagination effect above already pulls older history for a missing join), clear - // the row lens so a stale filter cannot hide the revealed row, then arm a one-shot scroll - // consumed by the layout effect once the row renders. const rootRef = useRef(null) const focusScrollRef = useRef(false) const nodeFocus = props.nodeFocus @@ -807,9 +665,6 @@ export function makeContextBrowser( focusScrollRef.current = true if (props.onNodeFocusHandled !== undefined) props.onNodeFocusHandled() }, [nodeFocus, props.onNodeFocusHandled, onOpenCat]) - // One-shot category reveal (the stats card's figure cells): the LIVE - // surface, the named category open, its kind chip picked when the caller - // carries one, the row lens cleared so nothing hides the rows. const catFocus = props.catFocus useEffect(() => { if (catFocus === null || catFocus === undefined) return @@ -831,14 +686,9 @@ export function makeContextBrowser( const requests = data.requests const stepsOf = useMemo(() => turnStepsOf(requests), [requests]) const stampOf = useMemo(() => stepStampOf(requests), [requests]) - // The live tail — items no logged request carries yet — will ride the NEXT - // request. Its numbers follow the harness's step loop (agent.ts): a newest - // user message opens a new turn, a text-only final reply means the turn - // ended, and anything else (pending tool results, an in-flight injection) - // continues the same turn — the next step is lastStep + 1. An ESTIMATE - // (dotted underline, pending tooltip): dsh names that step at step/start - // before the request exists, and the confirmed stamp replaces the estimate - // the moment the request logs. + // The live tail — items no logged request carries yet — rides the NEXT request, following the harness's step loop + // (`core/agent-loop`'s agent.ts): a newest user message opens a new turn, a text-only final reply ends the turn, + // anything else continues it. An ESTIMATE (dotted underline) until the request logs and the confirmed stamp replaces it. const pendingStamp = useMemo(() => { let hasReq = false let lastSeq = -1 @@ -851,7 +701,6 @@ export function makeContextBrowser( lastTurn = typeof r.turn === 'number' ? r.turn : -1 lastStep = typeof r.step === 'number' ? r.step : -1 } - // The step numbers of a newest request the log never stamped cannot be derived. if (hasReq && (lastTurn < 0 || lastStep < 0)) return null let newestSeq = -1 let newest: SurfaceNode | null = null @@ -867,26 +716,19 @@ export function makeContextBrowser( ? requests.find(r => r.seq === props.previewSeq) ?? null : null const req = hoverReq ?? (sel === 'live' ? null : requests.find(r => r.seq === sel) ?? null) - // The actual-prompt figure follows the shown step; the live surface pairs its next-request - // estimate with the freshest actual the log holds (highest seq — wire order is not trusted). + // The live surface pairs its next-request estimate with the freshest actual the log holds (highest seq — wire order is not trusted). const actual = req ?? requests.reduce((a, r) => (a === null || r.seq > a.seq ? r : a), null) - // A pinned step trimmed out of retention falls back to live. const seq = req !== null ? req.seq : null - // The browser joins the shared composition hover ONLY while it shows the LIVE step — a pinned/previewed step has a different - // composition, so its hover must not light the overview (and vice versa); the mirror filter drops the overview's 'free' key, which has - // no segment in this bar. + // The browser joins the shared composition hover ONLY while it shows the LIVE step — a pinned/previewed step has a + // different composition, so its hover must not light the overview (and vice versa). const linked = req === null && props.onHoverKey !== undefined const linkKey = linked && props.hoverKey !== null && props.hoverKey !== 'free' ? props.hoverKey : null const view = assemble(data, headers, seq) - // Header/tool epoch CONTENT (tool descriptions/schemas) and the system - // prompt TEXT: the projections carry metadata only, so the selected step's - // sources are fetched on demand — one seq-anchored history read per open - // section. The system prompt rides the timeline's own `systems` nodes (a - // `system/message` event), the tools the header epoch; both map through - // the same fetcher. Content caches per seq — history is immutable. + // The projections carry metadata only, so the selected step's content is fetched on demand (one history read per + // open section); the system prompt rides the timeline's own `system/message` nodes, the tools the header epoch. const fetchHeader = props.fetchHeader const headerSeq = view.header !== null ? view.header.seq : null const systemSeq = view.system !== null ? view.system.seq : null @@ -897,15 +739,13 @@ export function makeContextBrowser( 'dsh-context: header content fetch failed', ) const headerContent = epoch.values - // The note a not-yet-loaded epoch section shows, per fetch state (fetchOnMiss.tsx). const headerNote = fetchMissNote(t, fetchHeader, epoch.state, epoch.retry, 'browser.headerMetaOnly') const breakdown = req !== null ? req : data.current const parts = partsOf(breakdown) const total = breakdown.total - // The open category stays lit in the composition bar (category mode: its segment; DNA mode: its bands' group) even - // without pointer hover — a pointer hover overrides the pin, the pin resumes on leave. Dropped when the shown - // step's composition holds nothing for the category, so the bar never reads all-dimmed with nothing lit. + // The open category stays lit without hover (a pointer hover overrides the pin); dropped when the shown step's + // composition holds nothing for it, so the bar never reads all-dimmed. const pinKey = openCat !== null && (breakdown[openCat as Category | 'system' | 'tools'] || 0) > 0 ? openCat : null const pick = (v: string) => { setSel(v === 'live' ? 'live' : Number(v)) @@ -913,12 +753,8 @@ export function makeContextBrowser( setOpenElem(null) } - // δ baseline per the picked mode. Picked steps: 'step' = the immediately preceding record, 'turn' = - // the previous turn's last step. Live: 'step' = the last served request (the next request's direct - // predecessor); 'turn' = the previous turn's last step — this turn's growth so far. A picked step or - // a live surface with no predecessor (the log's first step, a first turn) falls back to a ZERO - // baseline: the whole surface is change, so the pills show the full makeup. A request-less live - // surface has nothing to compare against at all: no baseline, no pills. + // δ baseline: 'step' diffs the immediately preceding record, 'turn' the previous turn's last step — live: the last + // served request, or this turn's growth so far. A surface with no predecessor falls back to a ZERO baseline. const prevRecordOf = (r: RequestRecord): RequestRecord | null => { const i = requests.findIndex(x => x.seq === r.seq) return i > 0 ? requests[i - 1] : null @@ -934,19 +770,14 @@ export function makeContextBrowser( const byCat = byCatOf(view) - // Per-tool call-hit tally over the shown step's assembled surface: one - // tool-result node (the fold stamps `tool` on a paired result) is one - // completed call — the same accounting as the stats' toolCalls. A - // `skill`-tool load reclassifies its node into the `skill` bucket but - // keeps the tool stamp, so those calls still count here. + // One tool-result node is one completed call — the same accounting as the stats' toolCalls; a reclassified + // `skill` load keeps its tool stamp and still counts here. const toolHits = new Map() for (const n of view.nodes) { if ((n.cat === 'tool' || n.cat === 'skill') && n.tool !== undefined) toolHits.set(n.tool, (toolHits.get(n.tool) ?? 0) + 1) } const toolHitsOf = (tool: HeaderTool): number => toolHits.get(tool.name) ?? 0 - // DNA mode: per-item bands in prompt order (dna.ts). The band label names the item the way its accordion row would - // (the shared dnaBaseLabel — skill name, tool name, injection form, else the category label) with its time appended. const dnaLabel = (it: DnaItem): string => { const base = dnaBaseLabel(it, t, catLabel) return it.time !== undefined ? base + ' · ' + fmtTime(it.time) : base @@ -960,11 +791,9 @@ export function makeContextBrowser( label: dnaLabel(it), group: it.cat, })) ?? null - // A band hover is honored only while its key names a RENDERED band: a push can drop the band under a resting - // pointer (compaction, tail slide) without a mouseleave, and a dead key exact-matches nothing — the bar would - // sit all-dimmed with nothing lit (the same invariant the pin's `breakdown` gate keeps). + // A band hover is honored only while its key names a RENDERED band: a push can drop the band under a resting pointer + // without a mouseleave, and a dead key would leave the bar all-dimmed (the same invariant the pin's gate keeps). const liveDnaKey = dnaKey !== null && dnaByKey.has(dnaKey) ? dnaKey : null - // A band click opens its category + element row below (the same reveal the step brief uses) and scrolls it into view. const pickDna = (key: string): void => { const it = dnaByKey.get(key) /* v8 ignore next 1 -- the bar only reports keys of the parts it was handed; defensive. */ @@ -978,9 +807,7 @@ export function makeContextBrowser( const toolCount = (c: string): number => countOf(view, byCat, c) - // A category holding exactly one item opens that row with the category, so - // one click lands on the content directly (the lone prompt / tool schema / - // surface node). + // A category holding exactly one item opens that row with the category, so one click lands on the content directly. const singleKeyOf = (c: string): string | null => { if (c === 'system') return view.system !== null ? 'sys' : null if (c === 'tools') { @@ -1004,19 +831,13 @@ export function makeContextBrowser( return } setCat(c) - // A different category opens unfiltered — the lens belongs to the open one. setRowQuery('') setRowKind(null) setOpenElem(singleKeyOf(c)) } const toggleElem = (key: string) => { setOpenElem(openElem === key ? null : key) } - /** - * Expandable element row; `err` rows carry the red run-state dot right after the chevron (the chat's failed-tool marker) so a failed - * result scans while collapsed. `stamp` (the turn/step of the request that first carried the item, or the pending estimate for the - * live tail) merges with the time into ONE muted meta run ('T1 S41 · 22:58:10') so the meta tail reads as a single unit; a pending - * estimate wears the dotted underline and its own tooltip. - */ + /** Expandable element row; `err` carries the red run-state dot right after the chevron so a failed result scans while collapsed. */ const elemRow = ( key: string, tag: ReactNode | null, preview: string, tokens: number, time: number | undefined, body: ReactNode, @@ -1051,9 +872,7 @@ export function makeContextBrowser( ) } - // A row's stamp: its confirmed introducing step, else — LIVE only — the - // pending estimate for tail items no request carries yet (past-step views - // reconstruct only what their request already carried, so they never speculate). + // A row's stamp: its confirmed introducing step, else — LIVE only — the pending estimate (past-step views never speculate). const stampFor = (itemSeq: number): { turn: number; step: number; pending: boolean } | null => { const confirmed = stampOf(itemSeq) if (confirmed !== null) return { ...confirmed, pending: false } @@ -1061,8 +880,6 @@ export function makeContextBrowser( return { ...pendingStamp, pending: true } } - // The row's tag slot: plain tags render as one capsule; tool-named tags render one capsule per distinct call - // name, in first-appearance order, repeats folded into a ×N multiplier ('bash ×3'). const rowTagNode = (tag: string | null, names: readonly string[] | null): ReactNode => { if (tag === null) return null if (names === null) return {tag} @@ -1075,17 +892,12 @@ export function makeContextBrowser( const catBody = (c: string): ReactNode => { if (c === 'system') { - // No prompt in force at this step. The category only opens without one - // when there is no header epoch at all (the openable guard above), so - // the note names the missing PROJECTION — no headers service versus - // headers that carried no epoch yet. + // No prompt in force: the note must name the missing PROJECTION — no headers service versus no epoch yet. const sys = view.system if (sys === null) { return
{t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}
} const content = headerContent.get(sys.seq) - // Metadata-only until the prompt's event resolves (fetched when the - // section opens; the note names the state). if (content === undefined) return
{headerNote}
if (content.system === undefined) return
{t('browser.noSystem')}
return elemRow('sys', null, content.system.replace(/\s+/g, ' ').trim().slice(0, 80), breakdown.system, undefined, @@ -1101,15 +913,9 @@ export function makeContextBrowser( show: t('tool.jsonToggle'), hide: t('tool.jsonHide'), } - // The epoch's fetched content, joined onto the metadata rows by tool - // name (the open row's body renders description/params/JSON from it). const content = headerContent.get(view.header.seq) - // Every schema row of one epoch shares the epoch's introducing step. const epochStamp = stampFor(view.header.seq) const contentByName = new Map(content?.tools.map(t => [t.name, t]) ?? []) - // The text filter scans everything the rows can say: the name, the - // producer description, the plugin chip, and the raw parameter JSON — - // the latter two only once the epoch's content is loaded. const q = rowQuery.trim().toLowerCase() const shown = view.header.tools .filter((tool: HeaderTool) => { @@ -1121,18 +927,12 @@ export function makeContextBrowser( return (row.description ?? '').toLowerCase().includes(q) || schemaTextOf(row.schema).toLowerCase().includes(q) }) - // Size order mirrors the overview's Top chips — the producer's header - // order is not meaningful; count order ranks by the call-hit tally of - // the shown step's surface (ties break by name); name order gives - // lookup instead of ranking (names are unique keys, so a two-way - // comparison orders them fully). + // Size order mirrors the overview's Top chips; the producer's header order is not meaningful. .sort((a, b) => toolSort === 'count' ? (toolHitsOf(b) - toolHitsOf(a) || (a.name < b.name ? -1 : 1)) : toolSort === 'size' ? b.tokens - a.tokens : (a.name < b.name ? -1 : 1)) - // The toolbar stays mounted on an empty match, or the filter could - // never be cleared from the UI. const toolctl = ( {(['size', 'count', 'name'] as const).map(k => ( @@ -1155,8 +955,6 @@ export function makeContextBrowser(
) } - // The open row's body: the fetched content's description, parameter - // table, and raw JSON — or the epoch's fetch-state note. const toolBody = (tool: HeaderTool): ReactNode => { if (content === undefined) return
{headerNote}
const row = contentByName.get(tool.name) @@ -1168,13 +966,8 @@ export function makeContextBrowser(
{toolctl} {shown.map((tool: HeaderTool) => { - // The registering plugin (best-effort host attribution — see - // toolSources.ts) trails the tool name as a standalone chip, so it - // needs no extra frame around it. A tool whose provider predates - // the attribution hook arrives with the UNKNOWN_TOOL_SOURCE - // sentinel and renders a localized "unknown plugin" tag whose - // tooltip explains why no provider is shown. The hit tally always - // trails — a zero names a definition the shown step never called. + // Best-effort host attribution (toolSources.ts); a provider predating the attribution hook arrives as the + // UNKNOWN_TOOL_SOURCE sentinel and renders the localized "unknown plugin" tag. const trailing = ( <> {tool.plugin !== undefined @@ -1192,25 +985,15 @@ export function makeContextBrowser(
) } - // List surface nodes newest first (the live surface's reading order). /* v8 ignore next 1 -- the body renders only when the category is open, which requires count > 0 ⟺ byCat[c] exists; defensive. */ const nodes = (byCat[c as Category] ?? []).slice().reverse() - // Derive each row's display facts first so the text filter scans exactly - // what the rows show (tag + preview, plus the assistant join's reasoning) - // at derivation time, not per keystroke; the survivors render unchanged. - // Identity-labeled injection rows keep their folded content out of the - // preview, so the filter scans `text` too. + // Rows derive their display facts first, so the text filter scans exactly what the rows show — at derivation time, not per keystroke. const rows = nodes.map((n) => { const conv = bySeq.get(n.seq) - // A `skill`-tool load reclassifies into the `skill` bucket (issue #66) - // but stays a tool result — its failure marking follows the same rule. + // A `skill`-tool load reclassifies into the `skill` bucket but stays a tool result — same failure rule. const rowErr = (n.cat === 'tool' || n.cat === 'skill') && toolErrOf(n, conv).err - // Tag carries the compact fact (tool name, injection form) — one shared subtle chip style; the preview line carries the text — each - // fact shown once. let tag: string | null = null - // The tool names the tag carries, when it names tools (tool rows, a call breadcrumb) — they render as one - // capsule per distinct name; every other tag stays a single capsule. let tagNames: string[] | null = null let preview = nodeText(n) const id = nodeNameOf(n) @@ -1219,19 +1002,13 @@ export function makeContextBrowser( tagNames = n.tool !== undefined ? [n.tool] : null preview = callSummaryOf(conv) ?? t('node.toolResult') } else if (n.cat === 'skill') { - // Skill content (issue #66): a load/invocation names itself, and a - // text-less row (an unjoined load) falls back to the call summary; - // the catalog digest carries no name — its form label tags it and - // the stamped source identity (e.g. `skill-catalog`) previews. tag = n.skill !== undefined ? t('node.skillTag', { name: n.skill }) : t('form.' + (n.form || 'context')) preview = (n.skill === undefined && id !== '' ? id : null) ?? (n.text !== undefined && n.text !== '' ? n.text : null) ?? callSummaryOf(conv) ?? preview } else if (n.cat === 'assistant') { - // The fold stamps `calls` only on TEXT-LESS replies; a reply carrying both text and calls recovers its breadcrumb through the - // conversation join — the same recovery the step brief applies. The call names join into the tag's scan - // text (the filter scans what the rows say) and render as one capsule per distinct name; the preview - // carries the reply text, else the first call's own summary for a text-less turn. + // The fold stamps `calls` only on TEXT-LESS replies; a reply carrying both text and calls recovers its + // breadcrumb through the join. const names = Array.isArray(n.calls) && n.calls.length > 0 ? n.calls : callNamesOf(conv) if (names.length > 0) { tag = names.join(' › ') @@ -1240,12 +1017,9 @@ export function makeContextBrowser( ?? blockSummaryOf(conv) ?? t('node.empty') } else if (n.text === undefined || n.text === '') { - // A text-less turn can still preview a self-summarizing call from the join even when the node carries no call list. preview = blockSummaryOf(conv) ?? preview } } else if (n.cat === 'user') { - // User messages with image uploads gain an Image chip on the collapsed row (detected via the conversation join, like the expanded - // body); expanded, the grid shows anyway. const imgCount = conv !== undefined && Array.isArray(conv.content) ? conv.content.filter(b => imageRefOf(b) !== null).length : 0 @@ -1253,9 +1027,6 @@ export function makeContextBrowser( tag = t('attach.image') + (imgCount > 1 ? ' ×' + String(imgCount) : '') } } else { - // The last category ('inject'): the form label tags the row; the - // source identity (when stamped) is more scannable than the raw - // content, which stays one expand away. tag = t('form.' + (n.form || 'context')) if (id !== '') { preview = id @@ -1270,16 +1041,12 @@ export function makeContextBrowser( tag, tagNames, preview, - // The join's reasoning texts feed the scan (assistant rows only — no other category carries them). reasoning: n.cat === 'assistant' ? reasoningTextOf(conv) : '', kinds: msgKindsOf(n, conv), } }) const q = rowQuery.trim().toLowerCase() - // The assistant category's kind chips: per-kind message counts over ALL - // of the shown step's rows (the chips report the step's composition, not - // the text-lens survivors). A picked kind filters to the rows carrying - // it, intersected with the text query; picking it again clears. + // Kind chips count over ALL of the shown step's rows — the step's composition, not the text-lens survivors. const kindCounts = c === 'assistant' ? ROW_KINDS.map(k => ({ k, n: rows.reduce((acc, r) => acc + (r.kinds[k] ? 1 : 0), 0) })) : null @@ -1291,8 +1058,6 @@ export function makeContextBrowser( || (r.tag ?? '').toLowerCase().includes(q) || r.preview.toLowerCase().includes(q) || (typeof r.n.text === 'string' && r.n.text.toLowerCase().includes(q)) || r.reasoning.toLowerCase().includes(q))) - // The toolbar stays mounted on an empty match, or the filter could - // never be cleared from the UI. const rowctl = kindCounts === null ? : ( @@ -1322,7 +1087,6 @@ export function makeContextBrowser( conv={conv} rich={rich} img={{ Card: ImageCard, load: props.loadImage }} - // Localized section titles handed in by the parent so the body stays a pure function of props. labels={{ thinking: t('block.thinking'), answer: t('block.answer'), @@ -1341,8 +1105,7 @@ export function makeContextBrowser( ), }} - // Only the open row's body renders, so the miss note is exactly THIS join's fetch state; a joined-but-empty row keeps the - // static hint. + // Only the open row's body renders, so the miss note is exactly THIS join's fetch state. hint={conv === undefined ? missNote : t('browser.noContent')} />, rowErr, null, stampFor(n.seq)))} @@ -1408,12 +1171,9 @@ export function makeContextBrowser( {catLabel(c.key)} - {/* Count + Δ pill sit as one attached group (tight inner gap), - the group absorbs the row's free space so tokens/percent - stay right-aligned. */} {t('browser.items', { n: count })} {countDelta !== null && countDelta !== 0 ? ( diff --git a/src/client/components/contextJump.tsx b/src/client/components/contextJump.tsx index 1e7b3cf..f214d96 100644 --- a/src/client/components/contextJump.tsx +++ b/src/client/components/contextJump.tsx @@ -1,17 +1,7 @@ -/** - * The assistant-message action that opens the Context view at this reply's - * turn. Registered on the harness `conversation.chat.assistant-actions` seat - * (the icon row beside copy/branch), it receives the finalized reply's durable - * message id, resolves the matching assistant node's seq off the `useChat` - * node seat AT RENDER TIME (the seat is a real React hook — render-only; a - * click-time read throws the dispatcher guard and the jump would land - * unpinned), records it in the viewFocus relay on click, and opens the - * Context tab on the right Sidebar — where the jump pins the reply's TURN - * (see contextView's leg 2). A harness or placement without the sidebar tab - * falls back to the conversation tab; an unresolvable seq still opens the - * view, just without a pin; a message id that is not a plain string renders - * nothing at all. - */ +/** The assistant-message action that opens the Context view at this reply's turn, registered on the harness + * `conversation.chat.assistant-actions` seat. The matching assistant node's seq is resolved off the `useChat` node seat AT + * RENDER TIME: that seat is a real React hook, so a click-time read throws the dispatcher guard and the jump would land + * unpinned. An unresolvable seq still opens the view, just without a pin; a non-string message id renders nothing. */ import { type ReactElement } from 'react' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' @@ -20,17 +10,13 @@ import { conversationNodesOf } from '../services' import { activateContextTab, openContextSidebar, requestContextFocus } from '../viewFocus' import type { ViewKit } from '../viewkit' -/** The assistant-action seat's currency, as far as this button consumes it. */ export interface ContextJumpProps { messageId?: unknown sessionId?: unknown useChat?: UseChatLike } -/** - * The reply's request seq by its durable message id, or null when no served node proves the pair. Join/log nodes are untrusted input: each - * element is isolated, so one hostile object that throws on property access is skipped — the jump keeps its pin, never its click. - */ +/** Nodes are untrusted input: each element is isolated, so one hostile object that throws on property access is skipped. */ function seqOfMessageId(nodes: readonly ConversationNodeLike[] | undefined, messageId: string): number | null { for (const node of nodes ?? []) { try { @@ -43,7 +29,7 @@ function seqOfMessageId(nodes: readonly ConversationNodeLike[] | undefined, mess return null } -/** The jump glyph: the plugin's mini stacked composition bars, same 16px outline family as the shipped row icons. */ +/** Same 16px outline family as the shipped row icons. */ function JumpIcon(): ReactElement { return (
{source.detailState === 'failed' @@ -434,14 +335,11 @@ export function makeContextView( const markerOf = (req: RequestRecord): ContextEventRecord | undefined => { const i = displayRequests.indexOf(req) - /* v8 ignore next 1 -- the only caller passes displayRequests[activeIdx], - an element of the very array indexOf scans. */ + /** v8 ignore next 1 -- the only caller passes displayRequests[activeIdx], an element of the very array indexOf scans. */ return i >= 0 ? markers[i] : undefined } - // Provider-anchored CURRENT occupancy (contextPressure.projectedTokens): the headline, because the fixed 4-chars/token heuristic - // undercounts CJK by ~10–15% — proportions stay heuristic but are anchored to the real billed total. Shared with the /context popup - // (headline.ts); composition rides the official `contextBreakdown` rows. + // Provider-anchored occupancy: the 4-chars/token heuristic undercounts CJK by ~10–15%, so the headline anchors to the billed total. const head = headlineOf(data, pressure, breakdown) let fileScope = t('files.scopeLatest') if (activeReq !== null && filesBefore !== null) { @@ -450,31 +348,23 @@ export function makeContextView( : t('detail.step', { t: activeReq.turn ?? 0, s: activeReq.step ?? 0, n: stepsOf(activeReq.turn) }) } - // Turn highlight is hover-only: the turn strip hover wins, then the hovered bar's turn — no fallback, so a pinned or default selection - // never keeps a turn glowing. + // Turn highlight is hover-only (strip hover wins, then the hovered bar's turn): no fallback, so a pinned or + // default selection never glows. let activeTurn: number | null = hoverTurn if (activeTurn === null && hoveredSeq !== null) { for (const req of displayRequests) if (req.seq === hoveredSeq) { activeTurn = req.turn ?? null; break } } - // The trend card mirrors the SAME shared category hover the browser's live link uses: the overview's 'free' key drops - // (no segment in the chart or the detail bar), and every bar + the detail bar light that category's segment. + // Mirrors the browser's shared category hover; the overview's 'free' key drops — it has no segment in the chart or the detail bar. const trendHoverCat = hoverCat !== null && hoverCat !== 'free' ? hoverCat : null - // The cost cell prices in the active locale (zh → CNY, else USD), read at render time — the locale subscription above already - // re-renders on a switch; older hosts without getLocale fall back to USD. const localeSvc = ctx.get('locale') const activeLocale = localeSvc !== undefined && typeof localeSvc.getLocale === 'function' ? localeSvc.getLocale().active : 'en' const subtitle = (data.model ?? '') + (data.provider ? ' · ' + data.provider : '') - // The host's baseline gate (host/fallback.ts): the cards below render - // the fallback's zeroed data while the modal names both versions and - // urges the upgrade. Absent record = a supported harness, no gate. const gate = unsupportedOf(data.unsupported) - // The three main-row cards, built once and laid out by the shared - // arrangement below (every host uses the same row). const compositionCard = (
{t('trend.title')} - {/* The three display modifiers ride the title text's right as ONE multi-toggle segmented group - (the browser card's DNA toggle idiom), each segment lighting independently: DNA draws every - bar as that request's per-item context fingerprint (with Delta, per-item changes against the - previous bar); Adaptive rescales the axis to the visible bars, following the scroll; Duration - overlays each bar's step active time as a curve read off the right-hand quartile axis. */}
{displayRequests.length === 0 - // Split generation, first detail read pending or failed: say - // so instead of claiming the session has no history. + // Split generation, first detail read pending or failed: say so instead of claiming no history. ? (detailReady ?
{t('trend.empty')}
: ) : (
= 0 ? (activeIdx > 0 ? displayRequests[activeIdx - 1] : null) : undefined} - /* v8 ignore next 1 -- RequestDetail renders only when - displayRequests.length > 0, which forces activeReq - non-null via the activeIdx fallback above. */ + /** v8 ignore next 1 -- RequestDetail renders only when displayRequests.length > 0, which forces activeReq + * non-null via the activeIdx fallback above. */ marker={activeReq !== null ? markerOf(activeReq) : undefined} brief={brief} convOf={convOf} @@ -615,11 +497,6 @@ export function makeContextView( return (
- {/* The head band splits into two rows: the session's shape beside the - plugin card at a 3:1 split, then the two donut cards together. The - sidebar panel drops the first row (context stats / plugin info pay - off only on the full-width tab); the rows' own flex-wrap stacks the - pair in a narrow pane at the cards' min-width floors. */} {inSidebar ? null : (
- {/* One arrangement for every host: composition over trend in the left - column, the browser beside them and stretched to the pair's height. - All columns share the 360px floor (`min-w-[min(360px,100%)]`): the - rows wrap at it, and a sub-360px pane narrows the column instead - of overflowing. */}
{compositionCard}{trendCard}
- {/* `lc-col-browser` stretches the browser card to the left column's - height; the /context modal, which draws its own stack, must stay - content-sized. */} + {/* Stretches the browser card to the left column's height; the /context modal draws its own stack and stays content-sized. */}
{browserCard}
diff --git a/src/client/components/dateRange.tsx b/src/client/components/dateRange.tsx index 77d4be6..5ad7e5f 100644 --- a/src/client/components/dateRange.tsx +++ b/src/client/components/dateRange.tsx @@ -1,34 +1,10 @@ /** - * The Insights page's date-range picker: one calendar panel that takes BOTH - * ends of a custom range, parked beside the range group's own presets. + * The Insights page's date-range picker: one pill carries both ends of the LIVE window, so the picker and the preset + * group never disagree. Two clicks make the range in any order — the pair is SORTED, so every reader downstream (the + * scope window, the ledger-day predicate, the chip) only ever sees `from <= to`. * - * The trigger IS the scope: one pill carrying the start and the end side by - * side, fed the LIVE window — a picked range when one owns the scope, the - * preset's own window read as days otherwise ("last 30 days" shows the thirty - * days it covers). So the picker and the presets never disagree, and a scope - * the reader has to click into to see is a scope they cannot trust. Only a - * picked range takes the chip's brand voice and grows the way back. - * - * Two clicks make the range, in any order. The second click is paired against - * the first and SORTED, so a backwards drag (end before start) simply swaps - * the two ends rather than committing a reversed window — every reader - * downstream (the scope window, the ledger-day predicate, the chip that names - * the range) only ever sees `from <= to`. While the second end is pending the - * band previews under the cursor; the pick commits on that second click and - * hands the scope straight back to the page, so the dashboard answers - * immediately. - * - * The panel itself stays a plain calendar — no second copy of the two ends: - * the chip above it is already showing them, live, while the pick runs. - * - * The panel is portaled to and fixed-positioned off its trigger's rect, - * the same containment escape the page's hover tips and skill tips take: the - * page is the `lc-ov` query container and its scroller clips an in-tree - * popover, so an unportaled panel would be cropped and offset. It measures - * itself after commit (hidden until then, no flash at an unmeasured spot), - * clamps inside the viewport, and flips above the trigger when it would fall - * off the bottom. Any scroll, resize, outside press, or Escape closes it — - * retract rather than float detached from a moved anchor. + * The panel portals to and fixes off its trigger's rect: the page is the `lc-ov` query container, whose scroller + * would clip an in-tree popover. Any scroll, resize, outside press, or Escape closes it rather than float a detached anchor. */ import { useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react' @@ -40,19 +16,12 @@ import { useEscapeClose } from './escapeClose' import { IconChevronDown } from '../primitives' export interface DateRangeProps { - /** - * The scope's day span — the picked range while one owns the scope, else the - * preset's own window read as days (its open end is today). Null when the - * scope is unbounded ("all"), where there is no span to print. - */ + /** The scope's day span: the picked range, else the preset's own window read as days (its open end is today); + * null when the scope is unbounded. */ value: DayRange | null - /** Whether `value` is a picked range rather than a preset's window — the chip's voice and the way back. */ picked: boolean - /** Range relay: null hands the scope back to the preset. */ onChange: (range: DayRange | null) => void - /** The local today key (injected so specs pin the calendar) — the last selectable day. */ today: string - /** The active locale tag, for the panel's month header. */ locale: string } @@ -60,10 +29,8 @@ export interface DateRangeProps { const GAP = 6 const MARGIN = 8 -/** The grid's cell id, the target its `aria-activedescendant` cursor names. */ const CELL_ID = 'lc-dp-day' -/** The grid's arrow-key day steps: left/right one day, up/down one week. */ function dayStepOf(key: string): number | null { if (key === 'ArrowLeft') return -1 if (key === 'ArrowRight') return 1 @@ -72,7 +39,6 @@ function dayStepOf(key: string): number | null { return null } -/** The month header's localized name, or the bare key when it does not convert. */ export function monthLabelOf(month: string, locale: string): string { if (!MONTH_KEY_RE.test(month)) return month const date = new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1, 1) @@ -83,10 +49,8 @@ export function makeDateRange(kit: ViewKit): (props: DateRangeProps) => ReactEle const { t } = kit return function DateRange(props: DateRangeProps): ReactElement { const [open, setOpen] = useState(false) - // The month the panel shows, and the grid's keyboard cursor inside it. const [month, setMonth] = useState('') const [cursor, setCursor] = useState('') - // The first picked end, while the second is still pending. const [anchor, setAnchor] = useState(null) const [hover, setHover] = useState(null) const [spot, setSpot] = useState<{ left: number; top: number } | null>(null) @@ -96,9 +60,7 @@ export function makeDateRange(kit: ViewKit): (props: DateRangeProps) => ReactEle const close = (): void => { setOpen(false) } useEscapeClose(open, close) - // Any press outside the trigger and the panel closes it. The scroll and - // resize rules match the page's hover tips: the trigger moves with the - // pane, so the panel retracts instead of floating off its anchor. + // Outside press closes; scroll and resize retract the panel instead of leaving it off its moved anchor. useEffect(() => { if (!open) return undefined const onDown = (ev: MouseEvent): void => { @@ -117,11 +79,9 @@ export function makeDateRange(kit: ViewKit): (props: DateRangeProps) => ReactEle } }, [open]) - // Seat the panel after it commits, then keep it seated: the comparison - // makes a re-measure that lands on the same spot a no-op, so the - // every-render measurement cannot loop. Object.is, not ===: an - // unmeasurable anchor reads NaN, and NaN === NaN is false — which would - // make every re-measure a fresh object and spin the tree. + // Re-seat the panel after every commit; the comparison makes a re-measure landing on the same spot a no-op, so the + // every-render measurement cannot loop. Object.is, not ===: an unmeasurable anchor reads NaN, and NaN === NaN is + // false — every re-measure would then mint a fresh object and spin the tree. useLayoutEffect(() => { const anchorEl = rootRef.current const panelEl = panelRef.current @@ -135,14 +95,10 @@ export function makeDateRange(kit: ViewKit): (props: DateRangeProps) => ReactEle setSpot(current => current !== null && Object.is(current.left, left) && Object.is(current.top, top) ? current : { left, top }) }) - // The committed range, or the pending pair under the cursor (the anchor - // alone until the hover gives the second end a preview). const draft = anchor === null ? props.value : orderedDayRange(anchor, hover ?? anchor) const grid = open ? monthGridOf(month) : null const show = (): void => { - // Always this month: the calendar is a picking tool, and today is where - // a pick starts. The live scope's band is highlighted wherever it falls. const home = props.today.slice(0, 7) setMonth(home) setCursor(`${home}-01`) @@ -171,9 +127,6 @@ export function makeDateRange(kit: ViewKit): (props: DateRangeProps) => ReactEle setCursor(`${next}-01`) } - // One tab stop for the whole grid: the arrows walk the cursor a day or a - // week (staying inside the shown month and out of the future), Enter or - // Space picks it. const onKeyDown = (ev: React.KeyboardEvent): void => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault() @@ -188,9 +141,7 @@ export function makeDateRange(kit: ViewKit): (props: DateRangeProps) => ReactEle } const shown = spot !== null && open ? spot : null - // The two ends the head reads: the live scope's span while it has one, the - // placeholders otherwise. A first click with no hover yet has chosen only - // the start, so the end keeps its placeholder rather than echoing it. + // A first click with no hover yet has chosen only the start, so the end keeps its placeholder rather than echoing it. const from = draft?.from const to = anchor !== null && hover === null ? undefined : draft?.to return ( @@ -247,19 +198,15 @@ export function makeDateRange(kit: ViewKit): (props: DateRangeProps) => ReactEle const future = key > props.today const edge = draft !== null && (key === draft.from || key === draft.to) const inRange = draft !== null && key >= draft.from && key <= draft.to - // Only the cursor carries the id `aria-activedescendant` names. const className = 'lc-dp-cell' + (inRange ? ' lc-dp-in' : '') + (edge ? ' lc-dp-edge' : '') + (key === cursor ? ' lc-dp-cursor' : '') + (key === props.today ? ' lc-dp-today' : '') + (future ? ' lc-dp-future' : '') - // The day of the month, unpadded: the header already names - // the month, so "3" is the whole cell. const day = String(Number(key.slice(8, 10))) if (future) { - // The cursor never lands here (the keyboard stops at - // today), so this cell carries no activedescendant id. + // The keyboard cursor never lands here, so this cell carries no activedescendant id. return ( {day} diff --git a/src/client/components/detailNote.tsx b/src/client/components/detailNote.tsx index 191cd4d..00f8974 100644 --- a/src/client/components/detailNote.tsx +++ b/src/client/components/detailNote.tsx @@ -1,22 +1,11 @@ -/** - * The detail collections' pending/failed note — the split generation's - * visible loading story (timelineSource.ts): while the first detail read is - * in flight the detail-driven cards show this strip instead of a misleading - * empty state, and a settled-without-data read arms the retry button (never - * a spinner that never resolves, never a silent empty chart). Callers guard - * the failed state on a retry callback being wired (an unwired failure keeps - * the plain text). - */ +/** Pending/failed note for the detail collections: a settled-without-data read must not render as an empty state. */ import { type ReactElement } from 'react' import type { ViewKit } from '../viewkit' export function makeDetailNote(kit: ViewKit): (props: { - /** The pending kind: the first read in flight, or settled without data. */ state: 'loading' | 'failed' - /** The retry affordance for the failed read; absent = the note renders inert text. */ onRetry?: () => void - /** The container's style class: cards use the shared empty well, the browser its inline note strip. */ className?: string }) => ReactElement { const { t } = kit diff --git a/src/client/components/donut.tsx b/src/client/components/donut.tsx index 64c6bc1..cd26bc5 100644 --- a/src/client/components/donut.tsx +++ b/src/client/components/donut.tsx @@ -1,5 +1,4 @@ -/** - * The stats cards' ring chart: proportional SVG segments (stroke-dasharray +/** The stats cards' ring chart: proportional SVG segments (stroke-dasharray * over a 100-unit circumference, the same trick as the agent graph's rings) * around an HTML center label. Neighbouring segments part on a hairline of the * card's own background (each arc gives up a half gap at both ends); segments @@ -7,16 +6,12 @@ * the card never draws a misleading "100% of nothing" pie. The center type * scales with the ring's size, so the label always fits inside the hole the * thin stroke leaves. On mount the slices sweep in one after another, growing - * their dasharcs clockwise from 12 o'clock (stats.css, staggered by the - * per-slice `--lc-i` slot below). - */ + * their dasharcs clockwise from 12 o'clock (stats.css, staggered by the per-slice `--lc-i` slot below). */ import { type CSSProperties, type ReactElement, type ReactNode } from 'react' -/** - * The hairline divider between two slices, in dasharray units (1 unit ≈ 1% of - * the circumference ≈ 2.3px on the 96px card ring, so this reads as ~1px). - */ +/** The hairline divider between two slices, in dasharray units (1 unit ≈ 1% of + * the circumference ≈ 2.3px on the 96px card ring, so this reads as ~1px). */ const SEG_GAP = 0.5 export interface DonutSegment { @@ -47,8 +42,7 @@ export function makeDonut(): (props: DonutProps) => ReactElement { if (Number.isFinite(s.value) && s.value > 0) total += s.value } // One dasharray unit = 1% of the circumference (r = 15.9155 → C ≈ 100); - // each segment starts where the previous one ends, the +25 offset pins - // the first segment to 12 o'clock. + // each segment starts where the previous one ends, the +25 offset pins the first segment to 12 o'clock. const arcs: { key: string; color: string; len: number; offset: number }[] = [] let acc = 0 if (total > 0) { @@ -63,8 +57,7 @@ export function makeDonut(): (props: DonutProps) => ReactElement { // The divider: with a neighbour on each side a slice gives up half a gap at // BOTH ends, so the card's own background parts the colours. A lone painted // slice keeps its full ring (a gap there reads as a nick), and the cut never - // takes more than half a sliver — the 0.2% overhead wedge would otherwise - // vanish whole. + // takes more than half a sliver — the 0.2% overhead wedge would otherwise vanish whole. if (arcs.length > 1) { for (const a of arcs) { const cut = Math.min(SEG_GAP / 2, a.len / 4) diff --git a/src/client/components/errorBoundary.tsx b/src/client/components/errorBoundary.tsx index 608dae4..a09f348 100644 --- a/src/client/components/errorBoundary.tsx +++ b/src/client/components/errorBoundary.tsx @@ -1,9 +1,5 @@ -/** - * ErrorBoundary — the tab's no-white-screen guarantee: a render error in the subtree degrades to a styled error card instead of - * propagating - * into the harness's slot renderer and unmounting the conversation view. Class component: the only React primitive that can catch a - * subtree's render errors (no hook-based boundary in React 18); Retry resets the boundary and a healthy value resumes. - */ +/** A subtree render error degrades to this styled card with a working Retry; the harness's own slot boundary + * would leave an empty error placeholder instead. */ import { Component, type ComponentType, type ReactNode } from 'react' import type { Translate } from '../i18n' diff --git a/src/client/components/escapeClose.ts b/src/client/components/escapeClose.ts index a4f668c..895d962 100644 --- a/src/client/components/escapeClose.ts +++ b/src/client/components/escapeClose.ts @@ -1,20 +1,10 @@ -/** - * The shared Escape-to-close contract of the plugin's overlays (the /context - * modal and the baseline-gate dialog): capture-phase keydown on window, so - * the composer's own key handling never swallows Escape first; on close, focus - * returns to the element that held it when the overlay opened (skipped when - * that element left the document). - */ +/** Escape-to-close for the plugin's overlays: capture-phase window keydown, so the harness's Escape routing + * (a bubble-phase window listener) never sees the key. */ import { useEffect, useRef } from 'react' -/** - * Close on Escape while `active`; restores the pre-open focus on cleanup. - * `onClose` rides a latest-ref so the subscription lives on the `active` - * transitions alone: every caller passes a render-fresh closure, and a - * per-render resubscribe would rerun the cleanup's focus restore — yanking - * focus out of the overlay's own inputs on every keystroke. - */ +/** `onClose` rides a latest-ref: a per-render resubscribe would rerun the cleanup's focus restore and yank focus + * out of the overlay's own inputs on every keystroke. */ export function useEscapeClose(active: boolean, onClose: () => void): void { const close = useRef(onClose) close.current = onClose diff --git a/src/client/components/events.tsx b/src/client/components/events.tsx index e591fa2..9f28d33 100644 --- a/src/client/components/events.tsx +++ b/src/client/components/events.tsx @@ -1,7 +1,5 @@ -/** - * Glyphs: inject/model-switch reuse the harness's shared icon set (`@deepseek-ai/dsh-client-ui-primitives`, a platform seed word); - * compaction/prune keep the ✂ marker — no shared glyph exists for it. - */ +/** Glyphs: inject/model-switch reuse the harness's shared icon set (`@deepseek-ai/dsh-client-ui-primitives`, a platform seed word); + * compaction/prune keep the ✂ marker — no shared glyph exists for it. */ import { useEffect, useLayoutEffect, useRef, type ReactElement } from 'react' import type { ContextEventRecord } from '../../shared/types' @@ -15,11 +13,9 @@ const EVENT_ICONS: Record = { compaction: '✂', prune: '✂', m export interface EventListProps { events: ContextEventRecord[] - /** - * The timeline source's detail state (split generation): with no events to + /** The timeline source's detail state (split generation): with no events to * list, `loading`/`failed` replace the empty claim with the pending note / - * retry button — an un-fetched list is not an empty one. - */ + * retry button — an un-fetched list is not an empty one. */ state?: DetailState onRetry?: () => void } @@ -42,10 +38,8 @@ export function makeEventText(t: Translate): { return label } - /** - * Where this event sits in the timeline: boundary events (compaction/prune) label the GAP they sit in — same-turn 'Turn 2 · Step 3→4', - * cross-turn 'Turn 50 · Step 8 → Turn 51 · Step 1'; other kinds keep their single point; no turn/step (in flight) → null. - */ + /** Where this event sits in the timeline: boundary events (compaction/prune) label the GAP they sit in — same-turn 'Turn 2 · Step 3→4', + * cross-turn 'Turn 50 · Step 8 → Turn 51 · Step 1'; other kinds keep their single point; no turn/step (in flight) → null. */ function eventAt(ev: ContextEventRecord): string | null { if (ev.kind === 'compaction' || ev.kind === 'prune') { if (typeof ev.turn === 'number' && typeof ev.step === 'number') { @@ -66,11 +60,9 @@ export function makeEventText(t: Translate): { return { eventLabel, eventAt } } -/** - * Set the native `title` on every label whose text overflows its box (the styled tips cover cards, but a plain +/** Set the native `title` on every label whose text overflows its box (the styled tips cover cards, but a plain * ellipsis row still needs the native fallback); reads (scrollWidth/clientWidth) and writes (title) stay separate - * from layout-affecting work, and callers gate WHEN this runs so it never becomes a per-render forced layout. - */ + * from layout-affecting work, and callers gate WHEN this runs so it never becomes a per-render forced layout. */ function syncTitles(root: HTMLElement): void { for (const el of root.querySelectorAll('.lc-event-label')) { el.title = el.scrollWidth > el.clientWidth ? el.textContent || '' : '' diff --git a/src/client/components/fetchOnMiss.tsx b/src/client/components/fetchOnMiss.tsx index 56fe594..a86630a 100644 --- a/src/client/components/fetchOnMiss.tsx +++ b/src/client/components/fetchOnMiss.tsx @@ -1,12 +1,5 @@ -/** - * The fetch-on-miss state machine behind the Context browser's two on-demand - * reads (a row's full message content and a header epoch's prompt/schemas): - * one targeted history read per key the local data missed, with a visible - * terminal state for every outcome — `absent` when the durable log does not - * hold the key, `failed` arming the retry button, never an unhandled - * rejection or a spinner that never resolves. Landed values cache by key: - * history is immutable, so a resolved key never refetches. - */ +/** The fetch-on-miss state machine: one targeted history read per missed key; `absent`/`failed` are terminal, and a landed + * value caches by key (history is immutable, so it never refetches). */ import { useEffect, useState, type ReactNode } from 'react' import type { Translate } from '../i18n' @@ -14,18 +7,12 @@ import type { Translate } from '../i18n' export type FetchMissState = 'idle' | 'loading' | 'absent' | 'failed' export interface FetchOnMiss { - /** Resolved values by key (immutable history — a landed value never refetches). */ values: Map state: FetchMissState - /** Re-arm the read after a failure (the retry button). */ retry: () => void } -/** - * Run one fetch for `key` when it is set and not yet cached. `key` null (no - * target open) or `fetcher` undefined (older host without the history face) - * leaves the machine idle; the caller renders the matching note either way. - */ +/** `key` null or `fetcher` undefined (an older host without the history face) leaves the machine idle; the caller renders the note. */ export function useFetchOnMiss( key: number | null, fetcher: ((key: number) => Promise) | undefined, @@ -59,13 +46,7 @@ export function useFetchOnMiss( return { values, state, retry: () => { setAttempt(a => a + 1) } } } -/** - * The note a not-yet-loaded fetch target shows — one expression for both - * on-demand reads: the legacy static hint (`emptyKey`) when no fetcher is - * composed, `loading` while the read is in flight (and on the first frame - * before the effect fires), `notInLog` when the durable log does not hold - * the key, and a retry button after a failed read. - */ +/** The trailing `loading` also covers the first frame, before the effect fires. */ export function fetchMissNote( t: Translate, fetcher: unknown, diff --git a/src/client/components/fileCard.tsx b/src/client/components/fileCard.tsx index 32c6f1c..ea77a71 100644 --- a/src/client/components/fileCard.tsx +++ b/src/client/components/fileCard.tsx @@ -1,19 +1,3 @@ -/** - * The File Activity card — the user-benefit view of a step range: not what - * the context is MADE of (messages) but what the agent DID with its tools - * to the user's files. One row per touched file with per-purpose counts - * (read/written/searched), an estimated line delta, and multimodal forms - * (image reads, directory targets) flagged; the header chips double as - * purpose/form filters and the search box narrows by path. - * - * Interaction mirrors the Context browser's element rows: a row click - * expands the file's own operation log; each operation is itself the click - * target that jumps to (and reveals) the exact tool result in the browser. - * The file name opens the file's right-Sidebar preview where that column - * exists (the shipped files-sidebar idiom), and the system opener where it - * does not. - */ - import { memo, useState, type ChangeEvent, type ComponentType, type MouseEvent, type ReactElement } from 'react' import { absPathOf, displayPathOf, glyphOf } from '../fileActivity' import type { FileActivity, FileEntry, FileOp, FileOpKind } from '../fileActivity' @@ -26,25 +10,12 @@ export type FileFilter = 'all' | FileOpKind | 'image' export interface FileCardProps { activity: FileActivity - /** The range label — the picked step, or the whole-session latest view. */ scope: string - /** The host workspace root: workspace paths display './'-relative when known. */ workspace?: string - /** - * Open a file's right-Sidebar preview; returns whether the column took it, so - * a refusal falls through to the system opener. Absent = this harness has no - * preview column (the system-open path stays the only affordance). - */ + /** Returns whether the preview column took the file, so a refusal falls through to the system opener; absent = no preview column. */ onPreview?: (entry: FileEntry) => boolean - /** Open a file on the user's system; absent = the file name renders inert. */ onOpen?: (absPath: string) => void - /** Reveal one operation's result node in the Context browser; absent = op lines render inert. */ onLocate?: (op: FileOp) => void - /** - * The timeline source's detail state (split generation): the activity fold - * reads the detail collections, so a pending/failed first read replaces the - * empty claim with the pending note / retry button. - */ state?: DetailState onRetry?: () => void } @@ -71,33 +42,29 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component ) } - // Memoized at the factory level: the parent's activity/scope/onLocate props are all reference-stable across - // hover/select renders (see contextView), so this card skips reconciliation whenever its own inputs did not move. + // Memoized at the factory level: the parent's activity/scope/onLocate props are reference-stable across hover/select + // renders (see contextView), so the card skips reconciliation whenever its own inputs did not move. return memo(function FileCard(props: FileCardProps): ReactElement { const { activity } = props const [filter, setFilter] = useState('all') - // Mount-time default from the plugin settings card; in-card toggling stays mount-local and never writes back. + // Mount-time default; in-card toggling stays mount-local and never writes back. const [sort, setSort] = useState(() => settings.defaultFileSort()) const [query, setQuery] = useState('') const [openPath, setOpenPath] = useState(null) const q = query.trim().toLowerCase() - // Display form of an entry's path: './'-relative inside the workspace, verbatim outside — - // except pattern-as-target search rows, whose "path" is a search pattern, not a file. + // Display form of an entry's path: './'-relative inside the workspace, verbatim outside — except search rows, + // whose "path" is a pattern. const displayOf = (e: FileEntry): string => (e.pattern === true ? e.path : displayPathOf(e.path, props.workspace)) - // The count sort's magnitude: the selected kind's own ops when a kind - // chip is active (a file heavy on writes must not outrank one heavy on - // reads under the read chip), else the file's overall op count. + // Count sort magnitude: the selected kind's own ops under a kind chip, else the file's overall op count. const countOf = (e: FileEntry): number => filter === 'read' ? e.reads : filter === 'write' ? e.writes : filter === 'search' ? e.searches : e.ops.length - /* The fold serves entries latest-first; the sort toggle re-orders the filtered copy (never the source array). */ const shown = activity.entries .filter(e => matches(e, filter) && (q === '' || displayOf(e).toLowerCase().includes(q))) .sort((a, b) => sort === 'count' ? (countOf(b) - countOf(a)) || (b.ops[0].seq - a.ops[0].seq) : sort === 'latest' ? b.ops[0].seq - a.ops[0].seq - // Paths are unique map keys: a two-way comparison orders them fully. : (a.path < b.path ? -1 : 1)) const chips: { key: FileFilter; files: number; ops: number }[] = [ @@ -115,7 +82,6 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component const opLine = (op: FileOp): ReactElement => ( <> {op.tool} - {/* A read's line footprint: the exact `>>n` window off the result meta, or the ≈ limit estimate. */} {op.read !== undefined ? ( {op.detail} : null} {op.hits !== undefined ? {t('files.hits', { n: fmt(op.hits) })} : null} - {/* A nested PTC op with nothing else to say still tells why: its program's description. */} + {/* A nested PTC op with no other detail still names its program. */} {op.detail === undefined && op.hits === undefined && op.program !== undefined ? {op.program} : null} {op.added + op.removed > 0 ? : null} {op.err ? : null} - {/* The time rides the right edge, mirroring the file row above. */} {op.time !== undefined ? fmtTime(op.time) : '—'} ) @@ -156,13 +121,12 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component ) : (
- {/* The five purpose chips (labels + counts) overflow a ~300px card in English — flow the group to two lines. */} + {/* The five chips overflow a ~300px card in English — fold the group to two lines. */}
{chips.map(c => ( - {/* The DeepSeek platform balance (client/balance.ts): renders nothing - until a live figure lands, so the heading row never reflows for it. */} + {/* Renders nothing until a live figure lands, so the heading row never reflows for it. */} - {/* One wrapper so the head's right cluster pushes once: the - calendar picker, then the preset group. A bare `.lc-gran` - sibling would eat a second slice of the free space and the - two would drift apart. */} + {/* One wrapper so the head's right cluster pushes once; a bare `.lc-gran` sibling would split the free space. */}
{t('ov.unavailable')}
) : ( <> - {/* The first row: a 1:1 column pair — the KPI metrics band - (the range's six figures, three per row) beside the - last-7-days usage chart (per-day tokens + cost bars off - the merged ledger; the range selector does not scope it). */} + {/* The range selector does not scope the usage chart; it reads the merged daily ledger. */}
@@ -293,9 +238,6 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
- {/* The aggregate stats row: the range's Token Stats and Timing - Stats, folded over the KPI band's own scope (the composition - split and summed totals off kpisOf). */}
@@ -305,9 +247,6 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
{t('ov.heat.title')} - {/* One wrapper so the right side pushes with a single auto - margin (two bare auto-margin siblings would split the - free space and drift apart). */} {t('ov.heat.sub')}
@@ -324,12 +263,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
- {/* The activity column's second card: the scope's skill - loads off the rows' family ledgers (skillLoadsOf). The - scope is the range group plus the heatmap's pinned day - — the column's own drill-down — never the list's search - or group chips (those are list-local). A row pin narrows - the session list to the skill's loaders. */} + {/* Scope: the range group plus the heatmap's pinned day; the list's search and group chips are list-local. */} Math.max(top, s.loads), 0) // ---- the name filter --------------------------------------------------- // A local, case-insensitive substring filter over the skill names; the // summary counts and the rendered rows follow it, so the card always - // says what it shows. The input stays mounted on an empty match so the - // filter can always be cleared from the UI. + // says what it shows. The input stays mounted on an empty match so the filter can always be cleared from the UI. const [query, setQuery] = useState('') const needle = query.trim().toLowerCase() const visible = needle === '' ? stats : stats.filter(s => s.name.toLowerCase().includes(needle)) @@ -176,8 +169,7 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) = const bubbleRef = useRef(null) const hoverStat = hoverRow === null ? undefined : stats.find(s => s.name === hoverRow.name) - // Any scroll or resize moves every anchor — retract rather than float - // detached (the page's hoverTip zone's own rule). + // Any scroll or resize moves every anchor — retract rather than float detached (the page's hoverTip zone's own rule). useEffect(() => { if (hoverRow === null) return undefined const retract = (): void => { setHoverRow(null) } @@ -189,15 +181,11 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) = } }, [hoverRow]) - // Measure once per shown card (the spot keys on the hover record, so a - // same-row re-render never re-hides): center on the row, clamp inside the - // viewport, flip below when above overflows. + // Measured once per shown card (keyed on the hover record, so a same-row re-render never re-hides). useLayoutEffect(() => { if (hoverRow === null) return const el = bubbleRef.current - /* v8 ignore next -- the portal commits its ref before layout effects run, - * so a null ref here is unreachable; kept as a guard so a future portal - * change cannot crash the page. */ + /* v8 ignore next -- unreachable: the portal commits its ref before layout effects run. */ if (el === null) return const w = el.offsetWidth const h = el.offsetHeight @@ -228,8 +216,7 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) = // full path as a click-to-copy value — so nothing floats unlabeled. A pin // whose row is not rendered — out of the filtered scope or past the // render bound — falls back to a detached block at the card's bottom, - // head row included; a catalog-less pin with in-scope tallies still shows - // its usage rows. + // head row included; a catalog-less pin with in-scope tallies still shows its usage rows. const selected = props.selected const pinnedStat = selected === null || selected === undefined ? undefined @@ -399,8 +386,7 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) = ) // The pinned row and its detail read as one unit — a shared - // fill and a single ring around the pair (`.lc-ov-skill-unit`), - // never two stacked boxes with a seam. + // fill and a single ring around the pair (`.lc-ov-skill-unit`), never two stacked boxes with a seam. return pinned ?
{rowButton}{detailBlock}
: {rowButton} diff --git a/src/client/components/overviewTokens.tsx b/src/client/components/overviewTokens.tsx index 2493580..d60c2d5 100644 --- a/src/client/components/overviewTokens.tsx +++ b/src/client/components/overviewTokens.tsx @@ -1,12 +1,9 @@ -/** - * The Context Insights panel's aggregate Token Stats card — the first row's +/** The Context Insights panel's aggregate Token Stats card — the first row's * token half, categorized exactly like the Context tab's Token card * (statsTokens.tsx): the range's billed volume split by WHAT the tokens are — * the composition categories' `≈` estimates proportioning each session's * provider-reported prompt total, plus the exact output — folded by - * tokenPartsOf. The center figure stays the exact merged billed total the - * KPI band's Tokens Used cell shows. - */ + * tokenPartsOf. The center figure stays the exact merged billed total the KPI band's Tokens Used cell shows. */ import { useState, type ReactElement } from 'react' import type { TokenPartTotal } from '../overview' diff --git a/src/client/components/overviewUsage.tsx b/src/client/components/overviewUsage.tsx index 1d6a513..e943a30 100644 --- a/src/client/components/overviewUsage.tsx +++ b/src/client/components/overviewUsage.tsx @@ -53,7 +53,7 @@ export function makeOverviewUsage(kit: ViewKit): (props: OverviewUsageProps) => const { t, fmt } = kit return function OverviewUsage(props: OverviewUsageProps): ReactElement { const window = usageWindowOf(props.today) - // Widened honestly: a Record index read can miss at runtime. + // Widened: a Record index read can miss at runtime. const byKey: Record = props.days const columns = window.map(key => ({ key, entry: byKey[key] })) const empty = columns.every(c => c.entry === undefined || (c.entry.tokens <= 0 && c.entry.cost === null)) @@ -67,8 +67,7 @@ export function makeOverviewUsage(kit: ViewKit): (props: OverviewUsageProps) => const heightOf = (value: number, max: number): string => (max <= 0 || value <= 0 ? '0%' : `${Math.max(4, Math.round((value / max) * 100))}%`) const tickOf = (percent: number): string => fmt(Math.round((maxTokens * percent) / 100)) // '—' while nothing prices (an all-zero rail would read as free); the 0 - // tick reads bare — formatCost's toPrecision renders the floor as the - // odd "¥0.0". + // tick reads bare — formatCost's toPrecision renders the floor as the odd "¥0.0". const costTickOf = (percent: number): string => { if (maxCost <= 0) return '—' return percent === 0 ? '0' : formatCost((maxCost * percent) / 100, props.currency) @@ -99,8 +98,7 @@ export function makeOverviewUsage(kit: ViewKit): (props: OverviewUsageProps) => {columns.map((c) => { const tokens = c.entry?.tokens ?? 0 const cost = c.entry?.cost ?? null - // The tooltip line reads the BARE label — the legend's - // qualifier ("仅含支持计价映射的模型") stays off the bubble. + // The tooltip line reads the BARE label; the legend's priced-models qualifier stays off the bubble. const label = `${c.key}\n${t('ov.usage.tokens')} ${fmt(tokens)}\n${t('ov.usage.costTip')} ${cost === null ? '—' : formatCost(cost, props.currency)}` return (
diff --git a/src/client/components/pluginInfo.tsx b/src/client/components/pluginInfo.tsx index d378ec9..1310376 100644 --- a/src/client/components/pluginInfo.tsx +++ b/src/client/components/pluginInfo.tsx @@ -1,8 +1,5 @@ -/** - * PluginInfo — the card beside Context stats introducing the plugin. Metadata is baked in from package.json via tsdown `define` (see - * meta.ts); one live npm-registry check (latestVersion.ts, 1-hour TTL) appends an `↑ vX.Y.Z` chip when newer. - * Row labels lead with site marks: the simple-icons artwork (the set the harness's own SiteGlyph draws from), riding currentColor. - */ +/** The card beside Context stats introducing the plugin. Metadata is baked in from package.json via tsdown `define`; + * one live npm-registry check appends an `↑ vX.Y.Z` chip when newer. */ import { useEffect, useState, type ReactElement, type ReactNode } from 'react' import { IconSettings } from '../primitives' @@ -12,9 +9,7 @@ import { PLUGIN_NAME, PLUGIN_REPO, PLUGIN_REPO_SHORT, PLUGIN_VERSION } from '../ import { openPluginSettings } from '../settingsJump' import type { ViewKit } from '../viewkit' -// The marks fill their 24-unit box edge to edge; the -2 inset leaves the same -// ~8% margin the harness's SiteGlyph leaves for its site marks (SiteGlyph -// itself is not exported, so the artwork rides the package directly). +// The marks fill their 24-unit box edge to edge, so the -2 inset restores the harness SiteGlyph's ~8% margin. function SiteMark({ icon }: { icon: SimpleIcon }): ReactElement { return ( @@ -25,8 +20,7 @@ function SiteMark({ icon }: { icon: SimpleIcon }): ReactElement { export function makePluginInfo(kit: ViewKit): () => ReactElement { const { t } = kit - // `title` carries the untruncated value: at narrow card widths the row's - // ellipsis can cut the repo or name short, and the hover text recovers it. + // `title` carries the untruncated value, which the row's ellipsis can cut at narrow widths. const row = (icon: ReactElement, label: string, value: ReactNode, href: string, hint: string) => (
{icon}{label}
@@ -38,8 +32,7 @@ export function makePluginInfo(kit: ViewKit): () => ReactElement { useEffect(() => { if (PLUGIN_VERSION.includes('-dev')) return let on = true - // Fire-and-forget: fetchLatestVersion never rejects (every failure - // narrows to null), and the `on` flag drops late results. + // fetchLatestVersion never rejects (every failure narrows to null); the `on` flag drops late results. void fetchLatestVersion().then((v) => { if (on && v) setLatest(v) }) return () => { on = false } }, []) @@ -47,8 +40,7 @@ export function makePluginInfo(kit: ViewKit): () => ReactElement { const nameText = PLUGIN_NAME + ' (v' + PLUGIN_VERSION + ')' const nameValue: ReactNode[] = [nameText] if (update) nameValue.push({'↑ v' + update}) - // The quarter-share card of the head row (the stats card takes 3 parts): its rows - // ellipsize and recover values on hover, so a 240px floor suffices. + // Its rows ellipsize and recover values on hover, so a 240px floor suffices. return (
diff --git a/src/client/components/requestDetail.tsx b/src/client/components/requestDetail.tsx index c01121b..4a4d58a 100644 --- a/src/client/components/requestDetail.tsx +++ b/src/client/components/requestDetail.tsx @@ -10,29 +10,22 @@ import type { ViewKit } from '../viewkit' export interface RequestDetailProps { request: RequestRecord | null - /** - * Delta mode: the record displayed just before this one (null on the first + /** Delta mode: the record displayed just before this one (null on the first * bar). Its PRESENCE switches the panel from cumulative makeup to the signed - * change against that previous record — the same pairing the chart's - * deltaOf plots. - */ + * change against that previous record — the same pairing the chart's deltaOf plots. */ prev?: RequestRecord | null marker?: ContextEventRecord | null /** The step's semantic identity (turn opener / inputs / reply) — see brief.ts; null renders an empty reserved lane. */ brief?: StepBrief | null /** Conversation-snapshot join for call-argument enrichment; absent join = names only, never an error. */ convOf?: (seq: number) => ConversationNodeLike | undefined - /** - * Per-turn step totals (the parent's `turnStepsOf` over the served requests) behind the head's - * step-of-total figure; absent = a 1-step turn. - */ + /** Per-turn step totals (the parent's `turnStepsOf` over the served requests) behind the head's + * step-of-total figure; absent = a 1-step turn. */ stepsOf?: (turn: number | undefined) => number /** Reveal a brief row's node in the Context browser; absent = rows render inert. */ onLocate?: (node: SurfaceNode, isResponse: boolean) => void - /** - * Mirrored category hover (shared with the overview/browser): lights the composition bar's matching - * segment; the bar never reports hovers back. - */ + /** Mirrored category hover (shared with the overview/browser): lights the composition bar's matching + * segment; the bar never reports hovers back. */ hoverKey?: string | null } @@ -42,8 +35,7 @@ export function makeRequestDetail( ): ComponentType { const { t, fmt, fmtTime, catLabel, eventLabel, eventAt } = kit - /** - * One brief row: a fixed-width kind tag plus one glanceable line. The tag carries a styled, instant explanation bubble (the + /** One brief row: a fixed-width kind tag plus one glanceable line. The tag carries a styled, instant explanation bubble (the * shared `.lc-tip` chrome); the content span keeps the native title (preview + locate hint), so the two never stack. * Clickable when the browser linkage is wired AND the row carries a node (the always-present In row's empty state stays inert). */ @@ -76,13 +68,10 @@ export function makeRequestDetail( ) } - /** - * One line's anatomy — a compact FACT tag plus the preview text, mirroring the Context browser's element rows so a + /** One line's anatomy — a compact FACT tag plus the preview text, mirroring the Context browser's element rows so a * brief line reads exactly like the browser row its click reveals: tool results tag the tool name, skill content * the skill name, assistant replies the call breadcrumb, injections the form, user messages image attachments. - * The branches cover the Category union; a hostile cat degrades into the - * user tail — a plain text row, never a throw. - */ + * The branches cover the Category union; a hostile cat degrades into the user tail — a plain text row, never a throw. */ function chipParts(n: SurfaceNode, conv: ConversationNodeLike | undefined): { tag: string | null; text: string } { if (n.cat === 'tool') { const summary = callSummaryOf(conv) @@ -90,10 +79,9 @@ export function makeRequestDetail( return { tag: null, text: summary ?? t('node.toolResult') } } if (n.cat === 'skill') { - // Skill content (issue #66): a load/invocation tags its NAME, the - // catalog digest its form label ('目录更新'). A text-less row (the - // `skill`-tool load — its content rode the tool result) previews the - // call summary, like the tool rows do. + // Skill content (issue #66): a load/invocation tags its NAME, the catalog digest its form label (the + // catalog-updated wording). A text-less row (the `skill`-tool load — its content rode the tool result) + // previews the call summary, like the tool rows do. return { tag: n.skill !== undefined ? t('node.skillTag', { name: n.skill }) : t('form.' + (n.form ?? 'context')), text: n.text ?? callSummaryOf(conv) ?? '', @@ -128,10 +116,8 @@ export function makeRequestDetail( return tag !== null ? (text !== '' ? tag + ' · ' + text : tag) : text } - /** - * The brief's container ALWAYS renders — a fixed three-row lane (`.lc-brief`'s min-height) so scrubbing the chart never - * changes the panel's height; unknown or empty steps just leave parts of the lane blank instead of collapsing it. - */ + /** The brief's container ALWAYS renders — a fixed three-row lane (`.lc-brief`'s min-height) so scrubbing the chart never + * changes the panel's height; unknown or empty steps just leave parts of the lane blank instead of collapsing it. */ function BriefSection(props: { brief: StepBrief | null | undefined convOf?: (seq: number) => ConversationNodeLike | undefined @@ -145,11 +131,9 @@ export function makeRequestDetail( const locateChip = props.onLocate === undefined ? undefined : (n: SurfaceNode) => (e?: MouseEvent) => { e?.stopPropagation(); props.onLocate?.(n, false) } const MAX_CHIPS = 3 - /** - * The ONE content unit of the brief — inputs and the reply share the same chip anatomy (error dot, fact tag, + /** The ONE content unit of the brief — inputs and the reply share the same chip anatomy (error dot, fact tag, * preview text). Input chips are compact and individually clickable (each locates its own node); the reply is a - * single chip grown to the row's width, left inert because the row button already locates it. - */ + * single chip grown to the row's width, left inert because the row button already locates it. */ const nodeChip = (n: SurfaceNode, onClick?: (e?: MouseEvent) => void, grow = false): ReactElement => { const { tag, text } = chipParts(n, convOf(n.seq)) return ( @@ -208,8 +192,7 @@ export function makeRequestDetail( const head = isTurn ? t('detail.turn', { t: req.turn ?? 0, - /* v8 ignore next -- isTurn already guarantees stepCount !== undefined; - the fallback is defensive. */ + /** v8 ignore next -- isTurn already guarantees stepCount !== undefined; the fallback is defensive. */ n: req.stepCount ?? 0, }) : t('detail.step', { t: req.turn ?? 0, s: req.step ?? 0, n: props.stepsOf?.(req.turn) ?? 1 }) @@ -219,8 +202,7 @@ export function makeRequestDetail( const marker = props.marker ?? null const markerAt = marker !== null ? eventAt(marker) : null // Delta mode: per-category SIGNED change vs the previous record (the chart stacks them diverging - // above/below its zero line); provider usage chips drop out — prompt/output/cacheRead are - // per-request figures, not deltas. + // above/below its zero line); provider usage chips drop out — prompt/output/cacheRead are per-request figures, not deltas. const delta = props.prev !== undefined const prev = props.prev ?? null const deltas = CATS.map(c => delta ? (req[c.key] || 0) - (prev !== null ? prev[c.key] || 0 : 0) : 0) diff --git a/src/client/components/richText.tsx b/src/client/components/richText.tsx index 65a068b..cbd7817 100644 --- a/src/client/components/richText.tsx +++ b/src/client/components/richText.tsx @@ -1,10 +1,5 @@ -/** - * RichText — the raw/markdown body for the Context browser's detail sections. Markdown renders via the harness's shared MarkdownText (GFM, - * sanitized, resolved from the platform module table — zero plugin-side markdown dependency); raw is a line-numbered `
`. The Raw/MD
-  * switch sits at a section head's right edge (RichSwitch; per-card mode via useRichMode), with the copy-raw control (RichCopy) and the
-  * find-in-text control (useRichFind) beside it. Find wraps matches in DOM-level `` elements over the rendered body — the same lens
-  * in both modes, no markdown AST work — and the section remounts the body on a text change so React never reconciles over mutated nodes.
- */
+/** The raw/markdown body for the Context browser's detail sections: MarkdownText for `md` (the harness's shared GFM
+ * renderer), a line-numbered `
` for raw. */
 
 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactElement, type RefObject } from 'react'
 import { MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -13,11 +8,7 @@ import type { ViewKit } from '../viewkit'
 
 export type RichMode = 'raw' | 'md'
 
-/**
- * The Markdown chrome the harness primitives serve as a required `labels`
- * prop. Typed locally so the plugin typechecks without a primitives
- * dependency.
- */
+/** The `labels` prop MarkdownText requires, typed locally so the plugin needs no primitives dependency. */
 interface MarkdownChrome {
   code: { copyLabel: string; copiedLabel: string }
   footnotes: string
@@ -33,21 +24,17 @@ export interface RichKit {
   useRichFind: (text: string, mode: RichMode) => RichFind
 }
 
-/** Per-section find-in-text state and controls, wired by the section head. */
 export interface RichFind {
-  /** The head toggle button (magnifier), placed beside the copy control. */
   button: ReactElement
-  /** The find bar row, rendered between head and body; null while closed. */
   bar: ReactElement | null
   /** Key for the body wrapper: a text change remounts the body so React never diffs over the injected marks. */
   bodyKey: string
-  /** Ref for the body wrapper the marks are scoped to. */
   bodyRef: RefObject
 }
 
 /** Settle window for the query: rapid typing or deleting rescans the body once, not per keystroke. */
 const FIND_DEBOUNCE_MS = 150
-/** Cap on rendered marks: a one-character query in a huge body must not flood the DOM (the counter reports the cap with a '+'). */
+/** Cap on rendered marks: a one-character query must not flood the DOM (the counter reports the cap with a '+'). */
 const FIND_MAX_MARKS = 500
 
 interface FindMarks {
@@ -56,11 +43,8 @@ interface FindMarks {
   restore: () => void
 }
 
-/**
- * Wrap every case-insensitive occurrence of `query` under `root` in a ``, flag the active one and scroll it into view. The
- * restore puts the ORIGINAL text nodes back with their original values (splitText keeps the first node in place, so React's own
- * node references survive the whole cycle); created nodes are removed.
- */
+/** Wrap every case-insensitive occurrence of `query` under `root` in a ``. The restore puts the original text nodes
+ * back with their values (splitText keeps the first node in place, so React's node references survive). */
 function markMatches(root: HTMLElement, query: string, active: number): FindMarks {
   const needle = query.toLowerCase()
   const texts: Text[] = []
@@ -70,7 +54,7 @@ function markMatches(root: HTMLElement, query: string, active: number): FindMark
   const marks: HTMLElement[] = []
   let capped = false
   for (const textNode of texts) {
-    // CharacterData.data (not nodeValue) is non-nullable; an empty haystack simply never matches.
+    // CharacterData.data (not nodeValue) is non-nullable, so an empty haystack never matches.
     const full = textNode.data
     const created: Node[] = []
     let rest = textNode
@@ -112,9 +96,6 @@ export function makeRichText(kit: ViewKit): RichKit {
   const { t } = kit
 
   function useRichMode(): [RichMode, (mode: RichMode) => void] {
-    // Markdown is the default view: the detail cards hold prose (prompts,
-    // descriptions, messages), which reads better rendered; raw stays one
-    // click away for exact source inspection.
     const [mode, setMode] = useState('md')
     return [mode, setMode]
   }
@@ -136,9 +117,8 @@ export function makeRichText(kit: ViewKit): RichKit {
     )
   }
 
-  // One block per source line: the number is a counter-fed ::before glued to
-  // its own line across soft wraps, and pseudo content never reaches the
-  // clipboard, so selecting the body still copies the exact source text.
+  // One block per source line: the number is a counter-fed ::before glued to its line across soft wraps, and pseudo
+  // content never reaches the clipboard.
   function RawText(props: { text: string }): ReactElement {
     const lines = useMemo(() => {
       const parts = props.text.split('\n')
@@ -153,11 +133,7 @@ export function makeRichText(kit: ViewKit): RichKit {
     )
   }
 
-  // Icon-only copy of the section's exact source (always the raw text, never
-  // the rendered markdown). The write and its transient confirmation follow
-  // the harness's own code-block control: a rejected host write claims no
-  // success, a second click during the window is a no-op, and the glyph
-  // resets after a beat.
+  // Always the raw source, never the rendered markdown; the write and its confirmation follow the harness's own code-block control.
   function RichCopy(props: { text: string }): ReactElement {
     const [copied, setCopied] = useState(false)
     const onCopy = useCallback(() => {
@@ -183,8 +159,7 @@ export function makeRichText(kit: ViewKit): RichKit {
   }
 
   function RichText(props: { text: string; mode: RichMode }): ReactElement {
-    // Reference-stable per locale: a fresh object identity would discard the
-    // renderer's cached elements on every render.
+    // Reference-stable per locale: `labels` is a MarkdownText dependency, so a fresh object would re-parse the Markdown on every render.
     const mdLabels = useMemo(() => ({
       code: { copyLabel: t('rich.md.copy'), copiedLabel: t('rich.md.copied') },
       footnotes: t('rich.md.footnotes'),
@@ -195,9 +170,7 @@ export function makeRichText(kit: ViewKit): RichKit {
     return 
   }
 
-  // Find-in-text (ctrl+f style), scoped to one section's body. The query is debounced before the scan; Enter/Shift+Enter (or the
-  // chevrons) cycle the active match, Escape closes. The lens lives in the hook so a reopened bar finds the query it left, and the
-  // mark effect keys on text AND mode so the lens follows a body swap (step pick or Raw/MD switch) instead of going stale.
+  // Find-in-text (ctrl+f style), scoped to one section's body; the mark effect keys on text AND mode so the lens follows a body swap.
   function useRichFind(text: string, mode: RichMode): RichFind {
     const [open, setOpen] = useState(false)
     const [query, setQuery] = useState('')
diff --git a/src/client/components/settingsCard.tsx b/src/client/components/settingsCard.tsx
index 9b91d6c..1445936 100644
--- a/src/client/components/settingsCard.tsx
+++ b/src/client/components/settingsCard.tsx
@@ -1,11 +1,5 @@
-/**
- * The dsh-context preference card: the eight preference rows on the
- * Plugins-page seat (`plugins.bundle.config`, the Config-form generation),
- * rendered flat inside the section chrome the Plugins page draws for the
- * bundle. The card is keyed on the Host-served `dsh-context` namespace and
- * renders nothing while it is unavailable (a deployment without the Host
- * half, or a remote browser, shows no trace).
- */
+/** The dsh-context preference card, keyed on the Host-served `dsh-context` namespace: while that namespace is
+ * unavailable (a deployment without the Host half, or a remote browser) the card renders nothing. */
 
 import { useState, type ReactElement } from 'react'
 import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -58,10 +52,8 @@ function PrefRow(props: PrefRowProps): ReactElement {
   )
 }
 
-/** Translate over the plugin's dictionary, as the view kit binds it. */
 type Translate = ViewKit['t']
 
-/** The eight preference rows. */
 function PreferenceRows(props: { t: Translate; state: SettingsState; set?: SettingsCardProps['set'] }): ReactElement {
   const { t, state, set } = props
   const disabled = state.status !== 'ready' || !state.writable
@@ -154,11 +146,7 @@ function PreferenceRows(props: { t: Translate; state: SettingsState; set?: Setti
   )
 }
 
-/**
- * The Plugins-page card (the Config-form generation's `plugins.bundle.config`
- * seat, `view: 'page'`): the page owns the bundle's page and section chrome,
- * so the rows render flat. Unserved/absent degrades to nothing.
- */
+/** The Plugins page owns the bundle's page and section chrome, so the rows render flat; unserved or absent degrades to nothing. */
 export function makePluginConfigCard(kit: ViewKit): (props: SettingsCardProps) => ReactElement | null {
   const { t } = kit
   return function PluginConfigCard(props: SettingsCardProps): ReactElement | null {
diff --git a/src/client/components/sliceList.tsx b/src/client/components/sliceList.tsx
index e9170c0..18add68 100644
--- a/src/client/components/sliceList.tsx
+++ b/src/client/components/sliceList.tsx
@@ -1,13 +1,5 @@
-/**
- * The stats cards' slice rows: the donut's legend with numbers, two tiers
- * per slice — the primary line pairs the color dot and the name with the
- * bold share closing a fixed right column, and the secondary line drops the
- * absolute quantity (plus a qualifier like the call count) under the name in
- * muted small print. Hovering a row lights it and its donut segment together
- * (the shared hover key), and vice versa. No tracks or bars — the donut IS
- * the proportion chart; the rows are its legend. Preformatted strings in,
- * dumb markup out.
- */
+/** The stats cards' slice rows — the donut's legend: hovering a row lights it and its donut segment together, and vice
+ * versa. Preformatted strings in, dumb markup out. */
 
 import { type ReactElement } from 'react'
 
@@ -17,7 +9,7 @@ export interface SliceRow {
   label: string
   /** Preformatted leading share ('84%', '<1%', '—'). */
   pct: string
-  /** Preformatted secondary line ('204.9k', '4m 0s · 10 calls'); empty renders no line. */
+  /** Preformatted secondary line; empty renders no line. */
   count: string
   /** A zero-figure slice dims whole — the ring already carries the share. */
   dim?: boolean
@@ -25,9 +17,8 @@ export interface SliceRow {
 
 export function makeSliceList(): (props: {
   rows: SliceRow[]
-  /** The hovered slice key — the legend row ↔ segment hover link. */
   hoverKey?: string | null
-  /** Hover relay; absent renders the rows inert. */
+  /** Absent renders the rows inert. */
   onHoverKey?: (key: string | null) => void
 }) => ReactElement {
   return function SliceList(props: {
diff --git a/src/client/components/stackedBar.tsx b/src/client/components/stackedBar.tsx
index 6f882a9..cdaccc0 100644
--- a/src/client/components/stackedBar.tsx
+++ b/src/client/components/stackedBar.tsx
@@ -1,26 +1,20 @@
-/**
- * Composition bar + legend; the shared hover-link tooltip is bespoke — no shared primitive reproduces the cross-segment/legend linkage —
+/** Composition bar + legend; the shared hover-link tooltip is bespoke — no shared primitive reproduces the cross-segment/legend linkage —
  * styled through the shared `--dsw-alias-*` tokens. On mount the segments and the free track grow open
  * left to right (stackedBar.css, staggered by the capped per-piece `--lc-i` slots below, so a long DNA
- * band list settles fast); the reserve band stays unanimated.
- */
+ * band list settles fast); the reserve band stays unanimated. */
 
 import { useState, type CSSProperties, type ReactElement } from 'react'
 import type { PartsPart } from '../categories'
 import type { ViewKit } from '../viewkit'
 
-/**
- * Mirror of dsh-compaction-basic's default `thresholdRatio` (0.8): it compacts at step boundaries once `floor(contextWindow × ratio)` is
+/** Mirror of dsh-compaction-basic's default `thresholdRatio` (0.8): it compacts at step boundaries once `floor(contextWindow × ratio)` is
  * reached; DSH does not publish the configured ratio to plugins/clients, so the reserve band mirrors the default — deployments tuning
- * `thresholdRatio`/`modelPolicies` should adjust it to match.
- */
+ * `thresholdRatio`/`modelPolicies` should adjust it to match. */
 export const AUTO_COMPACT_RATIO = 0.8
 
-/**
- * Entrance stagger cap: the browser's DNA bands can list dozens of items, so the grow-in cascade stops
+/** Entrance stagger cap: the browser's DNA bands can list dozens of items, so the grow-in cascade stops
  * widening after this many slots and late bands simply join within the cap (stackedBar.css delays by
- * `--lc-i`); the composition bar's few category segments never reach it.
- */
+ * `--lc-i`); the composition bar's few category segments never reach it. */
 const STAGGER_CAP = 8
 
 export interface StackedBarProps {
@@ -31,20 +25,15 @@ export interface StackedBarProps {
   onHoverKey?: (key: string | null) => void
   /** Segment click (the browser's DNA mode opens the band's accordion row): segments gain the pick cursor and report their key. */
   onPickKey?: (key: string) => void
-  /**
-   * Minimum width each segment keeps, as a share of the OCCUPIED region (the browser's DNA bands: a tiny item must
+  /** Minimum width each segment keeps, as a share of the OCCUPIED region (the browser's DNA bands: a tiny item must
    * stay a hoverable filament instead of rendering sub-pixel). Bands below the floor pin to it and the rest rescale
    * proportionally; the floor caps at the equal split (100/N), where the strip degrades to uniform bands. Tooltips
-   * keep reporting TRUE shares — only widths are floored.
-   */
+   * keep reporting TRUE shares — only widths are floored. */
   minBand?: number
-  /**
-   * Render the hover tooltip (default true); a bar that only MIRRORS another card's hover turns it off, so the tooltip floats only over the
-   * surface the pointer actually rests on.
-   */
+  /** Render the hover tooltip (default true); a bar that only MIRRORS another card's hover turns it off, so the tooltip
+   * floats only over the surface the pointer actually rests on. */
   tip?: boolean
-  /**
-   * Optional auto-compaction reserve band: the rightmost (1−ratio) of the window, striped 'headroom' — the region the session normally
+  /** Optional auto-compaction reserve band: the rightmost (1−ratio) of the window, striped 'headroom' — the region the session normally
    * avoids filling because automatic compaction triggers past the threshold; rendered only when `max` (the window) is positive.
    */
   reserve?: { ratio: number; label: string }
diff --git a/src/client/components/statsContext.tsx b/src/client/components/statsContext.tsx
index d974fe5..b90326e 100644
--- a/src/client/components/statsContext.tsx
+++ b/src/client/components/statsContext.tsx
@@ -1,36 +1,6 @@
-/**
- * The Context card: the session as a flow diagram — the session node center
- * stage, fed by the two source nodes on the left (the user's own inputs and
- * the context events the host applied, broken down by kind) and draining
- * into the two effect nodes on the right (the live tool calls with their
- * most-called ranking, and the Agent Team ledger: the family's billed-token
- * and estimated-cost totals, then one row per scope — the current agent and
- * the subagent subtree, its session count included).
- * Measured bezier ribbons connect the nodes (a wide translucent band plus a
- * traveling dash of the same hue — element flow, not a conservative Sankey):
- * left→right on a wide card, top→bottom once the container query in
- * stats.css folds the three zones (the component flips the geometry at the
- * same width). The subagent share folds out of the session-list snapshot
- * (`makeSubagentCost` below); a harness without the outward sessions service
- * renders the family figure alone.
- *
- * Count figures only: nothing here is part of a spendable whole, so no pie —
- * proportions live in the composition card, the event rows themselves live
- * on the events card (contextView.tsx), and the token figures (the session's
- * cache-hit share among them) live on the Token card. The cost node prices
- * the host-folded cumulative billed totals (complete session logs, never
- * trimmed) from the models.dev price book (modelPrices.ts) in the locale's
- * currency; its hover bubbles (a '?' marker + styled DOM tip) explain each
- * scope and list the per-1M-token rates of the models the family actually
- * billed, straight from the same book (cost.ts), so printed rates can never
- * drift from the math. A book that has not loaded (or failed) dashes the
- * figures and notes the outage.
- *
- * The counts arrive precomputed: the split-generation wire head carries them
- * (shared/types.ts `TimelineCounts` — computed over the retained records),
- * and the caller derives them from the collections on the inline generation
- * (`countsOfRecords`). The card itself never touches the collections.
- */
+/** The Context card: counts only — proportions live on the composition card, the event rows on the events
+ * card, and the token figures on the Token card. The counts arrive precomputed (the split-generation wire
+ * head, or `countsOfRecords` on the inline generation); the card never touches the collections. */
 
 import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactElement, type ReactNode } from 'react'
 import type { ContextEventRecord, ContextTimeline, RequestRecord, SessionCostUsage, SurfaceNode, TimelineCounts } from '../../shared/types'
@@ -45,10 +15,8 @@ import { asRecord, type ClientCtx } from '../services'
 import { isDeepSeekProvider } from '../../shared/providers'
 import type { ViewKit } from '../viewkit'
 
-/** One billed model's tooltip block: the usage key and the registry face its price resolved to. */
 interface PriceRow { key: string; face: PriceFace }
 
-/** The four billed buckets of a price block, in display order, with their label keys. */
 const BANDS: readonly (readonly [keyof PriceFace['rate'], string])[] = [
   ['hit', 'stats.costHit'],
   ['miss', 'stats.costMiss'],
@@ -56,12 +24,6 @@ const BANDS: readonly (readonly [keyof PriceFace['rate'], string])[] = [
   ['out', 'stats.costOut'],
 ]
 
-/**
- * The billed models' price blocks — the usage keys priced against the book,
- * in fold order, each carrying the registry face (models.dev provider id ·
- * model id) its rates resolved from. Hostile branches skip; unpriced models
- * drop (their buckets simply do not contribute).
- */
 function priceRowsOf(usage: SessionCostUsage | undefined, book: ModelBook | null): PriceRow[] {
   if (usage === undefined || book === null) return []
   const rows: PriceRow[] = []
@@ -80,22 +42,16 @@ function priceRowsOf(usage: SessionCostUsage | undefined, book: ModelBook | null
   return rows
 }
 
-/**
- * The inline generation's counter derivation — the exact tally the card ran
- * over the served collections before the split (distinct turn values, record
- * count, per-kind event tallies, the distinct loaded-skill names). The host's
- * split-generation counts match it by construction (fold.ts buildTimelineHead).
- */
+/** The inline generation's counter derivation; the host's split-generation counts
+ * match it by construction (fold.ts buildTimelineHead). */
 export function countsOfRecords(requests: readonly RequestRecord[], events: readonly ContextEventRecord[]): TimelineCounts {
   const turns = new Set()
   for (const req of requests) turns.add(req.turn ?? 0)
   let injects = 0
   let compactions = 0
   let prunes = 0
-  // Loaded skills: the distinct names among the skill-tagged inject events —
-  // the host fold's exact tag set (`sub: 'skill'` on `/name` invocations and
-  // `skill`-tool loads; the available-skills catalog digest rides an untagged
-  // inject event, so it never lands here).
+  // Loaded skills: the distinct names among the skill-tagged inject events — the host fold's exact tag set
+  // (`sub: 'skill'` on `/name` invocations and `skill`-tool loads; the catalog digest rides an untagged event).
   const skills = new Set()
   for (const ev of events) {
     if (ev.kind === 'inject') injects++
@@ -106,29 +62,17 @@ export function countsOfRecords(requests: readonly RequestRecord[], events: read
   return { turns: turns.size, steps: requests.length, injects, compactions, prunes, skills: skills.size }
 }
 
-/**
- * The stats board's subagent-subtree figures: the merged billed-token usage
- * (null = nothing reported yet — no subagents, no usage, or no sessions face
- * on this harness) and the discovered descendant count (cold ones included —
- * a subagent is one whether or not its usage priced).
- */
+/** null usage = nothing reported (no subagents, no usage, or no sessions face on this harness). */
 export interface SubagentStats {
   usage: SessionCostUsage | null
   count: number
 }
 
-/**
- * The stats board's subagent seat: the subtree figures folded from the
- * session-list snapshot's warm rows (`subagentCostFoldOf`) with fetched slim
- * heads standing in for cold relatives.
- */
 export function makeSubagentCost(
   ctx: ClientCtx,
   heads: AgentHeads,
 ): (sessionId: string | undefined) => SubagentStats {
   return function useSubagentCost(sessionId: string | undefined): SubagentStats {
-    // Resolved lazily at mount: a deployment without the outward sessions
-    // service simply prices no subagent cost.
     const face = useMemo(() => sessionsFaceOf(ctx), [])
     const snapshot = useSessionsSnapshot(face)
     const [landed, setLanded] = useState>(new Map())
@@ -136,10 +80,7 @@ export function makeSubagentCost(
       () => subagentCostFoldOf(snapshot, sessionId, landed),
       [snapshot, sessionId, landed],
     )
-    // Fetch every cold descendant's slim head through the shared page-scope
-    // cache; a landed head re-folds the subtree with its usage. Same value →
-    // same state: the identity bail-out keeps a settled replay on every
-    // snapshot tick from looping.
+    // Same value → same state: the identity bail-out keeps a settled replay on every snapshot tick from looping.
     useEffect(() => {
       for (const id of fold.cold) {
         void heads.headOf(id).then((head) => {
@@ -151,37 +92,22 @@ export function makeSubagentCost(
   }
 }
 
-/** The five flow nodes' registry keys, in render order. */
 type FlowNodeKey = 'inputs' | 'events' | 'session' | 'tools' | 'cost'
 
 const FLOW_NODES: readonly FlowNodeKey[] = ['inputs', 'events', 'session', 'tools', 'cost']
 
-/** A node's box relative to the flow root, in px. */
 interface FlowBox { x: number; y: number; w: number; h: number }
 
-/** One link's rendered path and its element color. */
 interface FlowLink { d: string; color: string }
 
-/** An anchor on a box edge: the point plus the outward normal (the curve's tangent direction). */
+/** A box-edge anchor: the point plus the outward normal (the curve's tangent direction). */
 interface Anchor { x: number; y: number; dx: number; dy: number }
 
-/**
- * The narrow-fold width, paired with the `@container lc-card (max-width: …)`
- * rule in stats.css: the container's content box IS the flow root's own width
- * (it fills the card's content box), so the component's `clientWidth` read and
- * the query can never disagree about the mode.
- */
+/** Must match the `@container lc-card (max-width: …)` rule in stats.css: the flow root fills the card's
+ * content box, so the `clientWidth` read and the CSS query can never disagree about the fold mode. */
 const FLOW_FOLD_PX = 559
 
-/**
- * The flow topology: the session node center stage, the two source nodes
- * feeding it and the two effect nodes draining it, each link colored by its
- * element (the token composition palette, categories.ts). The fractional
- * attachments fan the pairs apart on the session node (⅓ and ⅔) so the
- * ribbons merge without stacking; the side nodes attach at their centers.
- * Non-conservative by design — every ribbon is the same width whatever the
- * tally behind it.
- */
+/** The flow topology; non-conservative by design — every ribbon is the same width whatever the tally behind it. */
 const FLOW_LINKS: readonly { from: FlowNodeKey; to: FlowNodeKey; fromAt: number; toAt: number; color: string }[] = [
   { from: 'inputs', to: 'session', fromAt: 0.5, toAt: 1 / 3, color: 'var(--color-green-500)' },
   { from: 'events', to: 'session', fromAt: 0.5, toAt: 2 / 3, color: 'var(--color-purple-500)' },
@@ -189,22 +115,14 @@ const FLOW_LINKS: readonly { from: FlowNodeKey; to: FlowNodeKey; fromAt: number;
   { from: 'session', to: 'cost', fromAt: 2 / 3, toAt: 0.5, color: 'var(--color-pink-500)' },
 ]
 
-/** The events card's kind pills, tinted by its own kind semantics (events.css `.lc-kind-*`). */
 const EVENT_PILLS: readonly { kind: 'inject' | 'compaction' | 'prune'; tally: 'injects' | 'compactions' | 'prunes'; cls: string }[] = [
   { kind: 'inject', tally: 'injects', cls: 'lc-kind-inject' },
   { kind: 'compaction', tally: 'compactions', cls: 'lc-kind-compaction' },
   { kind: 'prune', tally: 'prunes', cls: 'lc-kind-prune' },
 ]
 
-/**
- * The per-tool tally behind the tool card's pills, folded over the LIVE
- * surface with the host's own predicate (fold.ts `toolCalls`: tool-result
- * nodes, plus skill nodes carrying a tool) — the pills' sum matches the
- * head's `toolCalls` figure by construction, minus any result the fold
- * stamped no name on (such a node counts toward the head's figure but has
- * no pill to ride). Sorted by count desc, then name asc for a stable
- * display; hostile shapes skip rather than throw.
- */
+/** Folded over the LIVE surface with the host's own predicate (fold.ts `toolCalls`), so the pills sum to
+ * the head's `toolCalls` figure by construction, minus any result the fold stamped no name on. */
 export function toolTallyOf(nodes: readonly SurfaceNode[]): [string, number][] {
   const tally = new Map()
   for (const n of nodes) {
@@ -215,34 +133,19 @@ export function toolTallyOf(nodes: readonly SurfaceNode[]): [string, number][] {
   return [...tally].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
 }
 
-/** The stats card's props (the factory's return type and the inner component share them). */
 interface StatsContextProps {
-  /** The session-shape tally (host-precomputed on the split generation). */
   counts: TimelineCounts
-  /** The whole-session human-input tally (the user's messages + question answers; absent on older hosts). */
   humanInputs?: number
-  /** The whole-session answers tally — assistant messages carrying text (absent on older hosts/rows). */
   answers?: number
-  /**
-   * The second-row figure cells' jump: 'skills' opens the Context Browser's
-   * skill-injection section, 'answers' its assistant section filtered to
-   * answers (absent — e.g. a host page without the browser — the cells stay
-   * inert).
-   */
   onFigureClick?: (figure: 'skills' | 'answers') => void
-  /** Tool calls with a result live in the current context (absent on older hosts). */
   toolCalls?: number
-  /** The whole-session file-op tallies (op counts by kind), for the I/O card's pills. */
   files: { reads: number; writes: number; searches: number; images: number }
-  /** The live-surface per-tool call tally (`toolTallyOf`), count-desc. */
   tools: readonly (readonly [string, number])[]
   cost?: SessionCostUsage
   locale: string
-  /** The current session id, anchoring the subagent-cost fold (absent = nothing to fold). */
   sessionId?: string
 }
 
-/** The anchor at fraction `at` along one box edge, with the edge's outward normal. */
 function flowAnchor(box: FlowBox, side: 'left' | 'right' | 'top' | 'bottom', at: number): Anchor {
   switch (side) {
     case 'left': return { x: box.x, y: box.y + box.h * at, dx: -1, dy: 0 }
@@ -252,26 +155,14 @@ function flowAnchor(box: FlowBox, side: 'left' | 'right' | 'top' | 'bottom', at:
   }
 }
 
-/**
- * The Sankey-flavored connector between two anchors: a cubic whose tangents
- * leave along the source anchor's outward normal and enter AGAINST the
- * target's (the end control point sits at `b + b.normal·k`, so the path
- * arrives moving into the node), the control distance half the axis gap
- * (floored so near neighbors still bow visibly).
- */
+/** Cubic whose end control point sits at `b + normal·k`, so the path arrives moving INTO the node. */
 export function flowCurve(a: Anchor, b: Anchor): string {
   const k = Math.max(18, (a.dx !== 0 ? Math.abs(b.x - a.x) : Math.abs(b.y - a.y)) / 2)
   return `M ${a.x} ${a.y} C ${a.x + a.dx * k} ${a.y + a.dy * k} ${b.x + b.dx * k} ${b.y + b.dy * k} ${b.x} ${b.y}`
 }
 
-/**
- * All four links' paths over the measured node boxes. Horizontal mode flows
- * left→right; the narrow fold stacks the three zones and flows top→bottom —
- * same DOM, the stats.css container query flips the layout at FLOW_FOLD_PX
- * and the caller passes the matching orientation. Sources always attach on
- * their trailing edge, targets on their leading one (the session node is a
- * target of the left/top pair and the source of the right/bottom pair).
- */
+/** Sources attach on their trailing edge and targets on their leading one: the session node is a target of
+ * the left/top pair and the source of the right/bottom pair, so one orientation pair serves both modes. */
 export function measureFlow(boxes: Record, horizontal: boolean): FlowLink[] {
   const fromSide = horizontal ? 'right' as const : 'bottom' as const
   const toSide = horizontal ? 'left' as const : 'top' as const
@@ -289,11 +180,6 @@ export function makeStatsContext(
   return function StatsContext(props: StatsContextProps): ReactElement {
     const currency: CostCurrency = props.locale === 'zh' ? 'cny' : 'usd'
     const { book, failed } = useModelPrices()
-    // Both cost figures price the same host-folded cumulative totals, at one
-    // scope each: the family total (the current agent's own usage plus every
-    // subagent session's) in the cost card's header, the subagents' share
-    // alone in the split pill. The subtree's session count rides the session
-    // node's third figure.
     const sub = useSubagentCost(props.sessionId)
     const subUsage = sub.usage
     const usage = mergeCostUsage(props.cost, subUsage) ?? undefined
@@ -302,21 +188,12 @@ export function makeStatsContext(
     const fmtRate = (usd: number): string => formatPriceRate(toCurrency(usd, currency), currency)
     const rows = priceRowsOf(usage, book)
     const subRows = priceRowsOf(subUsage ?? undefined, book)
-    // DeepSeek's peak/off-peak scheme is explained only when the tip's own
-    // scope actually billed a DeepSeek provider — other sessions see nothing
-    // of it.
     const deepseek = usage !== undefined && Object.keys(usage).some(p => isDeepSeekProvider(p))
     const subDeepseek = subUsage !== null && Object.keys(subUsage).some(p => isDeepSeekProvider(p))
-    // Usage folded but nothing priced (the book has not loaded, or carries
-    // none of this scope's models): say so instead of a bare dash.
+    // Folded usage with no priced row (book not loaded, or it carries none of this scope's models): say so instead of a bare dash.
     const unpriced = rows.length === 0 && usage !== undefined && Object.keys(usage).length > 0
       && (failed || book !== null)
     const subUnpriced = subRows.length === 0 && subUsage !== null && (failed || book !== null)
-    // One scope's price table: the billed buckets' rates at the book's list
-    // per model — a zero list price carries no information, so its band drops
-    // (free/token-plan listings keep only their listing line) — each block
-    // closed by the listing line naming the registry face (provider id ·
-    // model id) the rates resolved from.
     const pricesBlock = (blocks: PriceRow[]): ReactNode =>
       blocks.length > 0 ? (
         
@@ -335,8 +212,6 @@ export function makeStatsContext(
           ))}
         
       ) : null
-    // The shared footnotes: the CNY conversion note in the CNY display, and
-    // DeepSeek's peak-window scheme when the scope billed a DeepSeek provider.
     const notes = (deep: boolean): ReactElement[] =>
       [
         currency === 'cny' ? {t('stats.costTipCny')} : null,
@@ -349,8 +224,6 @@ export function makeStatsContext(
       ...notes(deepseek),
       unpriced ? {t('stats.costUnavailable')} : null,
     ]
-    // The subagents' own share: scope explanation, its own price table, then
-    // the outage note when the subagents' models priced against nothing.
     const subTip: ReactNode = [
       t('stats.subCostTip'),
       {t('stats.billedTip')},
@@ -358,10 +231,8 @@ export function makeStatsContext(
       ...notes(subDeepseek),
       subUnpriced ? {t('stats.costUnavailable')} : null,
     ]
-    // The five nodes' measured boxes drive the connector overlay: a no-deps
-    // layout effect re-measures after every render (a same-string bail-out
-    // keeps it loopless), and a ResizeObserver tick covers pane drags that
-    // change no prop.
+    // A no-deps layout effect re-measures after every render (a same-string bail-out keeps it loopless); the
+    // ResizeObserver tick covers pane drags, which change no prop.
     const flowRef = useRef(null)
     const nodeEls = useRef(new Map())
     const [links, setLinks] = useState([])
@@ -396,10 +267,6 @@ export function makeStatsContext(
       return () => { observer.disconnect() }
     }, [])
     /* v8 ignore stop */
-    // One card's header: the title (with the '?' marker when a tip rides)
-    // left and the bold total right — as a div, or as an anchor opening the
-    // models.dev provider listing in a new tab when the caller hands a
-    // destination (the tooltip still frames and reveals off this same row).
     const head = (label: string, total: ReactNode, tip?: ReactNode, href?: string): ReactElement => {
       const body = (
         <>
@@ -416,10 +283,6 @@ export function makeStatsContext(
         ? 
{body}
: {body} } - // One pill: the label over the bold tally, tinted when the caller carries - // a kind class, dimmed on a zero count (the card's total already reads), - // '?'-tipped when a tip rides. The label keys the pill — unique within - // every card's row. const pill = (label: string, value: number, cls = '', tip?: ReactNode): ReactElement => ( @@ -430,9 +293,6 @@ export function makeStatsContext( {tip !== undefined && {tip}} ) - // One second-row figure cell: the bold tally over its label, a jump into - // the Context Browser's matching section on click/Enter (the team card's - // role=button + keydown idiom). Inert when the caller mounts no browser. const onFigureClick = props.onFigureClick const figure = (key: 'skills' | 'answers', value: number, label: string, tip: string): ReactElement => ( {label} ) - // The cost card links to the listing when ONE models.dev provider priced - // the whole scope — the natural "check these rates" destination. A - // multi-provider scope names each face in the tooltip instead and stays - // unlinked. const costPids = new Set(rows.map(r => r.face.pid).filter(p => p !== '')) const costHref = costPids.size === 1 ? 'https://models.dev/providers/' + [...costPids][0] + '/' : undefined const ownCost = estimateSessionCost(props.cost, book, currency) - // The team ledger's token faces, off the same billed buckets the figures - // price: the family's total, the current agent's own, the subagents'. const familyTokens = billedTokensOf(usage) const ownTokens = billedTokensOf(props.cost) const subTokens = billedTokensOf(subUsage) - // One scope's token/cost figure pair — the ledger rows' right side. const pair = (tokens: number, costText: string): ReactElement => ( {fmt(tokens)}/{costText} ) const costText = cost === null ? '—' : formatCost(cost, currency) const ownText = ownCost === null ? '—' : formatCost(ownCost, currency) const subText = subCost === null ? '—' : formatCost(subCost, currency) - // The card's namesake sits at the tab's foot (the Agent network card): - // a click anywhere OUTSIDE the price link scrolls it into view (a quiet - // no-op when the harness hides that card — no sessions service — or the - // page itself cannot scroll, revealScroll.ts). The price link and the - // '?' hover tips keep their own behavior untouched. const revealAgents = (): void => { const agents = flowRef.current?.closest('.lc-root')?.querySelector('.lc-agents') ?? null if (agents !== null) revealInScrollParent(agents) @@ -489,11 +337,8 @@ export function makeStatsContext( ev.preventDefault() revealAgents() } - // The I/O card's total: the user's own inputs plus every file op. const ioTotal = (props.humanInputs ?? 0) + props.files.reads + props.files.writes + props.files.searches + props.files.images - // The tool card: the head's live tally figure (the tools' own tally sums - // as the fallback on hosts too old to carry it), the three most-called - // tools as pills, and an overflow pill counting the rest. + // The head's live tally; the pills' own sum is the fallback on hosts too old to carry it. const toolTotal = props.toolCalls ?? props.tools.reduce((sum, [, n]) => sum + n, 0) const topTools = props.tools.slice(0, 3) const moreTools = props.tools.length - topTools.length @@ -503,12 +348,10 @@ export function makeStatsContext( {t('stats.title')}
- {/* The connector overlay: under the nodes (they carry z-index), in - flow order so the traveling dashes read left→right / top→bottom. */} + {/* Under the nodes; ordered left→right / top→bottom so the traveling dashes read in flow order. */}
{head(t('stats.io'), fmt(ioTotal))} - {/* Searches and image reads pill only when they happened — two - permanently dimmed pills are noise, these three zero honestly. */} + {/* Searches and image reads pill only when they happened; the three zero counts stay visible. */}
{pill(t('stats.humanInputs'), props.humanInputs ?? 0, '', t('stats.humanInputsTip'))} {pill(t('files.kind.read'), props.files.reads)} @@ -576,10 +418,6 @@ export function makeStatsContext( onKeyDown={onTeamKeyDown} > {head(t('agents.title'), pair(familyTokens, costText), costTip, costHref)} - {/* The team ledger: one row per scope — the current agent itself, - then the subagent subtree (its session count from the same - fold). A scope with nothing billed reads 0 tokens / a dashed - cost (the price book's outage note lives in the tips). */}
{t('stats.currentAgent')} diff --git a/src/client/components/statsTiming.tsx b/src/client/components/statsTiming.tsx index 0607950..aa97af2 100644 --- a/src/client/components/statsTiming.tsx +++ b/src/client/components/statsTiming.tsx @@ -1,5 +1,4 @@ -/** - * The Timing card: where the session's ACTIVE time went. The donut splits +/** The Timing card: where the session's ACTIVE time went. The donut splits * whole-step wall time into the model-call slices — TTFT (step start → first * token, the wait) and LLM generation (first token → assistant message) — * and the tool-execution slice, with the residue as overhead; a call whose @@ -18,8 +17,7 @@ * row numbers stay true. Below them the session-time strip packs every * completed step's slices gapless in occurrence order (the DNA idiom applied * to time consumed — each band's width is its share of the cumulative active - * time, idle time takes no track), joining the same hover link by KIND. - */ + * time, idle time takes no track), joining the same hover link by KIND. */ import { useState, type ReactElement } from 'react' import type { TimingSpan, TimingTotals } from '../../shared/types' @@ -44,10 +42,8 @@ interface Slice { /** The card's slice palette (timingStrip.tsx owns it — the strip paints the same kinds). */ const COLOR = TIMING_COLOR -/** - * The decode-throughput figure, formatted exactly as the harness's own - * session-stats seat formats it: whole tokens from ten up, one decimal below. - */ +/** The decode-throughput figure, formatted exactly as the harness's own + * session-stats seat formats it: whole tokens from ten up, one decimal below. */ function formatTokensPerSecond(tps: number): string { const clamped = Math.max(0, tps) return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10) @@ -55,11 +51,9 @@ function formatTokensPerSecond(tps: number): string { export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => ReactElement): (props: { timing: TimingTotals | null - /** - * The timing strip's painted spans (the detail channel's per-step time + /** The timing strip's painted spans (the detail channel's per-step time * slices). Absent on the overview's cross-session aggregate card and on - * rows folded before the collection existed — the card then shows no strip. - */ + * rows folded before the collection existed — the card then shows no strip. */ spans?: TimingSpan[] }) => ReactElement { const { t, fmt, fmtDuration, fmtShare } = kit @@ -72,8 +66,7 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac const wall = timing !== null && Number.isFinite(timing.wallMs) && timing.wallMs > 0 ? timing.wallMs : 0 // The throughput figure: the host paired provider output tokens with // decode windows exactly as the harness session-stats fold does, so the - // two figures can never disagree. An unreadable or absent pairing (an - // older cached row) renders no figure. + // two figures can never disagree. An unreadable or absent pairing (an older cached row) renders no figure. const tps = timing !== null && typeof timing.speedMs === 'number' && Number.isFinite(timing.speedMs) && timing.speedMs > 0 && typeof timing.speedTokens === 'number' && Number.isFinite(timing.speedTokens) && timing.speedTokens >= 0 @@ -94,14 +87,12 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac // alone, and a log whose stream carried token deltas but no block markers // (all-zero buckets) keeps the un-split shape instead of three dead rows. // An absent bucket reads as 0 (the host omits a zero span). - // // These slices qualify with their own BLOCK tally, never the call // count: one model call emits zero to many decode blocks (a single call // routinely requests several tools, and most calls emit no reasoning at // all), so the call count would read as a per-slice tally that is simply // untrue. TTFT and tools keep theirs — those happen exactly once per - // call/run. A host that folded no counts (an older cached row) serves - // the duration alone. + // call/run. A host that folded no counts (an older cached row) serves the duration alone. const blockTimes = (blocks?: number): string | undefined => blocks !== undefined && blocks > 0 ? t('timing.blockTimes', { n: fmt(blocks) }) : undefined const buckets: [key: string, color: string, label: string, ms: number, times?: string][] = [ @@ -139,8 +130,7 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac // into [0, wall], so the ratio needs no further bounding. const share = (ms: number): number => (wall > 0 ? ms / wall : 0) // The secondary line leads with the duration and qualifies it with the - // call count; a zero-duration slice keeps just the count (its dash has - // nothing to qualify). + // call count; a zero-duration slice keeps just the count (its dash has nothing to qualify). const countOf = (ms: number, times?: string): string => { const dur = fmtDuration(ms) if (times === undefined) return dur @@ -174,8 +164,7 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac // a "0.0%" row still carrying a count ("— · 323 calls") reads as "this // ran 323 times in no time" when in truth nothing ever ran or decoded // there. The ring already skips zero arcs, so the legend now matches it. - // Every slice is a share of the wall total, so dropping rows never - // distorts the remaining percentages. + // Every slice is a share of the wall total, so dropping rows never distorts the remaining percentages. rows = [ ...modelSlices.map(toRow), toRow(toolSlice), diff --git a/src/client/components/statsTokens.tsx b/src/client/components/statsTokens.tsx index 5bd9468..5b1065e 100644 --- a/src/client/components/statsTokens.tsx +++ b/src/client/components/statsTokens.tsx @@ -1,5 +1,4 @@ -/** - * The Token card: the session's whole billed token usage — the SAME total +/** The Token card: the session's whole billed token usage — the SAME total * the harness chat stats line shows under the composer (uncached input + * cache read + cache write + output off the official `tokenUsage` * projection) — split by WHAT the tokens are, not by how the provider @@ -12,8 +11,7 @@ * exact. Zero parts stay hidden once any usage is reported; the empty state * (no provider report yet) keeps all seven rows behind a dash center. The * title row's right corner carries the session's cache-hit share — the harness - * chat line's own reads-over-billed formula — hidden until something was billed. - */ + * chat line's own reads-over-billed formula — hidden until something was billed. */ import { useState, type ReactElement } from 'react' import type { ContextBreakdown, Snapshot, TokenUsage } from '../../shared/types' @@ -61,8 +59,7 @@ export function makeStatsTokens(kit: ViewKit, Donut: (props: DonutProps) => Reac color: p.color, label: p.key === 'output' ? t('tokens.output') : catLabel(p.key), pct: fmtShare(p.value, total), - // The six prompt-side shares are ratio estimates (≈, the composition - // card's convention); output alone is provider-exact. + // The six prompt-side shares are ratio estimates (≈, the composition card's convention); output alone is provider-exact. count: p.key === 'output' ? `${fmt(p.value)} · ${t('tokens.outputNote')}` : '≈' + fmt(p.value), })) return ( diff --git a/src/client/components/timingStrip.tsx b/src/client/components/timingStrip.tsx index 30713be..18d5289 100644 --- a/src/client/components/timingStrip.tsx +++ b/src/client/components/timingStrip.tsx @@ -100,8 +100,7 @@ export function makeTimingStrip(kit: ViewKit): (props: { }) const hoverKey = props.hoverKey const hovering = hoverKey !== null && hoverKey !== undefined && bands.some(b => b.kind === hoverKey) - // The tip centers on its band's rendered span, pinned 10% off both edges - // so a hairline band's bubble stays inside the card. + // The tip centers on its band's rendered span, pinned 10% off both edges so a hairline band's bubble stays inside the card. let tip: { text: string; leftPct: number } | null = null for (const b of bands) { if (b.key === tipKey) { diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx index c58463b..a6d0da1 100644 --- a/src/client/components/trendChart.tsx +++ b/src/client/components/trendChart.tsx @@ -1,10 +1,3 @@ -/** - * Bespoke per-request history chart — no shared data-viz primitive — styled through the shared `--dsw-alias-*` tokens; helpers - * aggregateByTurn/attachMarkers are shared with ContextView. On mount the NEWEST bars rise from their baseline, - * staggered left to right (trendChart.css, `--lc-i` slots below): only the columns a pane can reach, so a long - * log's entrance never animates thousands of bars nobody can see. - */ - import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement, type UIEvent } from 'react' import type { Category, ContextEventRecord, RequestRecord } from '../../shared/types' import { CATS } from '../categories' @@ -13,15 +6,10 @@ import type { DnaDelta, TrendBand } from '../dna' import { containHorizontalOverscroll } from '../overscroll' import type { ViewKit } from '../viewkit' -/** The entrance-rise utility pair (a reduced-motion-guarded transform sweep), shared by every bar interior. */ const RISE_CLASS = ' animate-lc-bar-in motion-reduce:animate-none' -/** - * The mount-time rise window: only the NEWEST `RISE_CAP` columns play the grow-in. The chart mounts anchored - * to the newest bars, so the columns a pane can show are a suffix of the log — 200 columns cover a 3200px-wide - * chart, wider than any pane — while animating everything left of the window was invisible work whose cost grew - * with the session (a thousand per-bar transform animations layerize the whole chart and stall the card's open). - */ +/** Only the NEWEST `RISE_CAP` columns play the grow-in: the chart mounts anchored to the newest bars, and animating + * everything left of the window layerizes the whole chart and stalls the card's open. */ export const RISE_CAP = 200 export interface TrendChartProps { @@ -33,34 +21,14 @@ export interface TrendChartProps { granularity: 'step' | 'turn' mode: 'total' | 'delta' focusTurn: number | null - /** Mirrored category hover (shared with the overview and the browser): lights that category's segment in every bar. */ hoverCat: string | null - /** - * The browser's open category: every bar plots only that category's fold figure, with the axis rescaled - * to its own max. Null plots every category; an unrecognized key (a stale or hostile state) degrades to the - * unfocused chart. - */ + /** An unrecognized key (a stale or hostile state) degrades to the unfocused chart. */ focusCat?: string | null - /** - * Adaptive scale (the trend card's title-adjacent toggle): the axis is recomputed from the bars currently - * VISIBLE in the scroller and follows the scroll, so a spike far outside the window cannot flatten the bars - * on screen. Off = the whole retained log scales the axis, the historical behavior. - */ adaptive?: boolean - /** - * Duration overlay (the trend card's title-adjacent toggle): each bar's step active time (`activeMs`) - * drawn as a curve over the bars, read off the quartile axis on the chart's right. The curve plots the - * raw figure in every mode (total/delta/DNA) — duration is not a composition category — and follows the - * same scale window as the bars (whole log, or the visible window when adaptive is on). - */ durationCurve?: boolean - /** - * DNA mode (the trend card's toggle): per bar, its assembled context decomposed into ONE band per item in - * read order (dna.ts), aligned with `requests` by index. Non-null draws every bar as a single-gradient - * fingerprint and implies TOTAL semantics — the parent disables the Total/Delta switch while DNA is on. - */ + /** DNA mode: per bar, its assembled context decomposed into one band per item in read order (dna.ts), aligned + * with `requests` by index. */ dna?: TrendBand[][] | null - /** DNA mode: a band click reveals that item in the Context browser at the bar's step. */ onPickBand?: (seq: number, band: { key: string; cat: Category | 'system' | 'tools' }) => void onSelect: (seq: number | null) => void onHover: (seq: number | null) => void @@ -69,11 +37,7 @@ export interface TrendChartProps { onFocusTurnHandled: () => void } -/** - * Collapse per-step requests into one bar per turn — each turn is represented by its LAST step's record, tagged `stepCount` for the bar's - * column width; the log keeps one turn's requests consecutive, so a run of equal turns collapses to its final record. The duration - * overlay's `activeMs` is the run's steps SUMMED (the turn's total step active time), absent only when no step of the run carried one. - */ +/** One bar per turn: a run of equal turns collapses to its LAST record (tagged `stepCount`), its `activeMs` the steps' SUM. */ export function aggregateByTurn(requests: RequestRecord[]): RequestRecord[] { const out: RequestRecord[] = [] let runSteps = 0 @@ -95,11 +59,7 @@ export function aggregateByTurn(requests: RequestRecord[]): RequestRecord[] { return out } -/** - * The per-turn step tallies the step-granularity labels lean on ("第 s 步 (共 n 步)") — the same count a - * turn-mode bar's `stepCount` carries. Records without a turn stamp pool under 0, the key the labels' - * `turn ?? 0` fallback reads; the getter answers 1 for a turn outside the list so a caller can never miss. - */ +/** Records without a turn stamp pool under 0; the getter answers 1 for a turn outside the list so a caller can never miss. */ export function turnStepsOf(requests: RequestRecord[]): (turn: number | undefined) => number { const counts = new Map() for (const req of requests) { @@ -109,10 +69,8 @@ export function turnStepsOf(requests: RequestRecord[]): (turn: number | undefine return turn => counts.get(turn ?? 0) ?? 1 } -/** - * Attach each boundary event (compaction/prune) to the first request logged after it — one entry per index, for the ✂ marker and the detail - * chip; shared with the detail panel so both show the SAME event. - */ +/** Each boundary event (compaction/prune) attaches to the first request logged after it; shared with the detail + * panel so both show the SAME event. */ export function attachMarkers(requests: RequestRecord[], events: ContextEventRecord[]): (ContextEventRecord | undefined)[] { const markers: (ContextEventRecord | undefined)[] = new Array(requests.length) for (const ev of events) { @@ -127,11 +85,8 @@ export function attachMarkers(requests: RequestRecord[], events: ContextEventRec return markers } -/** - * The chat→Context jump's target: the turn bar whose closing reply the user clicked — the relayed seq is a turn's LAST step, exactly the - * aggregate's record — or, when that turn has aged out of the host's retained window, the oldest retained bar. Resolved against turn - * aggregates, since the jump pins in turn granularity. Null only on an empty history. - */ +/** The chat→Context jump's target: the turn bar whose closing reply was clicked (the relayed seq is a turn's LAST + * step), or the oldest retained bar once that turn aged out. Null only on an empty history. */ export function jumpTargetOf(requests: RequestRecord[], seq: number): RequestRecord | null { for (const req of requests) if (req.seq === seq) return req return requests.length > 0 ? requests[0] : null @@ -141,49 +96,32 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE const { t, fmt, fmtDuration, eventLabel, eventAt, catLabel } = kit const CHART_H = 112 - // Quarter-mark label tops for the axis (mirrored to .lc-axis-q1/.lc-axis-q3 in trendChart.css): chart top 18 - // plus a quarter/three-quarters of the 112px bar area, minus half the 11px label box (font-size 11, line-height 1). + // Mirrored by .lc-axis-q1/.lc-axis-q3 in trendChart.css: chart top 18 + a quarter/three-quarters of the 112px bar + // area − half the 11px label box. const Q3_TOP = 41 const Q1_TOP = 97 - // Delta axis ticks are signed: '+' only on positives — fmt already carries the minus for negatives. const fmtSigned = (v: number): string => (v > 0 ? '+' : '') + fmt(v) - // Constant bar width: sparse histories don't stretch bars, dense ones scroll instead of compressing; the turn strip below mirrors the - // same column grid. + // Constant bar width (the turn strip below mirrors the same column grid): dense histories scroll instead of compressing. const BAR_W = 14 const BAR_GAP = 2 - // Entrance stagger cap: the rise window's delay stops widening after this many columns (`--lc-i`), so the - // cascade settles quickly however far past the window the log runs. const STAGGER_CAP = 20 - // Step flags: every 5th step bar plants one at its left edge, labeled with its cumulative step number - // (5, 10, 15, …) — the chart's only position landmark in step granularity (the turn strip numbers turns). const STEP_FLAG_EVERY = 5 - // Neutral zebra, deliberately DISJOINT from the category palette — the strip must read as a partition layer, not a bottom segment of the - // composition bars. + // Neutral zebra, deliberately DISJOINT from the category palette: the strip is a partition layer, not a segment of the bars. const TURN_FILLS = [ 'color-mix(in srgb, var(--color-neutral-500) 12%, transparent)', 'color-mix(in srgb, var(--color-neutral-500) 26%, transparent)', ] - // Turn labels render at natural width (a 2-digit "12" is wider than a 14px turn bar) and overflow their block. - // Every label must stay on the single line, so the strip shrinks ALL labels to one font size — the largest at - // which the tightest adjacent pair still clears the gap (analytic widths below, no measurement) — and the - // measured chain in updateTurnLabels stays only as the last-resort guard past the floor. OVERHANG bounds how - // far a label can reach beyond its block (a generous read of "9999" at 10px) for the viewport-participation - // test; GAP is the breathing room between adjacent labels' boxes. + // Labels render at natural width and overflow their block: the pair-gap sizing below shrinks ALL of them uniformly, and + // updateTurnLabels' measured chain is the last-resort guard. OVERHANG bounds a label's reach; GAP is the box gap. const LABEL_OVERHANG = 48 const LABEL_GAP = 2 - // Label font sizing (the 10px base mirrors .lc-turn in trendChart.css): conservative upper-bound glyph width - // at the base size (6.5px per digit at 10px semibold), floored at 6px. + // 10px base mirrors .lc-turn in trendChart.css; 6.5px per digit is a conservative upper bound at that size, floored at 6px. const LABEL_FONT = 10 const LABEL_FONT_MIN = 6 const estTurnLabel = (turn: number): number => 6.5 * String(turn).length - /** - * Focus a bar on one category (the browser's open category): the kept bucket carries its fold figure, the other - * buckets zero, and `total` IS the plotted figure — the downstream stack/tooltip math reads the derived record - * unchanged. The raw fold figures are plotted as-is: a per-request rescale against the provider prompt would drift - * with the heuristic's ratio error and fake growth into constant categories (a never-changing system prompt must - * plot flat). - */ + /** Focus one category: the kept bucket carries its fold figure and `total` IS the plotted figure. Raw fold figures + * are plotted as-is — rescaling against the provider prompt would drift with the heuristic's error and fake growth. */ const focusOf = (req: RequestRecord, cat: string): RequestRecord => { const key = cat as Category | 'system' | 'tools' const out: RequestRecord = { ...req } @@ -193,12 +131,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE return out } - /** - * Delta mode: each category keeps the SIGNED change vs the previous record so bars can diverge - * above/below the zero line; `total` is the churn (summed magnitude), `net` the signed change - * for the tooltip; the first request starts from zero so the scale is change-driven, and per-request - * provider prompt/output are dropped (they are not deltas). - */ + /** Delta mode: each category keeps the SIGNED change vs the previous record; `total` is the churn (summed magnitude) + * and `net` the signed change for the tooltip. Provider prompt/output are dropped — they are not deltas. */ const deltaOf = (req: RequestRecord, prev: RequestRecord | null): RequestRecord => { const { prompt: _prompt, output: _output, ...out } = req let churn = 0 @@ -214,7 +148,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE return out } - /** The visible window's own maxima (adaptive scale): the total-mode peak, the delta arms' up/down sums, the duration overlay's peak. */ interface VisibleMax { total: number up: number @@ -229,60 +162,38 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE hovered: boolean inTurn: boolean maxTotal: number - /** - * Delta mode geometry: zero-line offsets in px (up from the top / down from the bottom of the bar area) - * and the uniform px-per-token scale — identical above and below the zero line, so a +n segment and a - * −n segment always draw the same height. All three absent in total mode; passed as PRIMITIVES so the - * memoized bar keeps its shallow-compare bailout. - */ + /** Delta-mode geometry as PRIMITIVES (the memoized bar's shallow-compare bailout): zero-line offsets (px) and + * one px-per-token scale for both arms. */ upPx?: number downPx?: number deltaScale?: number - /** Bar index WITHIN the rise window (0 at its oldest column): the grow-in stagger slot, capped inside. */ enterIndex: number - /** Whether this bar plays the entrance rise at all — false for the columns left of the RISE_CAP window. */ rise: boolean - /** The step flag's label (the bar's cumulative step number), or null to plant none. */ flag: number | null - /** DNA mode: this bar's per-item bands (read order), or null in the stacked modes. */ dna: TrendBand[] | null - /** DNA+delta mode: this bar's signed band deltas (a stable memo element); null elsewhere. */ dnaDelta: DnaDelta | null - /** DNA+delta mode: the uniform px-per-token scale and the zero line's offset from the floor. */ dnaScale: number | undefined zeroBottom: number | undefined - /** DNA mode: reports the band under the pointer (null when the pointer leaves it or rests on no band). */ onDnaHit: (key: string | null) => void - /** DNA mode: a band click asks the owner to reveal the item in the Context browser. */ onPickBand?: (seq: number, band: { key: string; cat: Category | 'system' | 'tools' }) => void onSelect: (seq: number | null) => void onHover: (seq: number | null) => void } - /** - * DNA mode's bar interior: ONE gradient div paints the whole fingerprint — consecutive same-color - * bands coalesce into a single run (long same-category message runs would otherwise bloat the - * style string), zero-token bands occupy no height and skip. Gradient stops are each band's - * cumulative share of the bar (percent of the div's own height, which is proportional to the - * bar's total), so the strip reads bottom-up in the model's read order. - */ + /** DNA bar interior: ONE gradient div, same-color bands coalesced (long runs would bloat the style string) and + * zero-token bands skipped. */ interface DnaBarProps { bands: TrendBand[] total: number maxTotal: number enterIndex: number - /** Whether this bar plays the entrance rise (see ChartBarProps.rise). */ rise: boolean - /** The bar's request seq: a band pick reveals the item at THIS step in the Context browser. */ seq: number onHit: (key: string | null) => void - /** A band click's reveal request, lifted to the chart's owner (undefined = picking disabled). */ onPick?: (seq: number, band: TrendBand) => void } const DnaBar = function DnaBar(props: DnaBarProps): ReactElement | null { - // Zero-token bands occupy no height: they keep their read-order slot (the hit-test below - // walks the full list) but drop out of the gradient's runs entirely. const visible: TrendBand[] = [] for (const b of props.bands) if (b.tokens > 0) visible.push(b) if (visible.length === 0) return null @@ -294,11 +205,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE if (last !== null && last.color === b.color && last.to === from) last.to = to else runs.push({ color: b.color, from, to }) } - // Pointer → band: the fraction measured from the div's bottom maps linearly onto [0, total] - // tokens; the hit is the LAST band whose start is at or below the position (zero-token bands - // are invisible and skip), so the top edge still resolves to the last band. A hit always - // exists — the first visible band starts at off 0 (leading zero-token bands advance nothing) - // and the fraction clamps into [0, 1] — so the type carries no null. + // A hit always exists (the first visible band starts at off 0; the fraction clamps), so the type carries no null. const hitAt = (e: { clientY: number; currentTarget: HTMLDivElement }): TrendBand => { const rect = e.currentTarget.getBoundingClientRect() const frac = rect.height > 0 ? Math.min(1, Math.max(0, 1 - (e.clientY - rect.top) / rect.height)) : 0 @@ -321,30 +228,18 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE onMouseMove={(e) => { props.onHit(hitAt(e).key) }} onMouseLeave={() => { props.onHit(null) }} onClick={(e) => { - // A band click reveals the item in the Context browser (and the bar's own click - // bubbles up to pin this step — the two land on the same seq). if (props.onPick !== undefined) props.onPick(props.seq, hitAt(e)) }} /> ) } - /** - * DNA + DELTA mode's bar interior: ONE gradient div spans BOTH arms around the zero line — - * from the div's bottom, the down arm's bands in REVERSE read order (the first removed item - * hugs the zero line from below), then the up arm in read order. Like the segmented delta - * arms the div is absolutely positioned off the zero line and scaleY-opens FROM it - * (transform-origin at the zero line's share of the div's own height). A hit always exists: - * every position maps into one of the arms' read-order walks. - */ + /** DNA+delta bar interior: ONE gradient div spans both arms — the down arm's bands in REVERSE read order, then the up arm. */ interface DnaDeltaBarProps { d: DnaDelta - /** Uniform px-per-token scale (identical above and below the zero line). */ scale: number - /** The zero line's offset from the bar's floor (px). */ zeroBottom: number enterIndex: number - /** Whether this bar plays the entrance rise (see ChartBarProps.rise). */ rise: boolean seq: number onHit: (key: string | null) => void @@ -358,8 +253,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE for (const b of props.d.down) downSum -= b.tokens const gradSpan = upSum + downSum if (gradSpan === 0) return null - // Gradient runs: bottom-up = down arm reversed, then up arm; consecutive same-color bands - // coalesce exactly like the total-mode strip. const runs: { color: string; from: number; to: number }[] = [] const pushRun = (color: string, mag: number, at: number): number => { const from = Math.round(at / gradSpan * 10000) / 100 @@ -374,8 +267,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE for (const b of props.d.up) acc = pushRun(b.color, b.tokens, acc) const height = Math.max(1, Math.round(gradSpan * props.scale)) const hitAt = (e: { clientY: number; currentTarget: HTMLDivElement }): { key: string; cat: Category | 'system' | 'tools' } => { - // Fraction from the div's bottom maps onto [-downSum, upSum] around the zero line; each arm - // then walks its read order for the last band whose start is at or below |position|. const rect = e.currentTarget.getBoundingClientRect() const frac = rect.height > 0 ? Math.min(1, Math.max(0, 1 - (e.clientY - rect.top) / rect.height)) : 0 const pos = frac * gradSpan - downSum @@ -408,16 +299,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE ) } - /** - * DNA mode's cross-bar lifetime highlight: a flex row mirroring the bar grid (same 14px columns, - * 2px gap, 18px top band — no per-bar x math), painting ONE translucent slice per bar that still - * holds the hovered item — its whole life in the context at a glance. The chart resolves every - * slice's geometry (both DNA flavors) so this layer stays a dumb painter; memoized so a hover - * change reconciles only this thin layer, never the memoized bars beneath. Null while no item - * is hovered. - */ + /** Cross-bar lifetime highlight: a flex row mirroring the bar grid, one slice per bar still holding the hovered item; memoized. */ interface DnaHighlightsProps { - /** Per bar, the hovered item's slice in chart geometry — null entries paint nothing. */ slices: ({ bottom: number; height: number } | null)[] | null } @@ -436,18 +319,13 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE ) }) - // Memoized so a hover/selection change re-renders only the bars whose flags flipped — the retained log renders in full (thousands of - // nodes on long sessions); `req`/`marker` keep stable identities because the parent memoizes its aggregation, so the default shallow - // compare suffices. + // Memoized so a hover/selection change re-renders only the bars whose flags flipped (the retained log renders in full); + // `req`/`marker` keep stable identities because the parent memoizes its aggregation, so the default shallow compare suffices. const ChartBar = memo(function ChartBar(props: ChartBarProps): ReactElement { const { req, marker } = props const markerAt = marker !== undefined ? eventAt(marker) : null - // Delta mode: diverging stacks — positive category deltas pile UP from the zero line, negative ones - // hang DOWN from it, both in category colors (direction carries the sign, color the category). const diverge = props.upPx !== undefined && props.downPx !== undefined && props.deltaScale !== undefined - // Rise stagger slot, shared by the total stack and both delta arms (trendChart.css scaleY-opens them). const enterStyle = { '--lc-i': Math.min(props.enterIndex, STAGGER_CAP) } as CSSProperties - // The entrance rise class: absent left of the rise window, where no pane reaches at mount. const riseCls = props.rise ? RISE_CLASS : '' return (
ReactE return function TrendChart(props: TrendChartProps): ReactElement { const dnaBands = props.dna ?? null - // DNA mode: the bars become per-item fingerprints of each request's context. It ignores the - // category focus (the bands ARE the full composition already); the Total/Delta switch stays - // LIVE — delta diffs the bands against the previous bar instead of the categories. const dnaOn = dnaBands !== null const delta = props.mode === 'delta' const dnaDeltaOn = dnaOn && delta - // The duration overlay (durationCurve): each bar's step active time as a curve over the bars, - // read off the right-hand quartile axis. const durationOn = props.durationCurve === true // An unrecognized focus key degrades to the unfocused chart instead of plotting an empty axis. const focus = !dnaOn && props.focusCat !== null && props.focusCat !== undefined && CATS.some(c => c.key === props.focusCat) ? props.focusCat : null - // The item (band key) under the pointer in DNA mode: drives the cross-bar lifetime highlight - // and the tooltip's item row. Mount-local; cleared with the chart hover. const [dnaHit, setDnaHit] = useState(null) - // DNA+delta: each bar's bands paired against the previous bar's by key (dna.ts) — new/grown - // items ride the up arm, removed/shrunk ones the down arm. Aligned with `requests` by index; - // the first bar has no baseline and carries no change, mirroring the record delta. + // Each bar's bands paired against the previous bar's by key (dna.ts), aligned with `requests` by index; the + // first bar carries no change. const dnaDeltas = useMemo( () => (dnaDeltaOn ? dnaBands.map((bands, i) => deltaBandsOf(bands, i > 0 ? dnaBands[i - 1] : null)) : null), [dnaDeltaOn, dnaBands], @@ -559,16 +429,14 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE [props.requests, delta, focus], ) const markers = props.markers - // Adaptive scale: the maxima over the bars currently on screen. Null until the first measure, which falls - // back to the whole-log scale, so the first paint never draws an empty axis; the field-wise comparison means - // scrolling inside a window whose maxima do not move re-renders nothing. + // The maxima over the bars on screen; null until the first measure, which falls back to the whole-log scale, so the + // first paint never draws an empty axis and scrolling inside an unchanged window re-renders nothing. const adaptive = props.adaptive === true const [visMax, setVisMax] = useState(null) const measureVisible = (el: HTMLDivElement): void => { if (!adaptive) return const n = requests.length - // Nothing measurable: an empty history, or a zero-width viewport (a hidden pane, an unlaid-out test DOM). - // Keeping the previous scale degrades the chart to the whole-log axis instead of flattening every bar. + // Nothing measurable (an empty history, a zero-width viewport): keep the previous scale rather than flatten every bar. if (n === 0 || el.clientWidth <= 0) return const pitch = BAR_W + BAR_GAP const sl = el.scrollLeft @@ -577,21 +445,17 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE let up = 0 let down = 0 let activeMs = 0 - // The column holding the left edge through the one holding the right edge; the per-column test then drops - // the neighbour whose column falls in the 2px gap just outside the viewport. const from = Math.max(0, Math.floor(sl / pitch)) const to = Math.min(n - 1, Math.max(from, Math.floor((vr - 1) / pitch))) for (let i = from; i <= to; i++) { const col = i * pitch if (col >= vr || col + BAR_W <= sl) continue const req = requests[i] - // The duration overlay's window maximum rides the same pass, so the right axis - // follows the scroll exactly like the bars' axis (measured whether or not the - // overlay is currently on — a toggle-on then never re-measures). + // The duration overlay's window maximum rides the same pass, measured whether or not the overlay is on + // (so a toggle-on never re-measures). const m = req.activeMs ?? 0 if (m > activeMs) activeMs = m if (dnaDeltaOn && dnaDeltas !== null) { - // DNA+delta: the band arms' own sums — the same figures the whole-log loop takes. let bu = 0 let bd = 0 for (const b of dnaDeltas[i].up) bu += b.tokens @@ -599,7 +463,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE if (bu > up) up = bu if (bd > down) down = bd } else if (delta) { - // Per-bar arms, then the window maximum — the same figures the whole-log loop takes. let bu = 0 let bd = 0 for (const c of CATS) { @@ -617,8 +480,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE ? prev : { total, up, down, activeMs }) } - // Whole-log maxima: the axis when adaptive is off, and the fallback for a delta window with no change at all - // (it carries no scale of its own). + // Whole-log maxima: the axis when adaptive is off, and the fallback for a delta window with no change at all. let maxTotal = 1 let maxUp = 0 let maxDown = 0 @@ -658,9 +520,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE maxTotal = Math.max(1, visMax.total) } } - // The duration overlay's scale (the right-hand axis): mode-independent — the curve always plots the - // bar's raw active time, whether the bars read totals, deltas, or DNA bands. Whole-log by default; the - // visible window's own maximum when adaptive is on (measured on every scroll, durationCurve or not). + // The right-hand axis is mode-independent (the curve plots raw active time): whole-log by default, the + // visible window's maximum when adaptive. let maxActiveMs = 0 if (durationOn) { for (const req of requests) { @@ -669,10 +530,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE } if (adaptive && visMax !== null) maxActiveMs = visMax.activeMs } - // The overlay geometry: ONE polyline per contiguous run of bars carrying a duration (a bar without - // `activeMs` breaks the line instead of faking a value) and a dot for an isolated single point, which a - // one-point polyline would render invisible. X centers on each bar's column, y rides the right axis's - // floor-anchored scale; the svg rides the scrolling content, so scrolling needs no re-computation. + // A bar without `activeMs` breaks the run instead of faking a value; an isolated single point needs a dot — + // a one-point polyline renders invisible. const durationRuns: string[] = [] const durationDots: [number, number][] = [] if (durationOn) { @@ -693,20 +552,17 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE } flushRun() } - // The zero line splits the bar area PROPORTIONALLY to the larger side, so the px-per-token scale - // is identical above and below it — a compaction's downward bar reads honestly against a growth bar. + // The zero line splits the bar area PROPORTIONALLY to the larger side, so px-per-token is identical above and below it. const span = Math.max(1, maxUp + maxDown) const deltaScale = CHART_H / span const upPx = Math.round(maxUp * deltaScale) const downPx = CHART_H - upPx - // A delta quarter mark (axis label + its dashed guide) yields ENTIRELY when its 11px label box would - // overlap the zero label (top 13+upPx) — the zero line is the reading reference. Total-mode marks never - // collide and always render. + // A delta quarter mark yields entirely when its 11px label box would overlap the zero label; total-mode marks always render. const q3Clear = Math.abs(Q3_TOP - 13 - upPx) >= 11 const q1Clear = Math.abs(Q1_TOP - 13 - upPx) >= 11 - // Consecutive same-turn requests collapse into one labeled range; `span` counts the STEP columns the group covers (step records count - // one each), so strip blocks align with the bars in both granularities. + // `span` counts the STEP columns the group covers (step records count one each), so strip blocks align with + // the bars in both granularities. const groups: { turn: number; count: number; span: number; agg: boolean }[] = [] for (const req of requests) { let grp = groups.length > 0 ? groups[groups.length - 1] : null @@ -718,8 +574,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE grp.span += req.stepCount ?? 1 } - // Strip offsets/widths are computed in content px so the scroll handler can re-center labels analytically and measures only the handful - // of labels on screen. + // Computed in content px so the scroll handler can re-center labels analytically and measure only the labels on screen. const turnOffsets: number[] = [] const turnWidths: number[] = [] { @@ -732,15 +587,9 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE } } - // The rise window (RISE_CAP): the columns a pane can reach, since the chart mounts anchored to the - // newest bars. A log shorter than the window rises whole; a re-anchor onto an older turn (a strip click - // or a chat jump) simply leaves those columns without the rise. const riseFrom = Math.max(0, requests.length - RISE_CAP) - // One font size for the whole strip: the largest at which the TIGHTEST adjacent pair of labels still clears - // the gap between their block centers (center distance = half each block + the gap between blocks), so every - // turn label stays shown on the single line instead of thinning out. Uniform (not per-label) so sizes never - // mix, and computed from the groups alone so it is stable while scrolling. + // Uniform (not per-label) and computed from the groups alone, so sizes never mix and the value stays stable while scrolling. let labelFont = '' { let scale = 1 @@ -752,42 +601,25 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE if (scale < 1) labelFont = `${Math.max(LABEL_FONT_MIN, Math.floor(LABEL_FONT * scale))}px` } - // Default anchor: newest bars at the RIGHT edge; the first layout after mount scrolls unconditionally, a GRANULARITY SWITCH re-anchors - // the same way (step mode must not inherit the turn chart's stale left edge), otherwise stick to the end only while already near it; - // useLayoutEffect avoids a first-paint flash. const scrollRef = useRef(null) const scrolledOnce = useRef(false) const lastGranRef = useRef(props.granularity) - // The newest bar's seq (or 0 when the log is empty): the layout effect only re-runs when the right edge genuinely - // moves (new bar appended, granularity switched, focus turn set) — hover/select changes keep their scroll position - // so the chart does not flash with every keystroke. + // The layout effect re-runs only when the right edge genuinely moves; hover/select changes keep their scroll position. const lastSeqRef = useRef(0) - // The scrollWidth measured during the PREVIOUS effect pass. The "was the reader near the right edge?" check - // has to compare against the width as it was BEFORE the new bar landed — by the time the layout effect runs, - // `el.scrollWidth` is already the new (wider) value, so a near-edge check against it would miss the auto-follow. + // The previous effect pass's scrollWidth: by the time the layout effect runs, `el.scrollWidth` is already the new, + // wider value, so a near-edge check against it would miss the auto-follow. const prevScrollWidthRef = useRef(0) - // Cached viewport width / scroll offset for the hover path (issue #116): syncTip runs after every commit, and - // reading clientWidth/scrollLeft there would force a synchronous layout flush of whatever that commit just - // dirtied (page-wide on a hover-linked update). The commit effect, the scroll handler and the resize observer - // keep these refs fresh instead, so the per-hover pass touches no document-scope layout state. + // Cached viewport width / scroll offset for the hover path: reading clientWidth/scrollLeft inside syncTip would force a + // synchronous layout flush of whatever that commit just dirtied. The commit effect, scroll handler and resize observer refresh them. const cwRef = useRef(0) const slRef = useRef(0) - /** - * Keep each turn label centered within its block's VISIBLE slice, then thin colliding labels: a label wider - * than its block overflows it, so consecutive narrow turns would smear into each other — walking left→right - * in content coordinates, a label whose box reaches the previous KEPT one drops to visibility:hidden. The - * render-time font shrink (labelFont) already sizes every label to clear its tightest neighbour, so this - * chain only fires past the 6px floor or on viewport-edge shifts. Blocks that cannot reach the viewport even - * overhung by a label skip their reads/writes entirely (their transform/visibility just reset); reads - * (offsetWidth) batch before the writes to avoid layout thrash, and unchanged styles write nothing. - */ + /** Center each turn label within its block's visible slice, then thin colliding labels; reads batch before writes. */ const updateTurnLabels = (el: HTMLDivElement): void => { const labels = el.querySelectorAll('.lc-turn-label') const n = Math.min(labels.length, turnOffsets.length) const sl = el.scrollLeft const vr = sl + el.clientWidth const writes: [HTMLElement, string, string][] = [] - // Right edge (content px) of the last kept label's box plus the gap; -Infinity opens the chain. let chainR = -Infinity for (let i = 0; i < n; i++) { const off = turnOffsets[i] @@ -826,8 +658,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE lastGranRef.current = props.granularity scrolledOnce.current = false } - // A strip-clicked focus turn centers its bar instead of the newest anchor, consumed once via onFocusTurnHandled — also when - // granularity was already 'turn' (no re-anchor happens that render). + // A strip-clicked focus turn centers its bar instead of the newest anchor, consumed once via onFocusTurnHandled. if (props.focusTurn !== null) { const gi = groups.findIndex(g => g.turn === props.focusTurn) if (gi >= 0) { @@ -839,9 +670,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE scrolledOnce.current = true el.scrollLeft = el.scrollWidth } else if (grew && el.scrollLeft + el.clientWidth >= widthBeforeAppend - 24) { - // Only follow the latest bar when the right edge actually moved AND the reader was already near it - // BEFORE the new bar landed — comparing against the new scrollWidth would silently drop the stick for a - // mid-chart reader whose viewport just slid past the near-end threshold. el.scrollLeft = el.scrollWidth } lastSeqRef.current = newestSeq @@ -851,16 +679,14 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE updateTurnLabels(el) syncTip(el) measureVisible(el) - // A DNA↔stacked or total↔delta switch changes what the window maximum means (band arms vs - // category arms vs totals) — re-measure so an adaptive axis never rides a stale scale. + // A DNA↔stacked or total↔delta switch changes what the window maximum means, so re-measure rather than ride a stale adaptive scale. }, [props.granularity, props.focusTurn, requests, adaptive, dnaDeltaOn]) - // The observer callback needs the LATEST measure closure (it captures `requests`/`delta`); a ref keeps it fresh - // without tearing the observer down on every commit. + // The observer needs the LATEST measure closure (it captures `requests`/`delta`); a ref keeps it fresh + // without tearing the observer down. const measureRef = useRef(measureVisible) useLayoutEffect(() => { measureRef.current = measureVisible }) - // A pane resize (sidebar collapse/drag, window resize) changes the visible window without any render, so the - // observer re-measures. jsdom exposes no ResizeObserver — the commit/scroll measures cover those paths. + // A pane resize changes the visible window without any render, so the observer re-measures; jsdom exposes no ResizeObserver. useLayoutEffect(() => { const el = scrollRef.current /* v8 ignore next 1 -- the scroll div renders unconditionally and React @@ -871,8 +697,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE observer.observe(el) return () => { observer.disconnect() } }, []) - // A horizontal swipe running off the chart's edge must not chain into the browser's history navigation - // (overscroll.ts): the sheet's overscroll-behavior-x covers Chromium/Firefox, this covers WebKit. + // A horizontal swipe off the chart's edge must not chain into the browser's history navigation (overscroll.ts covers WebKit). useLayoutEffect(() => { const el = scrollRef.current /* v8 ignore next 1 -- the scroll div renders unconditionally and React @@ -881,18 +706,9 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE return containHorizontalOverscroll(el) }, []) - // Compact hover tooltip, shown instantly by the custom `.lc-chart-tip` (the native title is delayed): - // identity and the bar's total — the SAME value the bar height and axis are scaled against (the fold's - // heuristic figure, matching every other card). Identity phrasing follows the granularity — - // turn bars always speak TURN (the aggregate's step count, singular for a 1-step turn; a record missing - // stepCount degrades to that too), step bars carry the step index plus the turn's step total. Delta swaps - // the metric row for the net, and the duration overlay appends a third row while it is on. const stepsOf = useMemo(() => turnStepsOf(props.requests), [props.requests]) const hoveredIdx = props.hoveredSeq !== null ? requests.findIndex(r => r.seq === props.hoveredSeq) : -1 const hoveredReq = hoveredIdx >= 0 ? requests[hoveredIdx] : null - // DNA mode: the band under the pointer in the hovered bar — in total mode an absolute band of the - // hovered bar; in delta mode the signed band (either arm) of the hovered bar's delta. Nothing while - // the pointer rests on the bar's top padding, above the strip. const hoveredBands = dnaOn && !dnaDeltaOn && hoveredIdx >= 0 ? dnaBands[hoveredIdx] : null const hitBand = hoveredBands !== null && dnaHit !== null ? hoveredBands.find(b => b.key === dnaHit && b.tokens > 0) ?? null @@ -900,9 +716,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE const hoveredDelta = dnaDeltaOn && hoveredIdx >= 0 && dnaDeltas !== null ? dnaDeltas[hoveredIdx].up.find(b => b.key === dnaHit) ?? dnaDeltas[hoveredIdx].down.find(b => b.key === dnaHit) ?? null : null - // The hovered item's slice in EVERY bar, resolved to chart geometry: total mode anchors slices at the - // floor on the total scale; delta mode anchors them at the zero line on the delta scale (an item that - // left arms hangs below it, one that joined rises above). Feeds the dumb highlight layer. const dnaSlices = useMemo(() => { if (!dnaOn || dnaHit === null) return null const slices: ({ bottom: number; height: number } | null)[] = [] @@ -910,8 +723,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE for (const d of dnaDeltas) { const hit = d.up.find(b => b.key === dnaHit) ?? d.down.find(b => b.key === dnaHit) ?? null if (hit === null) { slices.push(null); continue } - // Up-arm slice rises from the zero line, down-arm slice hangs below it (`off` is the - // cumulative magnitude from the line, `mag` the band's own). + // Up-arm slice rises from the zero line, down-arm slice hangs below it (`off` is the cumulative magnitude from the line). const mag = Math.abs(hit.tokens) const edge = hit.tokens > 0 ? downPx + hit.off * deltaScale @@ -934,10 +746,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE const head = props.granularity === 'turn' ? (n > 1 ? t('tip.turn', { t: req.turn ?? 0, n }) : t('tip.turn1', { t: req.turn ?? 0 })) : t('tip.step', { t: req.turn ?? 0, s: req.step ?? 0, n: stepsOf(req.turn) }) - // DNA mode: the metric row names the hovered item the way the browser's DNA bands name it — - // its absolute tokens in total mode, its SIGNED change in delta mode. Both lookups are null - // unless a band sits under the pointer (a hover on the bar's padding above the strip falls - // through to the mode's own row: the delta's net, else the total). + // DNA mode: the metric row names the hovered item; a hover on the bar's padding falls through to the mode's own row. const band = hitBand ?? hoveredDelta let metric: string if (band !== null) { @@ -953,40 +762,30 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE : t('tip.total', { n: fmt(req.total) }) } const rows = [head, metric] - // The overlay's own reading: the bar's step active time, only while the curve is on. if (durationOn && req.activeMs !== undefined) rows.push(t('tip.duration', { n: fmtDuration(req.activeMs) })) return rows } - // Column center (content px) of the currently hovered bar, for syncTip reads outside the render pass. const tipColRef = useRef(0) - /** - * Glue the hover tip to its bar's VISIBLE slice. The tip deliberately does NOT live inside the scrolling - * content: an absolutely-positioned child of a scroller contributes to its scrollable overflow, so a wide - * reply preview on a right-edge bar used to inflate scrollWidth on every hover and flap the horizontal - * scrollbar open/closed — jumping the whole card. The only layout read left is the tip's own width (its - * subtree is the thing that just changed); the scroller's width/offset ride the cached refs, so this never - * forces a document-scope layout flush on the hover path. Unchanged transforms write nothing. + /** Glue the hover tip to its bar's visible slice. The tip deliberately does NOT live inside the scrolling content: an + * absolutely-positioned child of a scroller contributes to its scrollable overflow, so a wide tip would flap the scrollbar. */ const syncTip = (el: HTMLDivElement): void => { /* v8 ignore next 1 -- the scroll div renders unconditionally while mounted, so its parent exists. */ const tip = (el.parentElement ?? document.body).querySelector('.lc-chart-tip') - // No hover, nothing to place. if (tip === null) return const lw = tip.offsetWidth const cw = cwRef.current - // Center over the bar's visible slice, clamped so the tip never hangs past either edge nor gets cut off; a tip - // wider than the viewport centers over it instead of picking a bogus side on an inverted clamp window. + // Center over the bar's visible slice, clamped to the viewport; a tip wider than the viewport centers over it. const half = Math.min(lw / 2, cw / 2) const cx = Math.min(Math.max(tipColRef.current - slRef.current, half), cw - half) const next = `translate(${Math.round(cx - lw / 2)}px, 0)` if (tip.style.transform !== next) tip.style.transform = next } - // Position (and re-position after EVERY commit — the tip mounts on hover changes, which touch no other - // effect dependency here) from the committed hovered column before paint. All document-scope reads ride - // the cached refs, so an unrelated commit makes this a cheap arithmetic pass that writes nothing. + // Re-position after every commit (the tip mounts on hover changes, which touch no other dependency here) before paint; + // all document-scope reads ride the cached refs, so an unrelated commit is a cheap pass that writes nothing. useLayoutEffect(() => { /* v8 ignore next 1 -- the scroll div renders unconditionally and React attaches refs before layout effects run; el is never null here. */ @@ -1001,9 +800,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE {delta ? ( <> {fmtSigned(maxUp)} - {/* Axis quartile marks on the uniform px-per-token scale (the value at each fixed height); a mark - whose 11px label box would overlap the zero label drops itself — the zero line is the reading - reference and keeps its place. */} {q3Clear ? {fmtSigned(Math.round(maxUp - span / 4))} : null} @@ -1024,9 +820,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE )}
- {/* Only the scrolling CONTENT lives under .lc-chart-scroll; the hover tip sits beside it inside the - positioned wrapper instead of inside the scroller — absolutely-positioned children of a scroller - contribute to its scrollable overflow AND translate away with the content on scroll. */} + {/* Only the scrolling CONTENT lives under .lc-chart-scroll; the hover tip sits beside it — an + absolutely-positioned child of a scroller would inflate its overflow and translate with the content. */}
ReactE >
{ props.onHover(null); setDnaHit(null) }} >
- {/* Dashed guides aligning the bars with the axis quarter marks (fixed heights, both modes); a delta - mark that yielded to the zero label drops its guide too, and a coinciding guide sits under the - solid zero line painted after it. */} + {/* Dashed guides aligning the bars with the axis quarter marks; a delta mark that yielded to the + zero label drops its guide too. */} {(!delta || q3Clear) ?
: null} {(!delta || q1Clear) ?
: null} {!delta ?
: null} - {/* The SOLID zero baseline — the reading reference in both modes: inline-positioned off the up-arm - in delta mode, the chart floor (CSS default) under the '0' label in total mode. */}
{requests.map((req, i) => ( ReactE upPx={delta ? upPx : undefined} downPx={delta ? downPx : undefined} deltaScale={delta ? deltaScale : undefined} - // Counted from the window's edge: the rising columns cascade left to right even when the - // log runs far past the window (their raw indices would all sit past the stagger cap). + // Counted from the window's edge, so the rising columns cascade left to right even when the log runs far past it. enterIndex={Math.max(0, i - riseFrom)} rise={i >= riseFrom} dna={dnaOn ? dnaBands[i] : null} @@ -1088,11 +878,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE /> ))} {dnaOn ? : null} - {/* The duration overlay: one svg riding the scrolling content (top 18px padding band excluded), - so it scrolls with the bars and needs no scroll handler. z-index parity with the DNA - highlight but later in DOM order, so the curve reads above the translucent slices; a bar - without `activeMs` breaks the line instead of faking a point. Every segment paints twice: - a card-bg knockout underlay (`.lc-dur-halo`) lifts the line clear of the bar colors. */} + {/* One svg riding the scrolling content, so it scrolls with the bars; z-index parity with the DNA highlight but + later in DOM order, so the curve reads above the slices. Each segment paints twice (a card-bg knockout underlay). */} {durationOn && (durationRuns.length > 0 || durationDots.length > 0) ? ( ReactE ) : null}
- {/* Turn strip: one COLOR BLOCK per turn spanning exactly its bars' columns, so the partition reads at a glance and lines - up with the steps; hovering a block highlights that turn's bars and vice versa — one shared hover-only state. - */}
{ props.onHoverTurn(null) }}> {groups.map((grp, gi) => { const on = props.activeTurn === grp.turn @@ -1137,19 +921,10 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE })}
- {/* Compact hover tooltip (identity / bar total), shown instantly by the custom `.lc-chart-tip` - (the native title is delayed); the per-category breakdown lives in the detail panel below. It floats - ABOVE the plot (CSS bottom anchoring) so it never covers the bars, is capped at the wrapper's width - and wrapped, and is positioned imperatively over its bar's visible slice (syncTip) so scrolling keeps - it glued without ever widening the scrollable area. */} {hoveredReq !== null ? (
{tipRowsOf(hoveredReq).map((row, i) => {row})}
) : null}
- {/* The duration overlay's own quartile axis rides the chart's RIGHT edge (the bars' token axis - keeps the left): the same five fixed marks, formatted as durations, floor-anchored in every - mode — the curve plots raw active time even while the bars read deltas. Only while the overlay - is on. */} {durationOn ? (
{fmtDuration(maxActiveMs)} diff --git a/src/client/components/upgradeGate.tsx b/src/client/components/upgradeGate.tsx index 2688812..2a09a7f 100644 --- a/src/client/components/upgradeGate.tsx +++ b/src/client/components/upgradeGate.tsx @@ -1,14 +1,7 @@ -/** - * The baseline-gate modal: when the host reports the running harness below - * the supported baseline (the `unsupported` record on the pushed - * `contextTimeline` value — see host/fallback.ts), the Context tab keeps - * rendering its (zeroed) cards and this centered dialog names the detected - * harness version against the required minimum and urges the upgrade. - * - * Dismissal is remembered per session in a module-level ledger: tab remounts - * do not re-pop it, while a fresh app launch does — the gate stays visible - * until the harness is actually updated. - */ +/** The baseline-gate modal: when the host reports a harness below the supported baseline (the `unsupported` record on + * the pushed `contextTimeline` value), the tab keeps rendering its zeroed cards and this dialog names the detected + * version against the required minimum. Dismissal is remembered per session in a module-level ledger, so tab remounts + * do not re-pop it while a fresh app launch does. */ import { useCallback, useState, type ReactElement } from 'react' import type { ViewKit } from '../viewkit' @@ -19,7 +12,7 @@ const dismissed = new Set() export interface UpgradeGateProps { sessionId?: string - /** The detected harness version (displayed verbatim, already re-proved). */ + /** The detected harness version, already re-proved. */ current: string /** The plugin's minimum supported baseline. */ minimum: string @@ -29,8 +22,7 @@ export function makeUpgradeGate(kit: ViewKit): (props: UpgradeGateProps) => Reac const { t } = kit return function UpgradeGate(props: UpgradeGateProps): ReactElement | null { const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '' - // `closedFor` re-renders on dismiss within one mount; the ledger covers - // remounts and in-place session switches. + // `closedFor` covers dismissals within one mount; the ledger covers remounts and in-place session switches. const [closedFor, setClosedFor] = useState(null) const closed = closedFor === sessionId || dismissed.has(sessionId) const close = useCallback(() => { @@ -38,8 +30,7 @@ export function makeUpgradeGate(kit: ViewKit): (props: UpgradeGateProps) => Reac setClosedFor(sessionId) }, [sessionId]) - // Same overlay contract as the /context modal (escapeClose.ts): - // capture-phase Escape, and focus returns to the pre-open element. + // The /context modal's overlay contract: capture-phase Escape, focus restored to the pre-open element. useEscapeClose(!closed, close) if (closed) return null diff --git a/src/client/cost.ts b/src/client/cost.ts index 2c8e586..4de9b72 100644 --- a/src/client/cost.ts +++ b/src/client/cost.ts @@ -1,77 +1,41 @@ /** - * Session-cost estimate — prices the host-folded cumulative billed-token - * totals (SessionCostUsage) from the client's model-price book - * (client/modelPrices.ts): the models.dev registry, fetched through - * @opencode-ai/models. Book rates are USD per 1M tokens; the CNY display - * converts at the fixed 1 CNY = 0.15 USD, and the total and the tooltip's - * rates both go through `toCurrency`, so the printed figures can never - * drift from the math that prices the session. DeepSeek bills a period-based - * list whose models.dev figures ARE the official off-peak rates: the Host - * already split those buckets at fold time, so DeepSeek's `peak` buckets - * price at twice the book rate here (the `off` buckets stay at book) — - * never any other provider's. A dsh provider id the registry does not know - * prices through the model-side resolution index (PriceIndex): the vendor - * branch is picked from registry data — the model's own-vendor SDK package, - * the org segment of `vendor/model` spellings, the provider an id names — - * with ambiguous or conflicting candidates pricing nothing. + * Session-cost estimate: prices the host-folded cumulative billed totals from + * the client's models.dev price book. Rates are USD per 1M tokens, converted + * for the CNY display at 1 CNY = 0.15 USD. + * + * DeepSeek's book figures are the official OFF-peak rates, so only its `peak` + * buckets double here; a provider id the registry does not know prices through + * the model-side index, whose vendor comes from registry data. */ import type { SessionCostUsage } from '../shared/types' import { isDeepSeekProvider, modelsDevProviderOf } from '../shared/providers' import { asRecord, numOf } from './services' -/** The display currencies the stats board ships; the locale picks one. */ export type CostCurrency = 'usd' | 'cny' -/** 1 CNY = 0.15 USD — the fixed CNY-display conversion rate. */ const USD_PER_CNY = 0.15 -/** DeepSeek's peak rates are twice the off-peak rates (the official list). */ const PEAK_FACTOR = 2 -/** - * Per-1M-token rates (USD): cache-hit input, cache-miss input, cache - * write, output (reasoning included). Absent registry fields fall back to - * the input rate (a provider that publishes no cache prices bills those - * buckets as plain input). - */ +/** Per-1M-token rates (USD); an absent registry cache field falls back to the input rate. */ export interface PriceTriple { hit: number; miss: number; write: number; out: number } -/** - * The client's price book: models.dev provider id → model id → USD rates, - * extracted from the registry (modelPrices.ts). - */ export type ModelPrices = Record> -/** - * One cross-provider resolution candidate: the carrying provider (models.dev - * id) with its rates, and whether it is the model's own vendor — a provider - * whose AI-SDK package is named after itself (`@ai-sdk/anthropic` → - * `anthropic`); mirrors and gateways ride generic or foreign packages. `mid` - * is the registry's own model id spelling (the tooltip's listing face). - */ +/** One cross-provider candidate: the carrying provider (models.dev id) with its + * rates, plus whether it is the model's own vendor (`primary`). `mid` is the + * registry's model id spelling, the tooltip's listing face. */ export interface PriceCandidate { pid: string; mid: string; rate: PriceTriple; primary: boolean } -/** - * Model-side resolution index over the book: lowercased model id (exact ids - * plus their `-`-suffix tails, e.g. `k3` under `kimi-k3`) → the providers - * carrying it. Built once per book load (modelPrices.ts) so a dsh provider id - * the registry does not know still prices by model id alone — the vendor is - * picked by data, never by a per-model hardcode. - */ +/** Model-side index over the book: lowercased model id (exact ids plus their + * `-`suffix tails, e.g. `k3` under `kimi-k3`) → the providers carrying it. */ export interface PriceIndex { byModel: Map } -/** The delivered book plus the index built over it (one `pricesBookOf` result). */ export interface ModelBook { prices: ModelPrices; index: PriceIndex } -/** - * One billed model's price: the USD rates and the registry face — the - * models.dev provider id and branch model id spelling — they resolved from - * (the listing source the stats board's tooltip prints). - */ export interface PriceFace { pid: string; mid: string; rate: PriceTriple } -/** A USD amount in the display currency (CNY divides the fixed rate). */ export function toCurrency(usd: number, currency: CostCurrency): number { return currency === 'cny' ? usd / USD_PER_CNY : usd } @@ -82,7 +46,6 @@ function sameRate(a: PriceTriple, b: PriceTriple): boolean { return close(a.hit, b.hit) && close(a.miss, b.miss) && close(a.write, b.write) && close(a.out, b.out) } -/** One proven candidate rate off a built book branch, or null. */ function rateOfEntry(value: unknown): PriceTriple | null { const v: unknown = value if (v === null || typeof v !== 'object') return null @@ -93,14 +56,9 @@ function rateOfEntry(value: unknown): PriceTriple | null { : null } -/** - * Build the resolution index over a proven book. `npmOf` carries each - * provider's registry `npm` package (null when absent) — the vendor signal: - * a provider whose package is `@ai-sdk/` is the model's first - * party, while mirrors and gateways ride `@ai-sdk/openai-compatible` or - * someone else's package. Suffix tails are indexed alongside exact ids so a - * short dsh spelling (`k3`) resolves book-wide too. - */ +/** Build the resolution index over a proven book. `npmOf` carries each + * provider's registry `npm` package (null when absent): `@ai-sdk/` + * marks a first-party provider, while mirrors and gateways ride a generic or foreign package. */ export function priceIndexOf(prices: ModelPrices, npmOf: Record): PriceIndex { const byModel = new Map() const push = (key: string, cand: PriceCandidate): void => { @@ -126,11 +84,7 @@ export function priceIndexOf(prices: ModelPrices, npmOf: Record 1 && groups[0].n === groups[1].n ? null : groups[0].cand } -/** - * Pick one candidate: the model's own vendor (unique or unanimous), then a - * provider the model id itself names (`deepseek-v4-flash` under `deepseek`), - * then a lone carrier. Everything else prices null. - */ function resolveCandidates(lower: string, cands: PriceCandidate[], org: string | null): PriceCandidate | null { if (org !== null) { - // `vendor/model` catalogs (together- and vercel-style route ids): the - // org segment names the vendor — exactly, or as the registry id behind - // a variant org (`deepseek-ai` → `deepseek`, `zai-org` → `zai`). + // `vendor/model` catalogs: the org segment names the vendor, exactly or as + // the registry id behind a variant org (`deepseek-ai` → `deepseek`). const named = cands.filter(c => c.pid === org || org.startsWith(c.pid + '-') || (org.length >= 4 && org.startsWith(c.pid))) if (named.length > 0) { const cand = majorityCandidate(named) @@ -171,11 +119,7 @@ function resolveCandidates(lower: string, cands: PriceCandidate[], org: string | return cands.length === 1 ? cands[0] : null } -/** - * Resolve a model id book-wide: the full id first, then its last `/`-segment - * (an org-prefixed dialect whose exact spelling no registry provider lists). - * Null when every tier refuses. - */ +/** Resolve a model id book-wide: the full id, then its last `/`-segment. */ function resolveRate(index: PriceIndex, model: string): PriceCandidate | null { const lower = model.toLowerCase() const slash = lower.lastIndexOf('/') @@ -189,20 +133,14 @@ function resolveRate(index: PriceIndex, model: string): PriceCandidate | null { return null } -/** One book branch (a provider's models), as far as runtime can prove it. */ function branchOf(book: ModelPrices, id: string): Record | null { const v: unknown = book[id] return v !== null && typeof v === 'object' ? (v as Record) : null } -/** - * One branch's lookup hit — the registry's own model id and its rates, as - * far as runtime can prove it: exact own key first (the book is untrusted - * wire data), then case-insensitively, then by id SUFFIX — dsh spells some - * models short (`k3`) where the registry namespaces them (`kimi-k3`). - * Several suffix candidates (e.g. `k3` vs a hypothetical `other-k3`) are - * ambiguous and price nothing. - */ +/** One branch's lookup hit: exact own key first (the book is untrusted wire + * data), then case-insensitively, then by id suffix — dsh spells some models + * short (`k3`) where the registry namespaces them (`kimi-k3`). Several suffix candidates are ambiguous and price nothing. */ function lookupFace(models: Record, model: string): { mid: string; rate: PriceTriple } | null { if (Object.hasOwn(models, model)) return { mid: model, rate: models[model] } const m = model.toLowerCase() @@ -219,28 +157,20 @@ function lookupFace(models: Record, model: string): { mid: } /** - * One billed model's price: the USD rates plus the registry face — the - * models.dev provider id and branch model id spelling — they resolved from - * (what the stats board prints as the listing source). + * One billed model's price and registry face. The dsh provider id prices by + * model id (exact, case-insensitive, or suffix); a provider the book does not + * carry falls back to the model-side index. * - * Resolution: the dsh provider id goes through modelsDevProviderOf (unmapped - * ids pass through) and prices by model id — exact, case-insensitive, or - * suffix. A provider the book does not carry falls back to the model-side - * resolution index, which names the vendor from data (org segment, own-vendor - * SDK, id prefix, lone carrier) — never from a per-model hardcode. The one - * vendor-level seam: a `deepseek-` model id prices from DeepSeek's own - * registry branch first — its listing is the first-party price book (cache - * rates included), while the model-side tiers can hand the same id to a - * self-named hosting provider whose flat listing bills cache hits as plain - * input (azure's `deepseek-v4-pro`, issue #93) — the same specialness the - * peak/off-peak split already grants DeepSeek. + * The one vendor-level seam: a `deepseek-` model id prices from DeepSeek's own + * branch first, since its listing is the first-party book with cache rates, + * while the model-side tiers can hand the same id to a self-named host that + * bills cache hits as plain input (azure's `deepseek-v4-pro`, issue #93). */ export function priceFaceOf(book: ModelBook | null | undefined, provider: string, model: string): PriceFace | null { if (book === null || book === undefined) return null const registryPid = modelsDevProviderOf(provider) const direct = branchOf(book.prices, registryPid) - // A carried provider's own branch is final — an unknown model there prices - // null (no cross-provider guess). + // A carried provider's own branch is final: an unknown model there prices null. if (direct !== null) { const found = lookupFace(direct, model) return found === null ? null : { pid: registryPid, mid: found.mid, rate: found.rate } @@ -256,20 +186,12 @@ export function priceFaceOf(book: ModelBook | null | undefined, provider: string return cand === null ? null : { pid: cand.pid, mid: cand.mid, rate: cand.rate } } -/** The book's rates for one folded (provider, model) bucket ({@link priceFaceOf} without the face). */ export function priceOf(book: ModelBook | null | undefined, provider: string, model: string): PriceTriple | null { return priceFaceOf(book, provider, model)?.rate ?? null } -/** - * Price the session's cumulative billed-token totals. Cache reads bill at - * the hit rate, uncached input at the miss rate, cache writes at the write - * rate, output (reasoning included) at the out rate; `peak` buckets price - * at twice the book rate for DeepSeek (the book lists that provider's - * off-peak rates — the Host splits the period-based list at fold time). - * Null when nothing was priced (no usage folded, no book yet, or no model - * the book prices), so the cell can show a dash. - */ +/** Price the session's cumulative billed totals; `peak` buckets double for + * DeepSeek only. Null when nothing was priced, so the cell can show a dash. */ export function estimateSessionCost( usage: SessionCostUsage | null | undefined, book: ModelBook | null | undefined, @@ -281,9 +203,7 @@ export function estimateSessionCost( for (const provider of Object.keys(usage)) { const models = asRecord(usage[provider]) if (models === null) continue - // The doubled peak period is DeepSeek's alone (shared/providers): the - // book lists its off-peak rates, so only the peak bucket multiplies — - // every other provider bills every bucket at book price. + // The doubled peak period is DeepSeek's alone. const deepseek = isDeepSeekProvider(provider) for (const model of Object.keys(models)) { const rate = priceOf(book, provider, model) @@ -302,12 +222,6 @@ export function estimateSessionCost( return any ? toCurrency(total, currency) : null } -/** - * The scope's billed-token total: uncached input + cache read/write + output, - * summed over every billed model and pricing period — the token face of the - * same buckets the cost estimate prices. Hostile branches skip, the same - * re-proving the estimator applies. - */ export function billedTokensOf(usage: SessionCostUsage | null | undefined): number { if (usage === null || usage === undefined) return 0 let total = 0 @@ -327,12 +241,8 @@ export function billedTokensOf(usage: SessionCostUsage | null | undefined): numb return total } -/** - * Accumulate one usage's buckets into `out`, summing per (provider, model, - * period). Hostile branches skip (the same re-proving the estimator applies - * — the merge is a boundary too); true when any bucket record merged, even - * an all-zero one (the estimator prices it as $0, never a dash). - */ +/** Accumulate one usage's buckets into `out`. True when any bucket record + * merged, even an all-zero one, so the estimator prices it as $0 not a dash. */ function mergeInto(out: SessionCostUsage, usage: SessionCostUsage | null | undefined): boolean { if (usage === null || usage === undefined) return false let any = false @@ -361,12 +271,8 @@ function mergeInto(out: SessionCostUsage, usage: SessionCostUsage | null | undef return any } -/** - * Deep-merge session-cost usages into one — the stats board's total-cost - * scope (the current agent's usage plus every subagent session's) and the - * subagent fold's accumulation. Null when NO side carried a bucket record, - * so the caller keeps its dash. - */ +/** Deep-merge session-cost usages into one — the stats board's total-cost scope + * and the subagent fold. Null when no side carried a bucket record. */ export function mergeCostUsage(...usages: (SessionCostUsage | null | undefined)[]): SessionCostUsage | null { const out: SessionCostUsage = {} let any = false diff --git a/src/client/dna.ts b/src/client/dna.ts index 8fada7a..3e2c094 100644 --- a/src/client/dna.ts +++ b/src/client/dna.ts @@ -1,22 +1,12 @@ /** - * The browser's DNA mode: decompose an assembled context into ONE band per - * item (the system prompt, every tool schema, every surface message) and - * lay them out in the order the model actually reads them — the context's - * "fingerprint" strip behind the composition bar's `dna` toggle. + * The browser's DNA mode: one band per assembled-context item (system prompt, + * every tool schema, every surface message) in PROMPT order. * - * The order is PROMPT order, which is also the order items joined the - * context: the system prompt and tool schemas prefix every request, so they - * lead the strip; the message flow follows in seq (chronological) order. - * The system band rides the timeline's own `systems` nodes (the prompt's real - * surface positions), the tool bands their header epoch. The header epoch's - * own seq is NOT usable as an ordering key: dsh appends `request/header` at - * dispatch time of the first request that uses a header (agent.ts — - * 'initial'/'resume'/'change'/'series'), a bookkeeping snapshot that lands - * AFTER every message already in context. A mid-session refresh would - * otherwise park the tools bands in the middle of the strip, behind the very - * tool results those schemas describe. A refreshed epoch REPLACES the prefix - * in place — the strip reflects that by keeping the current epoch's bands at - * the front (the band's tooltip carries the epoch's time). + * The header epoch's seq is NOT usable as an ordering key: dsh appends + * `request/header` at the dispatch of the first request using it, landing after + * every message already in context, so ordering by seq would park the tools + * bands behind the results those schemas describe. A refreshed epoch replaces + * the prefix in place, keeping the current epoch's bands at the front. */ import type { Category, SurfaceNode } from '../shared/types' @@ -24,11 +14,8 @@ import type { Assembled } from './assemble' import { CAT_COLOR } from './categories' import type { Translate } from './i18n' -/** - * One band of the DNA strip. Header bands carry their tokens only; message - * bands hand the node through so the label builder reads tool/form/skill - * straight off it (the union discriminates on `cat` — no defensive guards). - */ +/** One band of the DNA strip. Message bands hand the node through so the label + * builder reads tool/form/skill straight off it (the union discriminates on `cat`). */ export type DnaItem = | { key: string; cat: 'system' | 'tools'; tokens: number; time?: number } | { key: string; cat: Category; tokens: number; time?: number; node: SurfaceNode } @@ -43,8 +30,7 @@ export function dnaOf(view: Assembled): DnaItem[] { items.push({ key: 'tool:' + tool.name, cat: 'tools', tokens: tool.tokens }) } } - // `assemble` guarantees seq order; zero-token items keep their band (the - // bar drops zero widths, the reading order stays truthful). + // Zero-token items keep their band: the bar drops zero widths, the reading order stays truthful. for (const n of view.nodes) { items.push({ key: 'n' + String(n.seq), @@ -57,19 +43,12 @@ export function dnaOf(view: Assembled): DnaItem[] { return items } -/** - * One band of a TREND CHART bar's DNA gradient (the trend card's DNA mode): - * the same decomposition as {@link dnaOf}, plus the band's color and `off` — - * the cumulative tokens of the bands BELOW it, since the stack grows from the - * floor. Both the pointer hit-test and the cross-bar lifetime highlight slice - * read `off`; the label builder reads `node` exactly the way the browser's - * DNA bands do (the union discriminates on `cat` — no defensive guards). - */ +/** One band of a TREND CHART bar's DNA gradient: the same decomposition with its + * color and `off`, the cumulative tokens below it (the hit-test and the cross-bar lifetime highlight read `off`). */ export type TrendBand = | { key: string; cat: 'system' | 'tools'; tokens: number; off: number; color: string } | { key: string; cat: Category; tokens: number; off: number; color: string; node: SurfaceNode } -/** The per-bar bands behind a trend chart bar: read order, category colors, cumulative offsets. */ export function trendBandsOf(view: Assembled): TrendBand[] { const bands: TrendBand[] = [] let off = 0 @@ -84,14 +63,9 @@ export function trendBandsOf(view: Assembled): TrendBand[] { return bands } -/** - * DNA mode's DELTA view of one bar against its predecessor: the per-item difference of two - * band lists paired by key. `tokens` is SIGNED — the up arm (items that grew or joined this - * step) positive, the down arm (items that shrank or left) negative — and `off` is the - * cumulative magnitude measured from the zero line, each arm in read order (a removed item's - * order comes from the PREVIOUS bar's list, the only place it exists). Mirrors the record - * delta's idiom: a null predecessor is no baseline at all, so the bar carries no change. - */ +/** DNA mode's DELTA view against the predecessor: per-item differences of two + * band lists paired by key. `tokens` is signed (up positive, down negative) and + * `off` the cumulative magnitude from the zero line, a removed item's order coming from the previous bar. */ export type DeltaBand = | { key: string; cat: 'system' | 'tools'; tokens: number; off: number; color: string } | { key: string; cat: Category; tokens: number; off: number; color: string; node: SurfaceNode } @@ -102,9 +76,8 @@ export interface DnaDelta { } export function deltaBandsOf(bands: TrendBand[], prev: TrendBand[] | null): DnaDelta { - // A null predecessor is no baseline at all: the bar carries no change (the record delta's - // first-bar idiom), so the scale stays change-driven instead of being pinned by the opening - // context's bulk. + // A null predecessor is no baseline: the bar carries no change, so the scale + // stays change-driven instead of being pinned by the opening context's bulk. if (prev === null) return { up: [], down: [] } const before = new Map(prev.map(b => [b.key, b.tokens] as const)) const after = new Map(bands.map(b => [b.key, b.tokens] as const)) @@ -131,13 +104,7 @@ export function deltaBandsOf(bands: TrendBand[], prev: TrendBand[] | null): DnaD return { up, down } } -/** - * The compact item name both DNA surfaces share — the browser appends the - * item's time on top of it for its own tooltip, the trend chart does not. - * Header bands name the system prompt / the tool schema; message bands name - * the item the way its browser row would (skill name, tool name, injection - * form, else the category label). - */ +/** The compact item name both DNA surfaces share: header bands name the prompt or tool schema, message bands the skill or tool. */ export function dnaBaseLabel(b: DnaItem | TrendBand | DeltaBand, t: Translate, catLabel: (key: string) => string): string { if (!('node' in b)) return b.cat === 'system' ? catLabel('system') : b.key.slice('tool:'.length) const n = b.node diff --git a/src/client/dockMeasure.ts b/src/client/dockMeasure.ts index f3cdaff..65d192b 100644 --- a/src/client/dockMeasure.ts +++ b/src/client/dockMeasure.ts @@ -1,30 +1,19 @@ -/** - * The /context modal's dock insets: the shell's sidebar tracks, so the fixed backdrop spans exactly the main column and centers the dialog - * over it instead of the whole viewport. The only host anchor is the app frame's inline grid template (`px ` — - * the leading px track is the left sidebar, the trailing px track the right panel's bound: the contract every supported baseline shares) - * found by walking up from the backdrop. The trailing bound is NOT the solved width: the frame lets the grid - * squeeze (the centre's declared minimum wins, the right track takes the remainder up to - * its bound — the frame's own columns solve), so the parsed bound is clamped to the space the centre minimum leaves. Anything unresolved — - * no frame, unparsable template, a hostile node in the chain — degrades to 0 insets, the full-viewport mask. - */ +/** The /context modal's dock insets: the fixed backdrop spans the frame's centre column, not the viewport. + * The frame's inline grid template (`px `) is the only host anchor, and its + * trailing px track is the right panel's BOUND, not the solved width (the grid squeezes the centre first), + * so it is clamped to the space the centre minimum leaves; unresolved measures degrade to 0 insets. */ -/** The dock measure for one backdrop position: the mask insets and the frame to observe for template rewrites. */ export interface DockMeasure { - /** Mask inset from the viewport's left edge, in px (0 = mask starts at the viewport edge). */ left: number - /** Mask inset from the viewport's right edge, in px (0 = mask ends at the viewport edge). */ right: number - /** The element owning the inline grid template; null when unresolved (nothing to observe). */ frame: HTMLElement | null } const LEADING_PX_TRACK = /^(\d+(?:\.\d+)?)px/ // The centre track's declared minimum (`minmax(0, 1fr)`, `minmax(400px, 1fr)` — spelling varies per shell layout, only the number is read). const CENTRE_MIN_TRACK = /^\s*minmax\(\s*([\d.]+)(?:px)?\s*,/ -// The minmax spelling's trailing track closes with a paren after the px. const TRAILING_PX_TRACK = /(\d+(?:\.\d+)?)px\)?$/ -/** Measure the sidebar tracks from the backdrop's ancestor chain, or resolve to the full-viewport mask. */ export function measureDock(start: HTMLElement | null): DockMeasure { try { for (let el = start?.parentElement ?? null; el !== null; el = el.parentElement) { diff --git a/src/client/fileActivity.ts b/src/client/fileActivity.ts index 2244333..c0efdac 100644 --- a/src/client/fileActivity.ts +++ b/src/client/fileActivity.ts @@ -1,33 +1,17 @@ /** - * File activity — what the agent DID to files. Since the op-log generation, - * the host fold derives one record per executed file op from the durable - * tool lifecycle (shared/fileOps.ts), covering the full session log — the - * card rides the detail channel's `fileOps` and only filters + aggregates - * here. Older hosts (the inline generation) serve no `fileOps`: the legacy - * path re-derives the ops client-side from the served tool-result nodes - * joined with the conversation window — same parser, but window-bound (a - * call whose arguments aged out of the join names no target and is skipped). + * File activity — what the agent DID to files. The op-log generation's host fold + * derives one record per executed file op over the whole log + * (shared/fileOps.ts), so this module only filters and aggregates; older hosts + * serve no `fileOps`, and the legacy path re-derives the ops from the served + * nodes joined with the conversation window (window-bound). * - * Code Mode (PTC) runs nested calls instead: the host folds each settled - * sub-dispatch (`tool/ptc-dispatch`) as ops located on the parent run_code - * result; the legacy path walks the conversation node's `subCalls` tree to - * the same effect. A nested dispatch's persisted event carries no result - * meta (upstream vocabulary), so nested reads keep the limit-estimate form - * and nested searches the call-target form. - * - * Scope: `before` is the EXCLUSIVE upper seq bound (the next request's seq), - * so the picked step's own calls — whose results land before the next - * request — are included; null serves everything (the latest view). A nested - * op's scope key is its parent result's seq (the run_code result is what the - * step's surface shows), matching the legacy node-level filter. + * `before` is the exclusive upper seq bound (the next request's seq). */ import type { FileOpRecord, RequestRecord, SurfaceNode } from '../shared/types' import { opsOfCall, parseCallArgs } from '../shared/fileOps' -// The harness's browser-safe file-address layer, inlined by the client bundle -// (the purity gate's INLINE_SAFE list, as the right Sidebar's own file types -// do): a produced address is the exact `dsh-resource://file/…` spelling the -// preview type claims and the Host resolves. +// The harness's browser-safe file-address layer, inlined by the client bundle: +// the produced address is the `dsh-resource://file/…` spelling the preview claims. import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' import type { ConversationNodeLike } from './services' @@ -35,7 +19,6 @@ export type FileOp = FileOpRecord export type FileOpKind = FileOpRecord['kind'] export type FileForm = 'text' | 'image' | 'dir' -/** One file's aggregated activity; `ops` newest first. */ export interface FileEntry { path: string form: FileForm @@ -53,12 +36,10 @@ export interface FileEntry { export interface FileKindTotal { files: number; ops: number } export interface FileActivity { - /** Path-resolved files, most-recently-touched first. */ entries: FileEntry[] totals: Record & { added: number; removed: number } } -/** The file's form — multimodal reads and image extensions scan apart; a trailing slash marks a directory target. */ export function formOf(tool: string, path: string): FileForm { if (tool === 'read_image' || IMAGE_EXT.test(path)) return 'image' if (path.endsWith('/')) return 'dir' @@ -67,18 +48,16 @@ export function formOf(tool: string, path: string): FileForm { const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i -/** One row icon: the emoji — or, when `color` is set, a letter badge with that - * fill and `glyph` as its label — plus the i18n key naming its bucket (the row's hover title). */ +/** One row icon: the emoji, or a letter badge when `color` is set, plus the i18n key naming its bucket. */ export interface FileGlyph { glyph: string tip: string /** Language-badge fill; present only on the code-file buckets. */ color?: string - /** Badge text color, riding along with `color` (white, or near-black on light fills). */ text?: string } -/** Directory buckets, by last path segment (lowercased, explicit plurals). Checked in order, then hidden dirs, then the plain folder. */ +/** Directory buckets by last path segment; then hidden dirs, then the plain folder. */ const DIR_BUCKETS: (readonly [readonly string[], string, string])[] = [ [['test', 'tests', '__tests__', 'spec', 'specs', 'e2e'], '🧪', 'files.glyph.tests'], [['doc', 'docs', 'documentation'], '📚', 'files.glyph.docs'], @@ -89,17 +68,11 @@ const DIR_BUCKETS: (readonly [readonly string[], string, string])[] = [ [['assets', 'static', 'public', 'images', 'fonts', 'icons', 'media'], '🎨', 'files.glyph.assets'], ] -/** Lockfile base names that do not end in `.lock`. */ const LOCK_NAMES = ['package-lock.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json'] const MAKE_NAMES = ['makefile', 'justfile', 'cmakelists.txt'] -/** A test file by name: a standalone or delimited `test`, or an inline `.test.`. */ const TEST_NAME = /(^|[^a-z0-9])test([^a-z0-9]|$)|\.test\./ -/** - * Programming-language files render as letter badges over their language's - * color (GitHub Linguist shades). Checked before the emoji buckets, so a - * language file never falls through to one. - */ +/** Language files render as letter badges over their language's color, before the emoji buckets. */ const CODE_LANGS: (readonly [readonly string[], string, string, string])[] = [ [['tsx'], 'TSX', '#3178c6', 'files.glyph.lang.ts'], [['ts'], 'TS', '#3178c6', 'files.glyph.lang.ts'], @@ -127,18 +100,13 @@ const CODE_LANGS: (readonly [readonly string[], string, string, string])[] = [ [['sql'], 'SQL', '#e38c00', 'files.glyph.database'], ] -/** - * Badge text by fill luminance: white on dark shades, near-black on light - * ones (the JS yellow, the shell green) — a fixed dark tone, never pure - * black, so it sits quietly next to the white badges. - */ +/** Badge text by fill luminance: white on dark shades, near-black (never pure black) on light ones. */ function badgeTextColor(hex: string): string { const n = parseInt(hex.slice(1), 16) const luminance = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255 return luminance < 0.6 ? '#ffffff' : '#1f2328' } -/** Extension buckets for non-code files, most specific first. */ const EXT_BUCKETS: (readonly [readonly string[], string, string])[] = [ [['yaml', 'yml', 'toml', 'ini', 'conf', 'cfg', 'properties', 'env'], '⚙️', 'files.glyph.config'], [['json', 'jsonc', 'json5', 'jsonl', 'ndjson', 'xml'], '🧾', 'files.glyph.data'], @@ -184,7 +152,6 @@ function fileGlyph(base: string): FileGlyph { return { glyph: '📄', tip: 'files.form.text' } } -/** The row icon for one file entry: form first (image/dir), then the file-name tables. */ export function glyphOf(path: string, form: FileForm): FileGlyph { if (form === 'image') return { glyph: '🖼', tip: 'files.form.image' } const trimmed = path.endsWith('/') ? path.slice(0, -1) : path @@ -194,27 +161,17 @@ export function glyphOf(path: string, form: FileForm): FileGlyph { const DRIVE_PATH = /^[a-zA-Z]:[\\/]/ -/** - * The absolute form of a real file path: verbatim when already absolute, - * resolved against the workspace root when relative, and undefined when a - * relative path has no root to resolve against (the system open can't reach - * it). Callers keep search-pattern "paths" away from here. - */ +/** The absolute form of a real file path: verbatim when already absolute, + * resolved against the workspace root when relative, undefined when a relative path has no root to resolve against. */ export function absPathOf(path: string, workspace: string | undefined): string | undefined { if (path.startsWith('/') || DRIVE_PATH.test(path)) return path if (workspace === undefined || workspace === '' || path.startsWith('.')) return undefined return workspace.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '') } -/** - * The right-Sidebar preview address of a real file — the session-scoped - * `dsh-resource://file/…` address the shipped preview type claims, built by - * the harness's own `fileAddressFor` (the files sidebar's idiom: a path under - * the workspace collapses to its session-relative spelling, so every route to - * one file settles on one tab). Undefined for a pathless search's PATTERN, a - * directory target, a missing session, and any path the encoder rejects — the - * caller falls back to the system opener or renders the name inert. - */ +/** The session-scoped `dsh-resource://file/…` preview address, built by the + * harness's own `fileAddressFor`, so every route to one file settles on one + * tab. Undefined for a search pattern, a directory, a missing session, or a path the encoder rejects. */ export function previewAddressOf( path: string, form: FileForm, @@ -226,18 +183,13 @@ export function previewAddressOf( try { return fileAddressFor(sessionId, workspace, path) } catch { - // A lone surrogate in an untrusted log path fails URI encoding; it is - // simply not previewable, never a thrown render. + // A lone surrogate in an untrusted log path fails URI encoding: not previewable, never a thrown render. return undefined } } -/** - * The row's display form of a path: inside the workspace (or already - * workspace-relative) it shortens to a './'-prefixed relative — an absolute - * path outside the workspace, a Windows drive path, and an already-'.' - * relative keep their verbatim form. - */ +/** The row's display path: inside the workspace (or already workspace-relative) + * it shortens to a './'-prefixed relative; other forms stay verbatim. */ export function displayPathOf(path: string, workspace: string | undefined): string { const root = workspace !== undefined && workspace.length > 1 ? workspace.replace(/\/+$/, '') : undefined if (root !== undefined) { @@ -248,11 +200,7 @@ export function displayPathOf(path: string, workspace: string | undefined): stri return './' + path } -/** - * Fold op records into per-file activity: scope-filtered, aggregated per - * path, ops newest first. The presentation half of the card — the parsing - * is the shared parser's (shared/fileOps.ts), however the records arrived. - */ +/** Fold op records into per-file activity: scope-filtered, aggregated per path, ops newest first. */ function aggregateOps(ops: readonly FileOp[], before: number | null): FileActivity { const totals: FileActivity['totals'] = { read: { files: 0, ops: 0 }, @@ -264,8 +212,7 @@ function aggregateOps(ops: readonly FileOp[], before: number | null): FileActivi } const byPath = new Map() for (const op of ops) { - // A nested op's scope key is its parent result's seq (the run_code result - // is what the step's surface shows) — the legacy node-level filter's rule. + // A nested op's scope key is its parent result's seq. const key = op.parent ?? op.seq if (before !== null && key >= before) continue totals[op.kind].ops++ @@ -310,11 +257,7 @@ function aggregateOps(ops: readonly FileOp[], before: number | null): FileActivi return { entries, totals } } -/** - * The op-log generation's read of the card: the fold-derived records (the - * detail payload's `fileOps`), with `gone` joined from the detail's archive - * at render time (the op's result node leaving the live surface bounds where - * its content stays viewable — the locate bridge reads it). +/** The op-log generation's read of the card: fold-derived records, with `gone` joined from the detail's archive at render time. */ export function activityOfOps(ops: readonly FileOpRecord[], archive: readonly SurfaceNode[], before: number | null): FileActivity { const goneBySeq = new Map() @@ -326,7 +269,6 @@ export function activityOfOps(ops: readonly FileOpRecord[], archive: readonly Su return aggregateOps(joined, before) } -/** One settled nested call of a Code-Mode tree, as far as the legacy join consumes it. */ interface SubCall { name: string argsRaw: string @@ -336,12 +278,7 @@ interface SubCall { subCalls?: readonly unknown[] } -/** - * Narrow one block of a conversation node's `subCalls` tree to a settled - * nested call, or null. The join is defensive — a running call has no result - * kind yet, and any malformed block is dropped, never thrown. (Null and - * non-object blocks never reach here: the folding loop pre-filters them.) - */ +/** Narrow one block of a node's `subCalls` tree to a settled nested call; a running or malformed block is dropped. */ function subCallOf(block: unknown): SubCall | null { const b = block as Record if (b.kind !== 'tool-result') return null @@ -365,19 +302,12 @@ function programOf(conv: ConversationNodeLike | undefined): string | undefined { return typeof description === 'string' && description !== '' ? description : undefined } -/** - * Depth guard for nested Code-Mode trees. The SDK bindings exclude `run_code` - * itself, so a real tree is one level deep; the cap only bounds defensive - * re-entry over a malformed join. - */ +/** Depth guard for nested Code-Mode trees: the SDK bindings exclude `run_code` itself, so a real tree is one level deep. */ const SUBCALL_MAX_DEPTH = 8 -/** - * The legacy nested walk: every settled sub-dispatch whose arguments resolve - * to a file target books one op, attributed to the nested tool and located - * on the parent run_code result. `seen` holds the already-visited blocks, so - * a malformed (cyclic) join cannot loop. - */ +/** The legacy nested walk: every settled sub-dispatch whose arguments resolve to + * a file target books one op, located on the parent run_code result. `seen` + * holds visited blocks, so a malformed cyclic join cannot loop. */ function foldSubCalls( blocks: readonly unknown[], parent: SurfaceNode, @@ -407,13 +337,8 @@ function foldSubCalls( } } -/** - * The INLINE generation's derivation: ops from the served tool-result nodes - * joined with the conversation window (arguments/meta live on the join — - * window-bound; the op-log generation covers the full session instead). - * One node's join data can never take the card down: anything that throws - * while folding it drops that node and the walk carries on. - */ +/** The INLINE generation's derivation from the served nodes joined with the + * conversation window (window-bound). Anything that throws while folding a node drops that node and the walk carries on. */ export function activityOf( nodes: SurfaceNode[], convOf: (seq: number) => ConversationNodeLike | undefined, @@ -442,20 +367,13 @@ export function activityOf( foldSubCalls(subCalls, n, programOf(conv), ops, new Set(), 1) } } catch { - // Unreachable with well-formed join data; the guard exists so it can - // never matter. + // The guard exists so malformed join data can never matter. } } return aggregateOps(ops, null) } -/** - * The browser step whose assembled surface SHOWS an op's result node: the - * first request dispatched after it while it was still alive (that step's - * brief is where the result landed). A live node no request has consumed - * yet reveals on the live surface; an archived node no retained step still - * contains is not viewable anywhere (null → the row stays inert). - */ +/** The browser step whose surface SHOWS an op's result node: the first request dispatched after it while it was still alive. */ export function locateStepOf(requests: RequestRecord[], seq: number, gone: number | undefined): number | 'live' | null { for (const r of requests) { if (r.seq > seq && (gone === undefined || gone > r.seq)) return r.seq diff --git a/src/client/format.ts b/src/client/format.ts index cfa5fa1..d387c2d 100644 --- a/src/client/format.ts +++ b/src/client/format.ts @@ -1,5 +1,3 @@ -/** `fmt`: the k/M/B suffix style shared by bars/details/stats; `fmtTime`: local HH:MM:SS. */ - export function fmt(n: number | null | undefined): string { if (n === undefined || n === null || isNaN(n)) return '—' const sign = n < 0 ? '-' : '' @@ -10,7 +8,6 @@ export function fmt(n: number | null | undefined): string { return sign + String(Math.round(a)) } -/** Byte sizes for attachment metadata (1 kB = 1000 B, matching the k/M style of `fmt`). */ export function fmtBytes(n: number | null | undefined): string { if (n === undefined || n === null || isNaN(n) || n < 0) return '—' if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB' @@ -18,35 +15,26 @@ export function fmtBytes(n: number | null | undefined): string { return String(Math.round(n)) + ' B' } -/** - * Cache-hit share of billed prompt-side input (`reads` over `billed`), - * TRUNCATED to two decimals (cut, not round) — same formula as the harness - * chat stats line's '缓存命中' figure and the token card's corner. Null when - * nothing was billed. The 1e-9 epsilon absorbs only float noise (integer - * token counts never sit that close to a boundary). - */ +/** Cache-hit share of billed prompt input, TRUNCATED to two decimals (the figure + * the harness chat stats line and the token card's corner show); the epsilon + * absorbs float noise only. Null when nothing was billed. */ export function cacheHitPercent(reads: number, billed: number): string | null { if (!(billed > 0)) return null const scaled = Math.trunc((reads / billed) * 100 * 100 + 1e-9) return `${Math.floor(scaled / 100)}.${String(scaled % 100).padStart(2, '0')}` } -/** One shared formatter: building an Intl formatter per call costs ~50x the format itself (issue #116). */ +/** One shared formatter: building an Intl formatter per call costs ~50× the format itself. */ const TIME_FMT = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) export function fmtTime(t: number): string { - // en-GB 24-hour clock zero-pads HH:MM:SS without a helper; invalid dates must show '—' (DateTimeFormat.format throws RangeError). + // en-GB zero-pads HH:MM:SS; an invalid date must show '—' (format throws RangeError). const d = new Date(t) if (isNaN(d.getTime())) return '—' return TIME_FMT.format(d) } -/** - * Share of a whole as a compact leading percentage for the slice rows: - * '—' when nothing totals, '0.0%' for empty slices, '<0.1%' for non-zero - * crumbs a 0.1%-precision figure would erase. One decimal everywhere; shares - * cap at 100% (parallel tool time can over-run the wall it belongs to). - */ +/** Share of a whole: '<0.1%' for non-zero crumbs a 0.1%-precision figure would erase; shares cap at 100%. */ export function fmtShare(part: number, total: number): string { if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) return '—' if (part <= 0) return '0.0%' @@ -55,12 +43,7 @@ export function fmtShare(part: number, total: number): string { return `${pct.toFixed(1)}%` } -/** - * Whole-session durations for the timing card, locale-free compact units: raw - * ms under a second, one-decimal seconds under a minute, then m/s and h/m. - * Non-finite or non-positive input shows the dash (callers render their empty - * state anyway). - */ +/** Whole-session durations in locale-free compact units; non-positive input shows the dash. */ export function fmtDuration(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return '—' if (ms < 1000) return `${Math.round(ms)}ms` diff --git a/src/client/globals.d.ts b/src/client/globals.d.ts index 8fd1f50..fce1848 100644 --- a/src/client/globals.d.ts +++ b/src/client/globals.d.ts @@ -1,25 +1,16 @@ -/** - * Runtime globals of the browser bundle. The client half ships as a CJS - * closure inside the web boot handoff: React and other platform modules - * arrive through the injected `require`, and the handoff's `module.exports` - * is what the loader reads. Declared here so the strict typecheck sees them. - */ +/** Runtime globals of the browser bundle's CJS closure, declared for the strict typecheck. */ declare function require(id: string): unknown declare let module: { exports: Record } declare let exports: Record -// Stylesheet imports resolve through the bundle's CSS channels (see -// tsdown.config.ts): side-effect global sheets, ?inline text, and -// CSS Modules class maps. +// Stylesheet channels (see tsdown.config.ts): side-effect global sheets, ?inline text, CSS Modules maps. declare module '*.css' declare module '*.module.css' { const classes: Record export default classes } -// The package emblem's static file (../icon.svg) rides the same build-time -// channel idea: `?raw` inlines the file's markup as the default export. declare module '*.svg?raw' { const markup: string export default markup diff --git a/src/client/headline.ts b/src/client/headline.ts index 4312276..271bc67 100644 --- a/src/client/headline.ts +++ b/src/client/headline.ts @@ -1,47 +1,23 @@ -/** - * Provider-anchored headline derivation, shared by the Context tab and the - * /context popup: the best-known occupancy of the next request, the route - * capacity it scales against, and the composition parts anchored to that - * total (heuristic ratios, provider-anchored sum). - * - * Two official token-meter projections feed it, the same keys the chat's - * composer ring reads: `contextPressure` supplies the anchored total (the - * ring's percentage), and `contextBreakdown` supplies the composition - * counts (the ring panel's system/tools/messages `~` rows) so the legend - * figures stay identical to the panel's by construction. Either key absent - * (older harness, meter not composed) degrades to the fold's own sums — - * the same fixed estimator. - * - * The percent is the kernel's own reading (`occupancyPercent`, below) at one - * decimal instead of the ring's integer — same numerator, denominator, and - * clamp, so the two surfaces can never disagree on the value. - */ +/** Provider-anchored headline derivation for the Context tab and the /context popup: the two official + * token-meter projections the chat's composer ring also reads — `contextPressure` for the anchored total, + * `contextBreakdown` for the composition counts — so the legend matches the ring panel by construction. */ import type { ContextBreakdown, ContextPressure, ContextTimeline } from '../shared/types' import { anchoredParts, officialParts, type PartsPart } from './categories' export interface Headline { - /** Best-known occupancy of the next request (projected ?? sampled ?? derived ?? heuristic total). */ tokens: number window?: number - /** tokens / window on the kernel's occupancy grid (one decimal), clamped to 100; null without a window. */ pct: number | null - /** - * Composition parts: `value` is the provider-anchored bar width (the - * ring's fill), `raw` the official heuristic count (the ring panel's - * rows) shown by the legend and tooltips. - */ + /** Composition parts: `value` is the provider-anchored bar width (the ring's fill), `raw` the official + * heuristic count the legend and tooltips show. */ parts: PartsPart[] } /** - * Occupancy percent of one reading — the kernel's own composer-ring - * derivation (`@deepseek-ai/dsh-client-ui-conversation`'s `contextOccupancy`: - * `Math.min(100, Math.round(used / window * 100))`, `used` = - * `projectedTokens ?? pressureTokens`) on a 0.1 grid instead of an integer - * one. Same numerator, denominator, and clamp, so every plugin surface - * prints the value the harness itself computes and only the precision of the - * printed reading is the plugin's. Callers prove `window > 0`. + * The kernel's own composer-ring derivation (ui-conversation's `contextOccupancy`: + * `Math.min(100, Math.round(used / window * 100))`, `used` = `projectedTokens ?? pressureTokens`) on a + * 0.1 grid. Callers prove `window > 0`. */ export function occupancyPercent(tokens: number, window: number): number { return Math.min(100, Math.round(tokens / window * 1000) / 10) @@ -53,26 +29,19 @@ export function headlineOf( breakdown: ContextBreakdown | null = null, ): Headline { const current = data.current - // The official projection's whole value is the newest usage sample carried - // forward by the heuristic surface movement since it was taken — the same - // formula the chat ring displays. Fields are last-wins; absent until a - // provider reports usage. + // Same formula the chat ring displays: the official projection is the newest usage sample carried + // forward by the heuristic surface movement since it was taken. const projected = pressure !== null && typeof pressure.projectedTokens === 'number' ? pressure.projectedTokens : undefined - // The kernel's own fallback order (`contextOccupancy`): the bare provider - // sample. A real fold stamps `pressureTokens` and the surface sample it was - // taken at together, so `projectedTokens` always rides along (only a foreign - // or hand-edited cache row can drop it); keeping the order means even that - // value reads the kernel's way. + // The kernel's own fallback order (`contextOccupancy`): the bare provider sample. A real fold stamps + // `pressureTokens` and the surface sample together, so the order matters only for foreign or hand-edited rows. const sampled = pressure !== null && typeof pressure.pressureTokens === 'number' ? pressure.pressureTokens : undefined - // Fallback anchor: the newest request's provider prompt plus the heuristic - // surface movement since it was logged (same shape as the projection, one - // request behind). The split-generation wire head carries that record's - // billing summary as `last` (the request records ride the detail channel); - // the inline generation reads its newest record directly. + // Fallback anchor, one request behind the projection: the newest request's provider prompt plus the + // heuristic movement since it was logged. The split-generation wire head carries that record's billing + // summary as `last` (request records ride the detail channel); the inline generation reads its newest record. const last = data.last const requests = data.requests const lastReq = requests.length > 0 ? requests[requests.length - 1] : null diff --git a/src/client/historyPage.ts b/src/client/historyPage.ts index 81d0e93..8da25ae 100644 --- a/src/client/historyPage.ts +++ b/src/client/historyPage.ts @@ -1,24 +1,14 @@ /** - * Targeted full-content fetch for the Context browser — the fallback that - * replaces blind tail paging. When a surface node's seq is outside the - * conversation window, ONE seq-anchored history read returns the page - * containing that event: the host cuts pages on whole append-origin message - * boundaries, so the newest group on the page covers `seq` whenever the - * durable log still holds it. The read rides the harness gateway remotes - * (`remote.session.page`, with the inclusive cut `throughSeq` pinned to the - * target seq) and the raw events map into the same conversation-node shapes - * the window join delivers (a thin display subset of dsh's own fold). - * Fetched nodes cache per session — history is immutable, so a seq never - * needs fetching twice. + * Targeted full-content fetch for the Context browser, replacing blind tail + * paging: ONE seq-anchored history read (`remote.session.page`, inclusive + * `throughSeq` cut pinned to the target) returns the page holding that event, + * since the host cuts pages on whole message boundaries. Fetched nodes cache per + * session because history is immutable. * * The remote face is resolved through the DECLARED inject - * (`watchHistoryFaces`): this plugin's module inject lists only slots/ - * locale, and NONDECLARED reads of the traced service proxy throw "cannot - * get property … without inject" and can take a view down. The injection - * callback runs under a fiber that declares both `remote` and - * `remote.session` (the dsh idiom), so the property path resolves there and - * nowhere else; the resolved face is re-proved and a hostile read leaves - * the slot unset instead of ever throwing. + * (`watchHistoryFaces`): a NONDECLARED read of the traced service proxy throws + * ("cannot get property … without inject") and can take a view down, so the + * callback declares both `remote` and `remote.session` in one fiber. */ import { useSyncExternalStore } from 'react' @@ -28,14 +18,10 @@ import type { } from './services' import type { HeaderEpochContent } from '../shared/types' -/** Narrow one served row to a validated durable event envelope, or null. */ function eventOf(entry: unknown): { type: string; seq: number; data: Record } | null { if (entry === null || typeof entry !== 'object') return null - // History records wrap the envelope in an `event` field - // (`{type:'event'|'chunks', event}`); a bare envelope passes too, so shape - // drift degrades instead of breaking the page. Packed chunk-row records - // flow through as unknown types and project to nothing downstream — only - // final events matter here. + // History records wrap the envelope in an `event` field; a bare envelope + // passes too, and packed chunk rows project to nothing downstream. const inner = (entry as HistoryEntryLike).event ?? entry if (typeof inner !== 'object') return null const e = inner as { type?: unknown; seq?: unknown; data?: unknown } @@ -44,7 +30,6 @@ function eventOf(entry: unknown): { type: string; seq: number; data: Record { const b = block !== null && typeof block === 'object' ? block as { type?: unknown; text?: unknown; attachment?: unknown; name?: unknown; arguments?: unknown } : null switch (b?.type) { @@ -98,13 +75,8 @@ function assistantBlockOf(block: unknown): Record { } } -/** - * Map one history page into joined conversation nodes keyed by their event - * seq — the display subset of the browser's join: user messages, assistant - * blocks, tool results paired with their in-page call head, and compaction - * checkpoints paired with their summary event. Everything else (headers, - * boundaries, chunks, bare calls) projects to nothing. - */ +/** Map one history page into joined conversation nodes keyed by event seq — + * the display subset of the browser's join; headers, boundaries, chunks, and bare calls project to nothing. */ export function pageNodesOf(entries: readonly unknown[]): Map { const nodes = new Map() const calls = new Map() @@ -137,9 +109,8 @@ export function pageNodesOf(entries: readonly unknown[]): Map : null const source = message?.source !== null && typeof message?.source === 'object' ? message.source as Record : null const callId = typeof message?.toolCallId === 'string' ? message.toolCallId @@ -183,9 +153,7 @@ export function pageNodesOf(entries: readonly unknown[]): Map void): () => void { faceListeners.add(listener) return () => { faceListeners.delete(listener) } } -/** The store snapshot both useSyncExternalStore seats read (client and hydration). */ function faceSnapshot(): SessionPageFace['page'] | undefined { return declaredPage } -/** - * The React seat over the resolved page face. A mount can RACE the declared - * inject — a watch rebuild (patchReload) remounts the slot components before - * the injected fiber re-fires — so the first render may legitimately see no - * face. Subscribing keeps that transient state from sticking: the fetchers - * derived downstream rebuild when the face lands (or is revoked), instead of - * degrading to the static note for the mount's whole lifetime. - */ +/** The React seat over the resolved page face. A mount can RACE the declared + * inject (a watch rebuild remounts slot components before the injected fiber + * re-fires), so the first render may see no face; subscribing rebuilds the downstream fetchers when it lands or is revoked. */ export function useHistoryFace(): SessionPageFace['page'] | undefined { return useSyncExternalStore(subscribeHistoryFace, faceSnapshot, faceSnapshot) } -/** - * Register the plugin's history face with the harness through the DECLARED - * inject — both `remote` AND `remote.session` (the ui-chat idiom) must be in - * one fiber's requirement list, because the traced `remote` proxy resolves - * `.session` through the context and each name needs the other's - * declaration; an undeclared read of the traced proxy throws instead of - * resolving. The callback re-runs on every unload/remount, so it owns the - * slot's lifetime. The face itself is re-proven: a never-fired invocation - * or a hostile property read leaves the slot unset — nothing here can throw. - */ +/** Register the plugin's history face through the DECLARED inject: `remote` and + * `remote.session` must sit in one fiber's requirement list, since the traced + * proxy resolves `.session` through the context and an undeclared read throws. + * The callback re-runs per unload/remount and owns the slot's lifetime. */ export function watchHistoryFaces(ctx: ClientCtx): void { ctx.inject(['remote', 'remote.session'], (c) => { try { @@ -261,11 +212,9 @@ export function watchHistoryFaces(ctx: ClientCtx): void { }) } -/** The rows array of a successful history page, under the served envelope. */ function rowsOf(response: unknown): readonly unknown[] { - // Envelope unwrapping: the remote resolves to the ClientResult itself - // ({ok, value}). A bare {records} payload passes too. Anything else - // rejects so the caller can offer a retry instead of claiming absence. + // The remote resolves to the ClientResult itself ({ok, value}); a bare + // {records} payload passes too. Anything else rejects, so the caller can offer a retry instead of claiming absence. let payload: unknown = response if (payload !== null && typeof payload === 'object' && 'ok' in payload) { const r = payload as { ok?: unknown; value?: unknown } @@ -280,22 +229,13 @@ function rowsOf(response: unknown): readonly unknown[] { return rows } -/** - * The session's history reader over the gateway remotes (`remote.session.page`), - * from the declared-inject slot. The page cuts message-aligned pages, so the - * returned read covers `seq` whenever the durable log still holds it: the - * inclusive cut is pinned to the seq itself, the exclusive bound one past - * it. Undefined when the slot holds no face (the inject never fired or - * served no usable page verb) — callers keep their static degradation. +/** The session's history reader from the declared-inject slot, with the cut + * pinned to the seq (`beforeSeq` one past it). Undefined when the slot holds no face — callers keep their static degradation. */ function pageReaderOf(sessionId: string): ((seq: number) => Promise) | undefined { - // The face resolved at build time: absent face = the caller's static - // degradation, a resolved face stays bound for the fetcher's lifetime. const page = declaredPage if (page === undefined) return undefined return (seq) => { - // The inclusive log cut pinned to the target seq, the exclusive bound - // one past it. return page({ address: { kind: 'session' as const, sessionId }, throughSeq: seq, @@ -304,39 +244,25 @@ function pageReaderOf(sessionId: string): ((seq: number) => Promise) | } } -/** - * Build the browser's per-session fetcher over the gateway history face - * (`remote.session.page`): message-aligned pages whose inclusive cut pinned - * to `seq` (exclusive bound one past it) cover the seq whenever the durable - * log still holds it. Undefined when no face was resolved at build time — - * the caller keeps its static preview-plus-hint degradation. Found nodes - * cache in the closure: one mount re-reading a row never re-fetches. - */ +/** Build the browser's per-session fetcher over the gateway history face, or + * undefined when no face resolved at build time (the caller keeps its static preview-plus-hint degradation). */ export function makeContentFetcher(sessionId: string): ContentFetcher | undefined { const read = pageReaderOf(sessionId) if (read === undefined) return undefined - // Fetched nodes cache in the closure: history is immutable, so one mount - // re-reading a row never re-fetches. const cache = new Map() return async (seq) => { const hit = cache.get(seq) if (hit !== undefined) return hit - // The whole envelope is re-proven (no-white-screen guarantee): a failed or - // malformed read rejects so the browser offers a retry instead of guessing. const node = pageNodesOf(rowsOf(await read(seq))).get(seq) ?? null if (node !== null) cache.set(seq, node) return node } } -/** - * Map one raw durable event into the epoch content the browser renders. A - * `request/header` yields each tool's producer description and raw schema; a - * `system/message` yields the prompt text alone (its tools live in the - * request header). Both mirror the host fold's per-entry guards — a null or - * primitive tool entry degrades to an unnamed row instead of throwing the - * read. Null when the event carries neither. - */ +/** Map one raw durable event into the epoch content the browser renders: a + * `request/header` yields each tool's description and raw schema, a + * `system/message` the prompt text alone. A null or primitive tool entry + * degrades to an unnamed row instead of throwing the read. */ function headerContentOf(event: { type: string; data: Record }): HeaderEpochContent | null { const { type, data } = event if (type === 'system/message') { @@ -365,17 +291,9 @@ function headerContentOf(event: { type: string; data: Record }) return { tools } } -/** - * The on-demand CONTENT fetch for the browser's System and Tools sections — - * the lazy counterpart of the node fetcher above. One seq-anchored history - * read off the requested seq returns the page holding that event (non-message - * events ride the page verbatim); a `request/header` maps to the epoch's - * tools, a `system/message` to the prompt's text. Landed content caches per - * seq (history is immutable), and OLDER - * epochs sharing the page cache for free — stepping back through epochs walks - * the same pages. Undefined when no history face exists — the browser keeps a - * metadata-only degradation instead. - */ +/** The on-demand CONTENT fetch for the browser's System and Tools sections: one + * seq-anchored read returns the page holding the event, and landed content + * caches per seq so stepping back through epochs reuses the same pages. Undefined when no history face exists. */ export function makeHeaderFetcher(sessionId: string): HeaderFetcher | undefined { const read = pageReaderOf(sessionId) if (read === undefined) return undefined @@ -383,8 +301,6 @@ export function makeHeaderFetcher(sessionId: string): HeaderFetcher | undefined return async (seq) => { const hit = cache.get(seq) if (hit !== undefined) return hit - // Same re-prove contract as the node fetcher: a failed or malformed read - // rejects so the browser offers a retry instead of guessing. const rows = rowsOf(await read(seq)) let picked: HeaderEpochContent | null = null for (const entry of rows) { @@ -392,8 +308,7 @@ export function makeHeaderFetcher(sessionId: string): HeaderFetcher | undefined if (ev === null || (ev.type !== 'request/header' && ev.type !== 'system/message')) continue const content = headerContentOf(ev) if (content === null) continue - // The page's exclusive bound is seq + 1, so every content event on it - // is the picked one or an OLDER one — cache them all. + // The page's exclusive bound is seq + 1, so every content event on it is the picked one or an OLDER one — cache them all. cache.set(ev.seq, content) if (ev.seq === seq) picked = content } diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 082c0b3..fc4738b 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -1,7 +1,5 @@ -/** - * Bilingual dictionaries (zh/en) for every UI string; missing keys resolve - * through the harness locale service (active → en → common → the key itself). - */ +/** Bilingual dictionaries (zh/en) for every UI string; missing keys fall through the harness + * locale chain (active → en → common → the key). */ export type Translate = (key: string, params?: Record) => string @@ -327,8 +325,7 @@ export const DICT_ZH: Record = { 'block.content': '内容', 'block.result': '结果', 'block.summary': '摘要', - // Line-count chip on a tool-result's payload card (the singular/plural - // split is a no-op in zh, where the measure word never inflects). + // The zh measure word never inflects, so the singular/plural forms are identical. 'block.line': '{n} 行', 'block.lines': '{n} 行', 'call.ok': '正常', @@ -354,8 +351,6 @@ export const DICT_ZH: Record = { 'ov.range.7d': '近 7 天', 'ov.range.30d': '近 30 天', 'ov.range.all': '全部', - // The custom date range (components/dateRange.tsx) — the head's fifth - // choice, beside the presets above. 'ov.date.label': '日期范围', 'ov.date.open': '选择自定义日期范围', 'ov.date.from': '开始日期', @@ -812,8 +807,6 @@ export const DICT_EN: Record = { 'ov.range.7d': 'Last 7 days', 'ov.range.30d': 'Last 30 days', 'ov.range.all': 'All', - // The custom date range (components/dateRange.tsx) — the head's fifth - // choice, beside the presets above. 'ov.date.label': 'Date range', 'ov.date.open': 'Pick a custom date range', 'ov.date.from': 'Start date', diff --git a/src/client/icon.tsx b/src/client/icon.tsx index af2dacc..d942ea8 100644 --- a/src/client/icon.tsx +++ b/src/client/icon.tsx @@ -1,30 +1,17 @@ -/** - * The Context emblem: the colourful document sheet, bundled rather than read - * off the harness primitives so the plugin's identity is self-contained. - * - * The single graphic source is the package-root `icon.svg` — the same file - * package.json `icon` hands to the Host's package-meta reader, which serves - * it to the Plugins page's package cards. The client bundle inlines the - * file's markup at build time (the `?raw` channel in tsdown.config.ts) and - * re-renders it at every requested size. The identity seats (tab chip, - * command, sidebar registration) keep the sheet's fixed fills — - * deliberately polychrome on both light and dark chrome — while the - * sidebar's panel-list seat (insightPage.ts) opts into `mono`: the sheet in - * the surrounding text colour at the harness's own line weight, so the - * shell-owned row weighs what the shipped rows beside it weigh. - */ +/** The Context emblem: the document sheet bundled from the package-root `icon.svg` (the file package.json + * `icon` hands the Host) rather than read off the harness primitives, so the plugin's identity is + * self-contained. The identity seats keep the sheet's fixed fills; only the sidebar's panel-list seat + * opts into `mono`, the harness's own line weight. */ import sheetMarkup from '../../icon.svg?raw' import { useId, type ReactElement } from 'react' import type { Translate } from './i18n' -/** Everything between the file's `` tags: the sheet's strokes in paint order, whitespace-folded. */ const SHEET_MARKUP = sheetMarkup .slice(sheetMarkup.indexOf('>') + 1, sheetMarkup.lastIndexOf('')) .trim() .replace(/>\s+<') -/** The same strokes with their palette dropped — the shape alone, which the mono seat's masks paint. */ const SHEET_OUTLINE = SHEET_MARKUP.replace(/fill="#[0-9A-Fa-f]{6}"/g, '') /** The harness icon set's line weight: one unit of its 16-unit box, which this artboard spells as 64. */ @@ -33,31 +20,19 @@ const HARNESS_STROKE = 1024 / 16 /** The source artboard's own bar weight: every pill and the dot measures 98 to 101 units. */ const SHEET_STROKE = 98 -/** What the mono seat's mask takes off each side of every stroke to reach the harness weight. */ const INSET = (SHEET_STROKE - HARNESS_STROKE) / 2 /** The mask canvas: the artboard grown past every shifted copy of itself, so no edge clips. */ const CANVAS = { x: -2 * INSET, y: -2 * INSET, width: 1024 + 4 * INSET, height: 1024 + 4 * INSET } const CANVAS_ATTRS = `x="${CANVAS.x}" y="${CANVAS.y}" width="${CANVAS.width}" height="${CANVAS.height}"` -/** One canvas-covering rect — the mask flood, a shift's paint, or the mono seat's own ink. */ const canvasRect = (fill: string, rest = ''): string => `` -/** The axes the mono seat erodes along, one shift per direction. */ const SHIFTS: [number, number][] = [[INSET, 0], [-INSET, 0], [0, INSET], [0, -INSET]] -/** - * The mono seat's markup: the sheet masked down to the harness line weight - * rather than redrawn — the plugin ships ONE artwork, and the shell's rows - * want thin strokes. Erosion is the intersection of a shape with its own - * copies shifted along each axis; a mask intersects as a white flood with - * one black shift per direction, every shift painted through the sheet's - * own complement (the `-hole` mask), which is the only copy of the drawing. - * Compositing rather than a filter: `feMorphology` rounds its radius to - * whole device pixels, so it would thin the glyph on a 2× display and leave - * it untouched (or over-thin it) at 1×. - * @param id - this instance's def prefix; two emblems on a page may not share mask ids. - */ +/** The mono seat's markup: the sheet masked down to the harness line weight rather than redrawn — the plugin + * ships ONE artwork. Compositing, not `feMorphology`: that filter rounds its radius to whole device pixels + * and would thin the glyph on a 2× display but not at 1×. */ function monoMarkup(id: string): string { const hole = `${id}-hole` const thin = `${id}-thin` @@ -75,18 +50,14 @@ function monoMarkup(id: string): string { /** The emblem's props, matching the harness `IconProps` the guide capsule hands it. */ export interface ContextIconProps { - /** Square edge in px. */ size?: number - /** Extra class for layout placement. */ className?: string /** The sidebar panel-list seat: the surrounding text colour at the harness line weight, not the palette. */ mono?: boolean } -/** The document sheet at the requested square edge — polychrome by default, the shell row's own weight in `mono`. */ export function ContextIcon({ size = 20, className, mono = false }: ContextIconProps): ReactElement { - // Mask defs are per instance: a second emblem on the page would otherwise - // resolve `url(#…)` against this one's defs, and lose them when it unmounts. + // Mask defs are per instance: a second emblem would otherwise resolve `url(#…)` against this one's defs. const id = `dsh-context-sheet-${useId().replaceAll(':', '')}` return ( } -/** - * The tab chip's title seat (`sidebar.right.pane.tab.title`): the emblem before - * the label, so the chip reads as the files chip does. The label comes from the - * plugin's own bound translate — read at render, so the chip follows the active - * locale — rather than the tab-information hook, which a foreign or - * not-yet-committed tab record can throw on. It carries a trailing gutter - * (`.lc-title-label`) so the active chip's fade lands past the text, never on - * the last glyphs. - * @param t - the plugin-namespace translate bound in `apply`. - * @returns the title component to register under the tab type's id. - */ +/** The tab chip's title seat: the emblem before the label, read through the plugin's own bound translate so + * the chip follows the locale — not the tab-information hook, which a not-yet-committed tab record can throw + * on. The label's trailing gutter (`.lc-title-label`) keeps the active chip's fade off the last glyphs. */ export function makeContextTabTitle(t: Translate): () => ReactElement { return function ContextTabTitle(): ReactElement { return ( diff --git a/src/client/index.ts b/src/client/index.ts index 2f7058b..5d2e042 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,26 +1,6 @@ -/** - * dsh-context — Client half (installed package bundle entry). - * - * Registers a "上下文/Context" tab in the conversation view ring - * (`conversation.view` slot, beside Chat/Trajectory) and renders the - * context-composition timeline: current makeup, per-request stacked-bar - * history, context events, and the live message list. - * - * Since v0.9 the tab rides the harness's session-projection pipeline - * (`contextTimeline` projection key), read from the framework standard kit - * (`useProjection('contextTimeline')`, a standard prop on every session-scope - * slot component). The wire value is the split generation's slim head; the - * heavy collections arrive on demand from the host's detail endpoint, one - * read per viewing client (timelineSource.ts). No polling, no stale-while- - * revalidate cache. - * - * This module is the body of the package's `./client` bundle: tsdown - * (tsdown.config.ts) bundles it (external `react` — the browser module table - * supplies it via the injected `require`) into the web boot handoff - * (`window.__ModuleLoader__.load({id, factory})`). All imports from other - * client modules are inlined by the bundler; everything here is zero-runtime - * beyond the bundled source. - */ +/** dsh-context — client half bundle entry. The Context view reads the harness session projection + * `contextTimeline` (the split generation's slim head); heavy collections arrive on demand from the + * host's detail endpoint, one read per viewing client (timelineSource.ts). */ import { createElement as h } from 'react' import { DICT_EN, DICT_ZH } from './i18n' @@ -39,12 +19,8 @@ import { watchPlacement } from './placement' import { watchSidebarContextTab } from './sidebar' import { makeViewKit } from './viewkit' -// Theme-native styles: the bundle's global-CSS channel injects each sheet as -// a plugin-owned