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.
This commit is contained in:
bowenliang123 committed 2026-10-10 00:26:03 +08:00
1 parent 3fe1b7cf77
commit 7cb0a9fa46
89 files changed
+1212 -4659

No files matched your search

+6 -18
View File
@@ -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<ContextTimeline | null>
}
/** One page-scope fetch cache, shared by every consumer the caller wires it into. */
export function makeAgentHeads(): AgentHeads {
const heads = new Map<string, Promise<ContextTimeline | null>>()
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])
+49 -155
View File
@@ -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<unknown>
}
/** 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<string, unknown> | undefined): AgentStats {
const timeline = timelineOf(values?.contextTimeline)
const pressure = contextPressureOf(values?.contextPressure)
@@ -201,9 +157,7 @@ export function agentStatsOf(values: Record<string, unknown> | 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<string, AgentRow>()
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<string>([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<string, AgentChild[]>()
@@ -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<string>): 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<string, AgentNode[]>()
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<string, number>()
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)
+11 -40
View File
@@ -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
}
+17 -63
View File
@@ -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<void> | 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<PlatformBalance | null> {
}
}
/**
* 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
}
+5 -25
View File
@@ -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])
+3 -12
View File
@@ -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[] = []
+16 -50
View File
@@ -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<Category | 'system' | 'tools', string>
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,
+7 -22
View File
@@ -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'
+7 -13
View File
@@ -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<ReadonlyMap<string, ContextTimeline>>(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<ContextTimeline | null>, 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 (
<AgentNodeView
+6 -21
View File
@@ -1,18 +1,7 @@
/**
* BalanceCapsule — the DeepSeek open-platform balance pill in the Context
* Insights page's heading (the plugin's one account-level figure, beside the
* session-level ones). Clicking it opens the platform's usage console in a
* new tab. Mounted only by the page heading; it paints the figure the
* previous open remembered while client/balance.ts revalidates it in the
* background, and renders NOTHING when nothing is remembered and the route
* answers nothing — the pill exists only when there is a figure to show. The
* non-zero parts of the breakdown (topped-up / granted) ride the page's
* portaled hover tip (hoverTip.tsx, off the pill's `data-lc-tip`) —
* immediate on hover, where a native `title` lags — in
* the entry's own currency (the pill itself carries the total); the entry
* follows the active locale (zh → CNY) with the account's first currency as
* the fallback.
*/
/** The DeepSeek open-platform balance pill in the Insights page heading. It paints the figure the previous open
* remembered while client/balance.ts revalidates in the background, and renders NOTHING when nothing is remembered and
* the route answers nothing. The breakdown rides the page's portaled hover tip off the pill's `data-lc-tip` (immediate,
* where a native `title` lags) in the entry's own currency; the pill carries the total. */
import { useState, type ReactElement } from 'react'
import { balanceEntryOf, readPlatformBalance } from '../balance'
@@ -27,23 +16,19 @@ function symbolOf(currency: string): string {
return currency + ' '
}
/** The platform console page behind the capsule's click — usage and top-ups. */
/** The platform console page behind the capsule's click. */
const USAGE_URL = 'https://platform.deepseek.com/usage'
export function makeBalanceCapsule(ctx: ClientCtx, kit: ViewKit): () => 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<PlatformBalance | null>(() => 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)] : []),
+59 -302
View File
@@ -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:<name>' (a tool schema), 'n<seq>' (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:<name>' (a tool schema), 'n<seq>' (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<string, unknown>
@@ -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 (
<div className="lc-ts-card">
<div className={'lc-ts-card-head' + (props.foldHead === true ? ' lc-ts-card-head-wrap' : '')}>
{/* The title recovers an ellipsized label: long mono call names truncate under width pressure. */}
<b className={props.labelClass} title={props.label}>{props.label}</b>
{right ? <span className="lc-ts-card-right">{props.meta}{props.actions}</span> : null}
{props.count !== undefined ? <span className="lc-ts-card-count">{props.count}</span> : 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 <div className="lc-br-note">{props.hint}</div>
}
@@ -578,24 +499,10 @@ function byCatOf(asm: Assembled): Partial<Record<Category, SurfaceNode[]>> {
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<string | null>(null)
const [openElem, setOpenElem] = useState<string | null>(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<RowKind | null>(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<DefaultToolSort>(() => 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<string | null>(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<DefaultDeltaBase>(() => 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<number, ConversationNodeLike>()
@@ -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<HTMLDivElement | null>(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<RequestRecord | null>((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<string, number>()
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 <span className="lc-br-tag">{tag}</span>
@@ -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 <div className="lc-br-note">{t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}</div>
}
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 <div className="lc-br-note">{headerNote}</div>
if (content.system === undefined) return <div className="lc-br-note">{t('browser.noSystem')}</div>
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 = (
<RowToolbar value={rowQuery} placeholder={t('tool.search')} tip={t('tool.sortTip')} onChange={setRowQuery}>
{(['size', 'count', 'name'] as const).map(k => (
@@ -1155,8 +955,6 @@ export function makeContextBrowser(
</div>
)
}
// 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 <div className="lc-br-note">{headerNote}</div>
const row = contentByName.get(tool.name)
@@ -1168,13 +966,8 @@ export function makeContextBrowser(
<div>
{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(
</div>
)
}
// 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
? <RowToolbar value={rowQuery} placeholder={t('browser.search.' + c)} onChange={setRowQuery} />
: (
@@ -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(
</span>
),
}}
// 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(
<StackedBar
parts={dnaParts ?? parts}
height={10}
// Highlight precedence: pointer hover (local in DNA mode, the shared link otherwise) over the open-category pin.
// The mirrored link (see `linked` above) works while the browser shows the live surface; it is ONE-WAY in DNA
// mode — an incoming category key lights that category's BANDS (the parts' `group`), while band hovers stay
// on the bar and never report upward (the overview keeps showing the live category composition). Tip stays
// off in category mode: a cross-card hover must not float a second tooltip over a bar the pointer does not
// rest on — and the group-keyed pin never exact-matches a DNA band, so the pinned highlight floats no tip.
// Highlight precedence: pointer hover (local in DNA mode, the shared link otherwise) over the open-category pin. In
// DNA mode the mirrored link is ONE-WAY: an incoming category key lights that category's bands, while band hovers stay
// on the bar and never report upward.
hoverKey={dna ? liveDnaKey ?? linkKey ?? pinKey : linkKey ?? pinKey}
onHoverKey={dna ? setDnaKey : linked ? props.onHoverKey : undefined}
tip={dna}
@@ -1463,9 +1223,6 @@ export function makeContextBrowser(
<span className={'lc-br-chev' + (open ? ' lc-br-chev-on' : '')} />
<i style={{ background: c.color }} />
<span className="lc-br-cat-label">{catLabel(c.key)}</span>
{/* Count + Δ pill sit as one attached group (tight inner gap),
the group absorbs the row's free space so tokens/percent
stay right-aligned. */}
<span className="lc-br-count-grp">
<span className="lc-br-cat-count">{t('browser.items', { n: count })}</span>
{countDelta !== null && countDelta !== 0 ? (
+8 -26
View File
@@ -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 (
<svg width="16" height="16" viewBox="0 0 16 16" className="fill-none" aria-hidden="true">
@@ -57,12 +43,9 @@ function JumpIcon(): ReactElement {
export function makeContextJumpButton(ctx: ClientCtx, kit: ViewKit): (props: ContextJumpProps) => ReactElement | null {
const { t } = kit
return function ContextJump(props: ContextJumpProps): ReactElement | null {
// The seat is read HERE — unconditionally and first, so the early return
// below keeps hook order — and the click consumes the captured pair.
// Read first and unconditionally, so the early return below keeps hook order.
const nodes = conversationNodesOf(props)
const messageId = props.messageId
// Interruption-frozen partials address no durable message — the owner
// already withholds them, and anything else non-string is ignored.
if (typeof messageId !== 'string' || messageId === '') return null
const seq = seqOfMessageId(nodes, messageId)
const jump = (): void => {
@@ -70,8 +53,7 @@ export function makeContextJumpButton(ctx: ClientCtx, kit: ViewKit): (props: Con
if (seq !== null && typeof sessionId === 'string' && sessionId !== '') {
requestContextFocus(sessionId, seq)
}
// The sidebar expands over the chat, keeping the clicked reply in view;
// a harness or placement without that tab keeps the tab activation.
// The sidebar expands over the chat, keeping the clicked reply in view; without that tab the activation stands alone.
if (!openContextSidebar(ctx)) activateContextTab(t('tab'))
}
return (
+9 -26
View File
@@ -1,8 +1,5 @@
/**
* The /context command's centered dialog — the same data as the Context tab (the pushed `contextTimeline` projection) distilled to the
* current-composition overview and the shared Context browser; rendered from the `conversation.input.overlay` slot and opened/closed
* through the per-session modal store, so the trigger flips it and no message ever enters session history.
*/
/** The /context command's centered dialog: the same pushed `contextTimeline` data as the Context tab, distilled to the
* current composition and the shared browser. */
import { createElement as h, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import { measureDock } from '../dockMeasure'
@@ -42,35 +39,26 @@ export function makeContextModal(
function ContextModalBody(props: ContextModalProps): ReactElement | null {
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
const open = typeof props.useContextModal === 'function' ? props.useContextModal(s => s) : false
// The timeline source (timelineSource.ts) — shares the tab's per-session
// detail store, so an open tab's detail serves the modal with no refetch.
// Shares the tab's per-session detail store, so an open tab's detail serves the modal with no refetch.
const source = useTimelineSource(props)
const data = source.data
const pressure = projectionOf(props, 'contextPressure', contextPressureOf)
const breakdown = projectionOf(props, 'contextBreakdown', contextBreakdownOf)
const headers = projectionOf(props, 'contextHeaders', headersOf)
// Conversation-window join for the browser (the seat is a real hook —
// read unconditionally here, before the closed early return, so the hook
// order stays stable across open/close).
// Read unconditionally, before the closed early return, so hook order stays stable across open/close.
const convNodes = conversationNodesOf(props)
const [hoverCat, setHoverCat] = useState<string | null>(null)
// Dock the mask between the shell sidebars: 0s until the frame measure
// lands (the layout effect below resolves it before first paint).
const [dock, setDock] = useState({ left: 0, right: 0 })
const backdropRef = useRef<HTMLDivElement | null>(null)
// Session-authorized durable-image loader for the browser's attachment cards, resolved through the harness `uiConversation` service
// (`imageUrl`); absent service/session degrades the cards to metadata-only, never an error. Same parity as the Context tab.
const loadImage = useMemo(
() => imageLoaderOf(ctx, sessionId !== '' ? sessionId : undefined),
[ctx, sessionId],
)
// The gateway page face, as a React seat: the first render can race the declared inject (a watch rebuild remounts this overlay before
// the fiber re-fires), and both fetchers below must rebuild — not stick to the static degradation — when the face lands or is revoked.
// The first render can race the declared inject (a watch rebuild remounts this overlay before the fiber re-fires),
// so both fetchers below must rebuild when the face lands or is revoked.
const historyFace = useHistoryFace()
// Same targeted content fetch the Context tab wires (one seq-anchored history read per expanded row), plus the on-demand header
// epoch content read for the browser's system/tools sections.
const fetchContent = useMemo(
() => (sessionId !== '' && historyFace !== undefined ? makeContentFetcher(sessionId) : undefined),
[sessionId, historyFace],
@@ -83,10 +71,7 @@ export function makeContextModal(
const close = useCallback(() => {
if (sessionId === '') return
modalStoreOf(sessionId).set(false)
// Consume the `/context` token now (it stayed in the composer while the modal was open) via the scoped input event — a stale guard
// (the user typed meanwhile) fails soft inside the shell and leaves the draft untouched. The sessions service is read at CLOSE time:
// capturing it at apply would race the finer module composition (`ctx.get` is the inject-free reflect read — undefined, never a
// throw, when the service is not composed).
// The sessions service is read at CLOSE time because capturing it at apply would race the finer module composition.
const guard = takePendingConsume(sessionId)
const sessions = ctx.get('sessions') as SessionsFace | undefined
if (guard === undefined || sessions === undefined) return
@@ -97,10 +82,8 @@ export function makeContextModal(
// Capture-phase Escape close + focus restore (the shared overlay contract).
useEscapeClose(open, close)
// Dock the mask to the main column: measure the sidebar tracks once
// before first paint, then follow the frame's inline template while open
// (either sidebar's drags, collapse toggles and narrow-viewport re-solves
// all rewrite it). An unresolved frame keeps the full-viewport mask.
// The sidebar tracks are measured once before first paint, then followed while open: drags, collapse toggles, and
// narrow-viewport re-solves all rewrite the frame's inline template.
useLayoutEffect(() => {
if (!open) return undefined
const dock = measureDock(backdropRef.current)
+49 -179
View File
@@ -1,10 +1,3 @@
/**
* Context tab root: renders the `contextTimeline` projection and composes stats, composition, history, events and messages.
* The data plane is the harness's projection pipeline end to end; the one addition is the split generation's on-demand
* detail read (timelineSource.ts) — the pushed wire value is the slim head, and the heavy collections arrive from the
* host's detail endpoint only while the tab (or modal) is open.
*/
import { createElement as h, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import type { Category, ContextEventRecord, RequestRecord, SurfaceNode } from '../../shared/types'
import { briefNodes, briefOf } from '../brief'
@@ -43,9 +36,8 @@ import { subscribeContextFocus, takeContextFocus } from '../viewFocus'
import { revealInScrollParent } from '../revealScroll'
import { makeErrorBoundary } from './errorBoundary'
// The context page scrolls inside the conversation's shared `[data-conversation-scroll]` container, which the chat bottom-anchors — mirror
// the chat's chatScroll pattern: a module-level per-session position ledger (survives tab remounts), restored once content renders; first
// visits start at the top.
// The page scrolls inside the conversation's shared `[data-conversation-scroll]` container, which the chat bottom-anchors;
// a module-level per-session ledger survives tab remounts and restores once content renders.
const viewScroll = new Map<string, number>()
const EVENT_KINDS = ['inject', 'compaction', 'prune', 'model', 'mode'] as const
@@ -65,8 +57,7 @@ export function makeContextView(
const FileCard = makeFileCard(kit, settings)
const Donut = makeDonut()
// One page-scope cold-head cache serves both subagent-data readers: the
// Agent network card's composition rings and the stats board's
// subagent-cost cell fetch each relative once.
// Agent network card's composition rings and the stats board's subagent-cost cell fetch each relative once.
const heads = makeAgentHeads()
const StatsContext = makeStatsContext(kit, makeSubagentCost(ctx, heads))
const StatsTiming = makeStatsTiming(kit, Donut)
@@ -78,58 +69,33 @@ export function makeContextView(
const AgentGraph = makeAgentGraph(ctx, kit, heads)
const ErrorBoundary = makeErrorBoundary(t)
// The body renders under the error boundary: a corrupt projection value (past the timelineOf shape guard) degrades to a styled error
// card, not a white screen; the boundary itself has NO hooks, so the body's hook order and loading/data flow stay unchanged.
function ContextViewBody(props: ContextViewProps): ReactElement {
const sessionId = props.sessionId
// The right Sidebar's panel is a narrow column: it drops the two head cards
// that only pay off on the full-width tab (context stats, plugin info).
const inSidebar = props.host === 'sidebar'
// The timeline source (timelineSource.ts): the pushed value on the inline
// generation, or the slim head merged with the on-demand detail on the
// split generation. `detailState`/`retryDetail` drive the detail cards'
// loading/failed notes.
const source = useTimelineSource(props)
const data = source.data
// Official token-meter `contextPressure` projection — the same key the chat's context ring reads; token-meter owns estimation, the Host
// no longer mirrors it. Absent → derived fallback.
// token-meter `contextPressure` — the key the chat's ring reads; absent → derived fallback.
const pressure = projectionOf(props, 'contextPressure', contextPressureOf)
// Official token-meter `tokenUsage` projection — the same data the chat stats line below the input box reads for its '缓存命中' figure, so
// the Token card's figures reuse it verbatim; absent → the card drops them instead of estimating.
// token-meter `tokenUsage` — the same data the chat stats line reads, so the figures match by construction.
const usage = projectionOf(props, 'tokenUsage', tokenUsageOf)
// Official token-meter `contextBreakdown` projection — the exact rows the chat ring's click-open panel shows, so the overview legend
// reads identically by construction; absent → the fold's own same-estimator sums inside headlineOf.
// token-meter `contextBreakdown` — the exact rows the chat ring's click-open panel shows.
const breakdown = projectionOf(props, 'contextBreakdown', contextBreakdownOf)
// `contextHeaders` companion projection (full system prompt + tool schemas) for the Context browser; absent key = older Host half →
// those sections degrade to tokens-only with a note.
// `contextHeaders` (full system prompt + tool schemas) for the Context browser; absent on older Host halves →
// tokens-only sections with a note.
const headers = projectionOf(props, 'contextHeaders', headersOf)
const [selectedSeq, setSelectedSeq] = useState<number | null>(null)
const [hoveredSeq, setHoveredSeq] = useState<number | null>(null)
const [hoverTurn, setHoverTurn] = useState<number | null>(null)
// Mount-time default from the plugin settings card; in-chart toggling stays mount-local and never writes back.
// Mount-time defaults from the plugin settings card; in-chart toggling stays mount-local and never writes back.
const [granularity, setGranularity] = useState<'step' | 'turn'>(() => settings.defaultGranularity())
// 'total' plots each request's cumulative composition, 'delta' its incremental change vs the previous one;
// like granularity, the default is read at mount and in-chart toggling never writes back.
const [trendMode, setTrendMode] = useState<'total' | 'delta'>(() => settings.defaultTrendMode())
// DNA mode: the trend bars become per-item fingerprints of each request's context (dna.ts); like the
// toggles above, mount-local and never written back — and SHARED with the Context browser, whose DNA
// toggle rides this same state so both switches move as one. Orthogonal to the Total/Delta switch —
// delta diffs the bands against the previous bar, so the two combine into a per-item change view.
// DNA mode: per-item fingerprints of each request's context (dna.ts).
const [dna, setDna] = useState(false)
// Adaptive scale (the title-adjacent toggle): the trend bars rescale to the visible window; like the two
// toggles above, mount-local and never written back.
const [adaptive, setAdaptive] = useState(false)
// The duration overlay (the title-adjacent toggle): each bar's step active time as a curve over the
// bars, read off the right-hand quartile axis. The settings card's `defaultDurationCurve` seeds the mount
// state (default on); in-chart toggling stays mount-local and never writes back.
const [durationCurve, setDurationCurve] = useState(() => settings.defaultDurationCurve() === 'show')
// Strip-clicked turn: chart switches to turn granularity and scroll-centers that turn's bar, then clears via onFocusTurnHandled.
const [focusTurn, setFocusTurn] = useState<number | null>(null)
// Chat → Context jump: the assistant-action relay's one-shot request, held until the projection data is in, then resolved into a
// turn-level pin below (mirrors the strip-click's consume-once focus flow).
const [jumpSeq, setJumpSeq] = useState<number | null>(null)
const [hoverCat, setHoverCat] = useState<string | null>(null)
// The browser's open category: the trend card focuses its bars on it (collapsing the category restores all).
const [focusCat, setFocusCat] = useState<string | null>(null)
const [pickedKinds, setPickedKinds] = useState<string[]>([...EVENT_KINDS])
const toggleKind = (k: string) => {
@@ -139,35 +105,25 @@ export function makeContextView(
return p.length === 1 ? [...EVENT_KINDS] : p.filter(x => x !== k)
})
}
// Step-brief → browser reveal bridge: one-shot focus request consumed by the Context browser.
// `key` is the band key both DNA surfaces share ('sys' / 'tool:<name>' / 'n<seq>'), so the
// same bridge serves the brief's message locates and the trend chart's DNA band picks.
// One-shot reveal bridge; `key` is the band key both DNA surfaces share ('sys' / 'tool:<name>' / 'n<seq>').
const [nodeFocus, setNodeFocus] = useState<{ step: number | 'live'; key: string; cat: Category | 'system' | 'tools' } | null>(null)
const clearNodeFocus = useCallback(() => { setNodeFocus(null) }, [])
// Session-authorized durable-image loader for the browser's attachment cards, resolved through the harness `uiConversation` service
// (`imageUrl`); absent service/session degrades the cards to metadata-only, never an error.
const loadImage = useMemo(
() => imageLoaderOf(ctx, typeof sessionId === 'string' ? sessionId : undefined),
[ctx, sessionId],
)
// The gateway page face, as a React seat: the first render can race the
// declared inject (a watch rebuild remounts this view before the fiber
// re-fires), and both fetchers below must rebuild — not stick to the
// static degradation — when the face lands or is revoked.
// The gateway page face can land after the first render (a watch rebuild remounts this view before the fiber
// re-fires): the fetchers below must rebuild when it lands or is revoked.
const historyFace = useHistoryFace()
// Targeted full-content fetch for browser nodes outside the conversation window (one seq-anchored history read per expanded row);
// absent face/session degrades to the static hint — never an error.
const fetchContent = useMemo(
() => (typeof sessionId === 'string' && sessionId !== '' && historyFace !== undefined
? makeContentFetcher(sessionId)
: undefined),
[sessionId, historyFace],
)
// On-demand header epoch content (system prompt text, tool schemas) for the Context browser — one seq-anchored history read per
// opened epoch; the `contextHeaders` projection carries metadata only. Same degradation shape as fetchContent.
const fetchHeader = useMemo(
() => (typeof sessionId === 'string' && sessionId !== '' && historyFace !== undefined
? makeHeaderFetcher(sessionId)
@@ -179,23 +135,18 @@ export function makeContextView(
const scrollerRef = useRef<HTMLElement | null>(null)
// The session whose position was already applied this mount — re-applying on re-renders would yank the reader's scroll.
const restoredRef = useRef<string | null>(null)
// The stats card's second-row figures jump here: open the Context Browser's
// matching section on the live surface ('answers' also picks the answer
// kind chip), then scroll the browser card into view.
const [catFocus, setCatFocus] = useState<CatFocus | null>(null)
const clearCatFocus = useCallback(() => { setCatFocus(null) }, [])
const onFigureClick = useCallback((figure: 'skills' | 'answers'): void => {
setCatFocus(figure === 'skills' ? { cat: 'skill' } : { cat: 'assistant', kind: 'answer' })
/* v8 ignore start -- the stats card and the browser card render in the
same view, so the target always resolves here; the guard stays for
defensive parity with revealAgents. */
/** v8 ignore start -- the stats card and the browser card render in the
* same view, so the target always resolves here; the guard stays for defensive parity with revealAgents. */
const browser = rootRef.current?.querySelector('.lc-col-browser') ?? null
if (browser !== null) revealInScrollParent(browser)
/* v8 ignore stop */
}, [])
// Restore the saved position (or the top on first visit) in a layout effect, so the chat's bottom-anchored position never flashes in
// first.
// Restore the saved position in a layout effect, so the chat's bottom-anchored position never flashes in first.
useLayoutEffect(() => {
if (typeof sessionId !== 'string' || sessionId === '' || data === null) return
if (restoredRef.current === sessionId) return
@@ -210,8 +161,7 @@ export function makeContextView(
scroller.scrollTop = viewScroll.get(sessionId) ?? 0
}, [sessionId, data])
// Save the position on unmount/session change — a layout-effect cleanup, so it fires before the incoming view's own layout effects
// re-scroll the shared container.
// Save the position in a layout-effect cleanup, so it fires before the incoming view's layout effects re-scroll the shared container.
useLayoutEffect(() => {
return () => {
if (typeof sessionId !== 'string' || sessionId === '') return
@@ -221,53 +171,37 @@ export function makeContextView(
}
}, [sessionId])
// No locale subscription here: the harness slot outlet subscribes the
// LocaleFace revision and re-renders every entry on a locale switch, and
// the kit's bound `t` reads the active locale at call time.
// No locale subscription here: the harness slot outlet re-renders every entry on a locale switch, and the kit's
// bound `t` reads the active locale at call time.
// Hooks stay unconditional (Rules of Hooks): the projection value can arrive AFTER a loading first render, and an early return above
// these useMemos would grow the hook count between renders (React #310); fall back to empty collections and keep the loading return
// below the last hook.
const requests = data ? data.requests : []
const events = data ? data.events : []
// The stats board's count figures — the split head's precomputed tallies, or
// the collections' own derivation on the inline generation. Shared by the
// stats card's shape cells and the events card's kind-filter counts.
const counts = data?.counts ?? countsOfRecords(requests, events)
// The per-kind tallies that ride the filter buttons: only the three priced
// kinds the host counts (model/mode switches carry no tally, hence undefined).
// Only the three kinds the host tallies; model/mode switches carry no count.
const kindCounts: Record<string, number | undefined> = { inject: counts.injects, compaction: counts.compactions, prune: counts.prunes }
const shownEvents = pickedKinds.length === EVENT_KINDS.length ? events : events.filter(e => pickedKinds.includes(e.kind))
// Per-step bars, or one per turn (each turn's LAST step's record); memoized so hover-driven re-renders keep bar props identity-stable —
// the chart's memoized bars then skip reconciliation (turn-mode aggregation allocates).
// Per-step bars, or one per turn (each turn's LAST step's record); memoized so hover-driven re-renders keep bar props identity-stable.
const displayRequests = useMemo(
() => (granularity === 'turn' ? aggregateByTurn(requests) : requests),
[requests, granularity],
)
// The step labels' per-turn totals ("第 s 步 (共 n 步)"), tallied over the RAW step records — turn-mode
// aggregates read their own stepCount instead.
// Tallied over the RAW step records — turn-mode aggregates read their own stepCount instead.
const stepsOf = useMemo(() => turnStepsOf(requests), [requests])
const markers = useMemo(() => attachMarkers(displayRequests, events), [displayRequests, events])
// DNA mode's per-bar bands: one assemble() per displayed record — the same pure client-side
// reconstruction the Context browser uses — memoized on the timeline so hover-driven re-renders
// never reassemble. Turn bars assemble at the aggregate's (its last step's) seq, exactly the
// step the bar plots. Null whenever DNA is off (the chart's stacked modes never pay for it).
// One assemble() per displayed record — the same pure reconstruction the browser uses — memoized so hover-driven
// re-renders never reassemble.
/* v8 ignore next 1 -- a missing projection returns the loading screen before the trend card
(and its DNA toggle) renders, so `dna && data === null` cannot occur. */
const dnaBands = useMemo(
() => (dna && data !== null ? displayRequests.map(req => trendBandsOf(assemble(data, headers, req.seq))) : null),
[dna, data, headers, displayRequests],
)
// A DNA band click reveals the item in the Context browser through the same one-shot bridge
// the step brief uses — the band key ('sys' / 'tool:<name>' / 'n<seq>') opens the very row
// the browser's own DNA bands click open, at the bar's step.
const revealBand = useCallback((seq: number, band: { key: string; cat: Category | 'system' | 'tools' }): void => {
setNodeFocus({ step: seq, key: band.key, cat: band.cat })
}, [])
// Chat → Context jump, leg 1: pick up the assistant-action relay's request for this session —
// once per mount, and again on every later record (the sidebar landing keeps this view mounted
// while the user jumps between replies).
// Chat → Context jump, leg 1: pick up the relay's request once per mount and on every later record (the sidebar
// landing keeps this view mounted).
useEffect(() => {
if (typeof sessionId !== 'string' || sessionId === '') return
const take = (): void => {
@@ -278,12 +212,8 @@ export function makeContextView(
return subscribeContextFocus(take)
}, [sessionId])
// Leg 2: the action row belongs to the reply that CLOSED a turn, so the jump is turn-level — flip the chart to turn bars, pin that
// turn's aggregate (the relayed seq is the turn's last request, exactly the aggregate's record), and center it: the same flow as a
// turn-strip click. An aged-out turn clamps to the oldest retained bar. The landing then reveals the Current Composition card, so
// the card and the pinned turn's trend chart share the scrollport (revealScroll.ts; no scrollable ancestor → a quiet no-op).
// The split generation waits for the detail read first: the relayed seq resolves against the served request records, so the
// one-shot request must not be consumed while the collections are still pending (it re-fires when they land).
// Leg 2: the relayed seq is the turn's LAST request — exactly the aggregate's record — so the jump pins that turn bar; the one-shot
// request must not be consumed while the split generation's collections are still pending (it re-fires when they land).
const detailReady = source.detailState === 'ready' || source.detailState === 'legacy'
useEffect(() => {
if (jumpSeq === null || data === null || !detailReady) return
@@ -293,20 +223,13 @@ export function makeContextView(
setGranularity('turn')
setSelectedSeq(target.seq)
setFocusTurn(target.turn ?? 0)
// The restore layout effect already applied the saved position on this same data render
// (layout effects precede this one); the reveal overrides it wherever it landed.
/* v8 ignore next 2 -- the effect only fires while mounted, and both render paths attach
rootRef and render the composition card. */
const anchor = rootRef.current?.querySelector('[data-lc-current]') ?? null
if (anchor !== null) revealInScrollParent(anchor)
}, [jumpSeq, data, requests, detailReady])
// Step-brief raw material: every served node seq-sorted (live tail + archive), and the conversation-snapshot
// join the brief uses for call-argument enrichment (same join the Context browser builds).
const briefList = useMemo(() => (data ? briefNodes(data) : []), [data])
// The conversation-window join, from the `useChat` seat. The seat is a
// real hook — invoked unconditionally per render (stable order);
// undefined join = render without it, never an error.
const convNodes = conversationNodesOf(props)
const bySeq = useMemo(() => {
const m = new Map<number, ConversationNodeLike>()
@@ -314,7 +237,6 @@ export function makeContextView(
return m
}, [convNodes])
// Active bar / pin lookup — derived BEFORE the loading-return so the hooks below stay unconditional (React #310).
let pinnedIdx = -1
for (let i = 0; i < displayRequests.length; i++) if (displayRequests[i].seq === selectedSeq) pinnedIdx = i
const pinnedReq = pinnedIdx >= 0 ? displayRequests[pinnedIdx] : null
@@ -325,25 +247,20 @@ export function makeContextView(
if (activeIdx < 0) activeIdx = pinnedIdx
if (activeIdx < 0 && displayRequests.length > 0) activeIdx = displayRequests.length - 1
const activeReq = activeIdx >= 0 ? displayRequests[activeIdx] : null
// File activity follows the same active bar: the EXCLUSIVE upper bound is the next RAW request's seq, so the picked step's own
// tool calls (results land before the next request) count too; the latest bar's null bound serves everything.
// File activity follows the same active bar: the EXCLUSIVE upper bound is the next RAW request's seq (the picked step's own
// tool calls count too); the latest bar's null bound serves everything.
let filesBefore: number | null = null
if (activeReq !== null) {
// The active bar's seq always exists in the raw list (turn aggregates keep their last step's record).
const ri = requests.findIndex(r => r.seq === activeReq.seq)
filesBefore = ri + 1 < requests.length ? requests[ri + 1].seq : null
}
// The active bar's semantic identity ("what this step was about"); pure derivation over the served nodes, null when nothing is known.
const brief = useMemo(
() => (activeReq !== null ? briefOf(briefList, displayRequests, activeIdx) : null),
[activeReq, briefList, displayRequests, activeIdx],
)
const convOf = useCallback((seq: number): ConversationNodeLike | undefined => bySeq.get(seq), [bySeq])
// File activity follows the same active bar: the op-log generation reads the fold-derived records (full
// session-log coverage); older hosts keep the conversation-window derivation. Recomputed when the brief
// source, the conversation join, or the upper bound moves — hover/select elsewhere (composition chips,
// kind filters) leaves every input reference untouched, so the walk is skipped on those renders.
// Memoized on the walk's inputs only: hover/select renders elsewhere leave every input reference untouched, so the walk is skipped.
const fileActivity = useMemo(
() => {
if (data !== null && data.fileOps !== undefined) {
@@ -353,10 +270,7 @@ export function makeContextView(
},
[data, briefList, convOf, filesBefore],
)
// The stats card's pill tallies: the WHOLE-session file-op counts (null
// bound, off the same derivations the FileCard rides) for the I/O card,
// and the live-surface per-tool tally (toolTallyOf — the head's own
// `toolCalls` predicate) for the tool card's most-called pills.
// The stats card's pill tallies; the null bound means the WHOLE session.
const ioTotals = useMemo(
() => {
const totals = (data !== null && data.fileOps !== undefined
@@ -367,13 +281,9 @@ export function makeContextView(
[data, briefList, convOf],
)
const toolTally = useMemo(() => (data ? toolTallyOf(data.nodes) : []), [data])
// The session's workspace root — './'-relative row paths when known; read per
// render (an observable snapshot), so the next projection push re-renders with it.
// Read per render off the observable snapshot, so the next projection push re-renders with the current workspace root.
const workspace = typeof ctx.get === 'function' ? workspaceOf(ctx, typeof sessionId === 'string' ? sessionId : undefined) : undefined
// The system-opener affordance rides the same ctx; both resolve before the early return.
// The capability is an RPC answer now (the synchronous host-description fact is gone),
// so it lands in state once the probe settles — inert file names until then; the
// effect's cleanup drops an answer that arrives after the view unmounted.
// The capability is an async RPC answer, so file names stay inert until the probe settles.
const [canOpenPaths, setCanOpenPaths] = useState(false)
useEffect(() => {
let live = true
@@ -384,11 +294,7 @@ export function makeContextView(
() => (canOpenPaths ? openPathVia(ctx) : undefined),
[canOpenPaths, ctx],
)
// The right-Sidebar preview opener (the same optional column the Context
// tab itself registers into): resolved off `ctx` at mount, the face
// re-proved per call, so an HMR reload's revocation never leaves a stale
// opener. Absent face = no `onPreview` at all, and the card keeps its
// system-open affordance untouched.
// The preview column this tab registers into; the face is re-proved per call, so an HMR revocation leaves no stale opener.
const previewOpener = useMemo(() => openResourceVia(ctx), [ctx])
const sessionKey = typeof sessionId === 'string' ? sessionId : undefined
const previewFile = useMemo(
@@ -401,15 +307,13 @@ export function makeContextView(
[previewOpener, sessionKey, workspace],
)
const locateFileOp = useCallback((op: FileOp): void => {
// A nested Code-Mode op has no surface row of its own — it reveals on
// its parent run_code result (whose removal stamp the op carries).
// A nested Code-Mode op has no surface row of its own — it reveals on its parent run_code result.
const seq = op.parent ?? op.seq
const step = locateStepOf(requests, seq, op.gone)
if (step === null) return
setNodeFocus({ step, key: 'n' + String(seq), cat: 'tool' })
}, [requests])
// A brief row's reveal target: inputs/opener live in the picked step's OWN assembled surface; the response node (seq === the
// request's) first appears in the NEXT step's surface — or the live surface when the last bar is picked.
// A response node (seq === the request's) first appears in the NEXT step's surface, or the live surface on the last bar.
const locateNode = useCallback((node: SurfaceNode, isResponse: boolean): void => {
/* v8 ignore next 1 -- locateNode is only wired to brief rows, and
brief !== null guarantees activeReq !== null in the same closure. */
@@ -420,9 +324,6 @@ export function makeContextView(
}, [activeReq, activeIdx, displayRequests])
if (!data) {
// No renderable value: the cold read is still in flight (the loading
// screen) or settled without one — the retryable failure note, never a
// spinner that never resolves.
return (
<div className="lc-root" ref={rootRef}>
{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 = (
<CurrentComposition
head={head}
@@ -487,11 +377,6 @@ export function makeContextView(
<div className="lc-card">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('trend.title')}</span>
{/* 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. */}
<span className="lc-gran lc-trend-mods" role="group">
<button
type="button"
@@ -536,19 +421,17 @@ export function makeContextView(
</div>
</div>
{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
? <div className="lc-empty">{t('trend.empty')}</div>
: <DetailNote state={source.detailState === 'failed' ? 'failed' : 'loading'} onRetry={source.retryDetail} />)
: (
<div>
<TrendChart
// Remount per session: switching sessions re-anchors the chart at the newest bars instead of inheriting stale scroll
// state.
// Remount per session: re-anchors at the newest bars instead of inheriting stale scroll state.
key={sessionId}
// Render ALL retained requests (bounded by the host's maxKeptTurns/maxRequestSteps config) so earlier turns/steps
// stay reachable via horizontal scroll.
// ALL retained requests (bounded by the host's maxKeptTurns/maxRequestSteps config): earlier turns/steps
// stay reachable via scroll.
requests={displayRequests}
markers={markers}
selectedSeq={pinnedReq ? pinnedReq.seq : null}
@@ -573,9 +456,8 @@ export function makeContextView(
request={activeReq}
// Delta mode pairs the detail with the SAME previous record the chart diffs against (first bar: null).
prev={trendMode === 'delta' && activeIdx >= 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 (
<div className="lc-root" ref={rootRef}>
{/* 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 : (
<div className="lc-cols lc-head">
<StatsContext counts={counts} humanInputs={data.humanInputs} answers={data.answers} toolCalls={data.toolCalls}
@@ -634,16 +511,9 @@ export function makeContextView(
<StatsTiming timing={data.timing ?? null} spans={data.spans} />
</div>
{/* 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. */}
<div className="lc-cols lc-cols-main">
<div className="lc-col flex-1 min-w-[min(360px,100%)]">{compositionCard}{trendCard}</div>
{/* `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. */}
<div className="lc-col lc-col-browser flex-1 min-w-[min(360px,100%)]">{browserCard}</div>
</div>
+13 -66
View File
@@ -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 <body> 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 <body> 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<string | null>(null)
const [hover, setHover] = useState<string | null>(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 (
<span className={className} role="gridcell" aria-disabled="true" aria-label={key} key={d}>
{day}
+1 -12
View File
@@ -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
+6 -13
View File
@@ -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)
+2 -6
View File
@@ -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'
+4 -14
View File
@@ -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
+8 -16
View File
@@ -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<string, string> = { 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<HTMLElement>('.lc-event-label')) {
el.title = el.scrollWidth > el.clientWidth ? el.textContent || '' : ''
+4 -23
View File
@@ -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<T> {
/** Resolved values by key (immutable history — a landed value never refetches). */
values: Map<number, T>
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<T>(
key: number | null,
fetcher: ((key: number) => Promise<T | null>) | undefined,
@@ -59,13 +46,7 @@ export function useFetchOnMiss<T>(
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,
+15 -56
View File
@@ -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<FileFilter>('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<DefaultFileSort>(() => settings.defaultFileSort())
const [query, setQuery] = useState('')
const [openPath, setOpenPath] = useState<string | null>(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 => (
<>
<span className="lc-fa-op-tool" title={op.tool}>{op.tool}</span>
{/* A read's line footprint: the exact `>>n` window off the result meta, or the ≈ limit estimate. */}
{op.read !== undefined
? (
<span
@@ -130,13 +96,12 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
: null}
{op.detail !== undefined ? <span className="lc-fa-op-detail">{op.detail}</span> : null}
{op.hits !== undefined ? <span className="lc-fa-op-detail">{t('files.hits', { n: fmt(op.hits) })}</span> : 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
? <span className="lc-fa-op-detail">{op.program}</span>
: null}
{op.added + op.removed > 0 ? <DeltaPair added={op.added} removed={op.removed} /> : null}
{op.err ? <span className="lc-br-err-dot" title={t('node.failed')} /> : null}
{/* The time rides the right edge, mirroring the file row above. */}
<span className="lc-fa-op-time">{op.time !== undefined ? fmtTime(op.time) : '—'}</span>
</>
)
@@ -156,13 +121,12 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
) : (
<div>
<div className="lc-fa-ctl">
{/* 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. */}
<div className="lc-gran @max-[380px]/lc-card:flex-wrap">
{chips.map(c => (
<button
key={c.key}
type="button"
// Kind chips carry their badge color (the row pills below) in every state.
className={'lc-gran-btn' + (filter === c.key ? ' lc-gran-on' : '')
+ (c.key === 'read' || c.key === 'write' || c.key === 'search' ? ' lc-fa-chip-' + c.key : '')}
title={t('files.chipTip', { files: c.files, ops: c.ops })}
@@ -183,7 +147,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
<div className="lc-fa-meta">
<span>{t('files.files', { n: activity.entries.length })}</span>
{activity.totals.added + activity.totals.removed > 0 ? (
/* The one styled tip of the card: it lives OUTSIDE the scrolling list, so the bubble never clips. */
/* The one styled tip of the card lives OUTSIDE the scrolling list, so the bubble never clips. */
<span className="lc-fa-meta-delta group/tip">
<DeltaPair added={activity.totals.added} removed={activity.totals.removed} />
<span className="lc-tip lc-fa-meta-tip group-hover/tip:opacity-100" role="tooltip">{t('files.deltaTip')}</span>
@@ -208,18 +172,13 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
<div className="lc-fa-list">
{shown.map((e) => {
const open = openPath === e.path
// The path column renders the display form; the row title keeps the raw path.
const display = displayOf(e)
const trimmed = display.endsWith('/') ? display.slice(0, -1) : display
const slash = trimmed.lastIndexOf('/')
const dir = slash >= 0 ? trimmed.slice(0, slash + 1) : ''
const base = slash >= 0 ? trimmed.slice(slash + 1) : trimmed
const glyph = glyphOf(e.path, e.form)
// The system-open affordance: real paths resolve to absolute; pattern rows are not files.
const abs = e.pattern === true ? undefined : absPathOf(e.path, props.workspace)
// The preview affordance leads where the column exists: a real
// file (never a search pattern or directory target) opens its
// Sidebar tab, and a refusal falls through to the system opener.
const previewable = props.onPreview !== undefined && e.pattern !== true && e.form !== 'dir'
const openable = previewable || (abs !== undefined && props.onOpen !== undefined)
return (
@@ -240,9 +199,9 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
)
: glyph.glyph}
</span>
{/* Narrow cards: wrap instead of crushing — the path's near-full-width basis keeps
line 1 to chevron + icon + path, badges/delta/time fold onto line 2. The 46px
reservation is chevron (12) + gaps (2×7) + form icon (20). */}
{/* Narrow cards wrap instead of crushing: the path's near-full-width basis keeps line 1 to
chevron + icon + path, badges/delta/time fold onto line 2. The 46px reservation is
chevron (12) + gaps (2×7) + form icon (20). */}
<span className="lc-fa-path flex-1 @max-[380px]/lc-card:basis-[calc(100%-46px)]">
{dir !== '' ? <em>{dir}</em> : null}
{openable
@@ -280,8 +239,8 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
<div className="lc-fa-ops">
{e.ops.map((op, i) => {
const onLocate = props.onLocate
// A meta-attributed search rows one op per matched file off ONE
// result — the ops share that result's seq, so the key joins the index.
// A meta-attributed search rows one op per matched file off ONE result — the ops share
// that result's seq, so the key joins the index.
const key = `${op.seq}:${i}`
return onLocate !== undefined
? (
+7 -17
View File
@@ -1,22 +1,16 @@
/**
* The Context Dashboard's activity heatmap: a GitHub-style contribution grid
/** The Context Insights page's activity heatmap: a GitHub-style contribution grid
* (weeks as columns, Sunday-first weekdays as rows, a month label over each
* column where a month begins — the window's opening column always labeled —
* and a Less→More key for the depth steps) over the merged daily
* ledger (overview.ts). Cell depth is the selected metric's share of the
* window's maximum — distinct active sessions or settlement steps; a session
* can spread its steps thinly across many days while another day stacks a
* run, so each metric scales on its OWN maximum — in four steps; a day with
* data is a button whose click
* run, so each metric scales on its OWN maximum — in four steps; a day with data is a button whose click
* pins the session list to that day (click again to release). Cells carry
* their tip (the date, the day's active sessions, and its steps) in
* `data-lc-tip`, served by the page's portaled hover tip (hoverTip.tsx) —
* instant on hover, where a native `title` lags a second behind. The grid is
* computed from the injected
* `today` key, so
* the layout is deterministic in tests and follows the browser's local
* calendar at runtime.
*/
* instant on hover, where a native `title` lags a second behind. The grid is computed from the injected `today` key, so
* the layout is deterministic in tests and follows the browser's local calendar at runtime. */
import type { ReactElement } from 'react'
import { dayKeyOf, shiftDayKey, sundayOfWeek } from '../../shared/days'
@@ -48,12 +42,9 @@ interface HeatCell {
future: boolean
}
/**
* Lay out the grid: `weeks` columns ending at today's week, each seven
/** Lay out the grid: `weeks` columns ending at today's week, each seven
* Sunday-first cells (row 0 = Sunday). Returns null when `today` (or its
* week math) fails — a corrupt injected key degrades the card to its empty
* note.
*/
* week math) fails — a corrupt injected key degrades the card to its empty note. */
export function gridOf(today: string, weeks: number): HeatCell[][] | null {
const lastSunday = sundayOfWeek(today)
if (lastSunday === null || weeks < 1) return null
@@ -94,8 +85,7 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
const metric = props.metric ?? 'steps'
const columns = gridOf(props.today, weeks)
if (columns === null) return <div className="lc-empty">{t('ov.heat.empty')}</div>
// Join the ledger onto the grid and price the depth scale. The record is
// widened honestly: a day-key read can miss at runtime.
// Join the ledger onto the grid and price the depth scale. Widened: a day-key read can miss at runtime.
const byKey: Record<string, DayTotals | undefined> = props.days
let maxSessions = 0
let maxSteps = 0
+9 -39
View File
@@ -1,23 +1,10 @@
/**
* The dashboard's hover tips — one fixed bubble, portaled to <body>, driven
* by event delegation off the page root.
* The Context Insights page's hover tips: one fixed bubble, portaled to <body>, driven by event delegation off the page root.
*
* Why not the harness Tooltip: the Insights page is the `lc-ov` query
* container, and inline-size containment makes the page the containing block
* for FIXED descendants — an in-tree fixed bubble (the harness Tooltip's
* unportaled spelling) would paint sidebar-width away from its anchor, while
* the primitive's `portal` prop exists only on 0.1.7+. The plugin already
* portals its own fixed overlays for exactly this trap (the image lightbox,
* base.css), so the page's tips follow that precedent and stay correct on
* every supported line.
* The page is the `lc-ov` query container, and its inline-size containment makes the page the containing block
* for FIXED descendants: an in-tree bubble would paint sidebar-width away from its anchor.
*
* Anchors carry `data-lc-tip` (the label) and optional `data-lc-tip-side`
* ('top' default, 'bottom'); the zone delegates `mouseover`/`mouseout` and
* focus/blur capture, so anchors stay plain elements — no per-cell wrapper
* components. One bubble at a time: it re-anchors on anchor switch, hides on
* anchor leave, on any zone scroll, on a viewport resize (every anchor just
* moved), and on unmount; while shown it sets the anchor's
* `aria-describedby`.
* Anchors carry `data-lc-tip` (the label) and optional `data-lc-tip-side` ('top' default, 'bottom').
*
* @module dsh-context/client/components/hoverTip
*/
@@ -25,10 +12,8 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
import { createPortal } from 'react-dom'
/** The bubble's identity for the anchor's `aria-describedby` (one live bubble at a time). */
const TIP_ID = 'lc-hovertip-bubble'
/** Anchor-to-bubble distance and the viewport margin the bubble clamps inside, in px. */
const GAP = 6
const VIEWPORT_MARGIN = 8
@@ -39,14 +24,12 @@ interface ActiveTip {
rect: { left: number; top: number; width: number; height: number; bottom: number }
}
/** The bubble's measured spot for one shown tip. */
interface Measured {
tip: ActiveTip
left: number
top: number
}
/** The nearest tip anchor of an event target, if any. */
function anchorOf(target: EventTarget | null): Element | null {
return target instanceof Element ? target.closest('[data-lc-tip]') : null
}
@@ -64,7 +47,6 @@ function tipOf(anchor: Element): ActiveTip | null {
}
export interface HoverTipZone {
/** Spread onto the zone's root element (the page section). */
zoneProps: {
onMouseOver: (ev: React.MouseEvent) => void
onMouseOut: (ev: React.MouseEvent) => void
@@ -72,15 +54,10 @@ export interface HoverTipZone {
onBlurCapture: (ev: React.FocusEvent) => void
onScrollCapture: (ev: React.UIEvent) => void
}
/** The portaled bubble element, or null while nothing is tipped. */
bubble: ReactElement | null
}
/**
* The zone hook: stable delegated handlers plus the portaled bubble. The
* bubble renders hidden until the layout effect measures it, so it never
* flashes at an unmeasured spot.
*/
/** The bubble renders hidden until the layout effect measures it, so it never flashes at an unmeasured spot. */
export function useHoverTip(): HoverTipZone {
const [tip, setTip] = useState<ActiveTip | null>(null)
const [measured, setMeasured] = useState<Measured | null>(null)
@@ -105,8 +82,7 @@ export function useHoverTip(): HoverTipZone {
})
}, [])
// Anchor switching never hides: leaving A for B skips the hide (B's
// mouseover re-anchors), and moving inside A's own subtree keeps it.
// Anchor switching never hides: leaving A for B skips the hide, since B's mouseover re-anchors.
const onMouseOver = useCallback((ev: React.MouseEvent): void => {
const anchor = anchorOf(ev.target)
if (anchor !== null) show(anchor)
@@ -123,9 +99,7 @@ export function useHoverTip(): HoverTipZone {
}, [hide])
const onScrollCapture = useCallback((): void => { hide() }, [hide])
// A viewport resize moves every anchor (the pane re-solves, the content
// column re-centers): the zone's scroll rule extended — retract rather
// than float detached off the moved anchor.
// A viewport resize moves every anchor: retract rather than float detached.
useEffect(() => {
if (tip === null) return undefined
const retract = (): void => { hide() }
@@ -133,15 +107,11 @@ export function useHoverTip(): HoverTipZone {
return () => { window.removeEventListener('resize', retract) }
}, [tip, hide])
// Measure once per shown tip (the measured record keys on tip identity, so
// a same-anchor mousemove never re-hides the bubble): center on the anchor,
// clamp inside the viewport, flip sides when the preferred side overflows.
// Measured once per shown tip (keyed on tip identity, so a same-anchor mousemove never re-hides).
useLayoutEffect(() => {
if (tip === null || measured?.tip === tip) 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
+4 -17
View File
@@ -1,8 +1,5 @@
/**
* dsh's multimodal pipeline stores only a normalized reference per image (never inline bytes); the block carries everything the card needs
* except the display URL, which comes from the harness `uiConversation` service's `imageUrl` (the same loader the chat history rides on),
* handed down as `load`; absent loader or failed load degrades to the metadata row alone — the card never throws.
*/
/** dsh stores only a normalized reference per image (no inline bytes); the display URL comes from the harness
* `uiConversation.imageUrl` loader handed down as `load`. */
import { useCallback, useEffect, useState, type ReactElement } from 'react'
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -16,11 +13,8 @@ export interface ImageKit {
load?: ImageLoader
}
/**
* Narrow an unknown content block to a durable image ref: accepts both raw message blocks (`{ type: 'image', attachment }`) and the
* snapshot's assistant blocks (`{ kind: 'image', attachment }`); everything else null. Lenient on the optional facts — imageUrl reads
* only attachmentId.
*/
/** Accepts both raw message blocks (`type: 'image'`) and the snapshot's assistant blocks (`kind: 'image'`);
* every optional fact is re-proved. */
export function imageRefOf(block: unknown): ImageRefLike | null {
if (block === null || typeof block !== 'object') return null
const b = block as { type?: unknown; kind?: unknown; attachment?: unknown }
@@ -47,11 +41,6 @@ export function imageRefOf(block: unknown): ImageRefLike | null {
}
}
/**
* One attachment card, the WHOLE card the click target: 64px cover tile + metadata column — Raw (the pre-normalization raster dsh records
* when normalization reduced the image), Sent (the normalized raster the model receives, with byte size), estimated provider-billed tokens.
* Click opens the platform ImageLightbox; load failures retry on click; unknown facts leave no row.
*/
export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
const { t, fmt } = kit
return function ImageCard(props: { attachment: ImageRefLike; load?: ImageLoader }): ReactElement {
@@ -87,8 +76,6 @@ export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
value: attachment.bytes !== undefined ? `${sent} · ${fmtBytes(attachment.bytes)}` : sent,
})
}
// Estimated provider-billed tokens (shared/imageTokens — the official docs calculator on the stored dimensions; 117–384 per the vision
// guide's cap), shown whenever normalized dimensions are known.
const tokens = attachment.width !== undefined && attachment.height !== undefined
? estimateImageTokens(attachment.width, attachment.height)
: null
-2
View File
@@ -1,5 +1,3 @@
/** The one-line label cascade shared by the Context browser's element rows. */
import type { SurfaceNode } from '../../shared/types'
import type { ViewKit } from '../viewkit'
+3 -26
View File
@@ -1,13 +1,3 @@
/**
* One session card in the Context Dashboard's grid: the row's title (wrap-
* clamped), last-activity and first-active times, and the directory over a
* mini composition donut (the same seven-category ring the stats board
* draws, at card scale) and the three figures a user scans for — turns and
* steps, billed tokens, cost. A row whose host folded nothing yet degrades
* to a metadata-only card (never an error). The whole card is one button:
* clicking opens the session (the panel's `onOpen`).
*/
import type { ReactElement } from 'react'
import { billedOf, createdDayOf, projectOf, relativeTime, turnsOf, type OverviewRow } from '../overview'
import { partsOf } from '../categories'
@@ -17,11 +7,8 @@ import { makeDonut } from './donut'
export interface OverviewCardProps {
row: OverviewRow
/** The workspace title claiming this session, when one does (the breadcrumb's group). */
group?: string
/** The priced cost label ('—' until the price book lands or nothing billed). */
costLabel: string
/** Render instant for the relative-time label. */
now: number
onOpen(id: string): void
}
@@ -32,29 +19,19 @@ export function makeOverviewCard(kit: ViewKit): (props: OverviewCardProps) => Re
return function OverviewCard(props: OverviewCardProps): ReactElement {
const { row } = props
const timeline = row.timeline
// The card's two money figures ride the team scope: the whole subtree's
// billed tokens and priced cost (the donut and the turns stay the
// session's own — the ring reads the session's CURRENT context).
// The two money figures ride the TEAM scope (the whole subtree); the donut and the turns stay the session's own —
// the ring reads the session's CURRENT context.
const billed = billedOf(row)
const occupancy = timeline !== null && typeof timeline.contextWindow === 'number' && timeline.contextWindow > 0
? fmtShare(timeline.current.total, timeline.contextWindow)
: null
// The lead figure: turns and steps when the fold reported counts, the
// bare turns estimate otherwise (a counts-less head falls back to the
// request tally inside turnsOf).
const turns = turnsOf(timeline)
const steps = timeline?.counts?.steps
const turnsLabel = steps === undefined ? String(turns) : t('ov.card.turns', { n: turns, s: steps })
// The breadcrumb: group (workspace title) / project (cwd basename); a
// session with neither drops the row, and the full path stays on the tip.
// A workspace whose title IS the project (the common single-repo case)
// shows the name ONCE — never "dsh-context / dsh-context".
// A workspace whose title IS the project (the common single-repo case) shows the name ONCE, never "dsh-context / dsh-context".
const project = projectOf(row.cwd)
const group = props.group !== undefined && props.group !== project ? props.group : undefined
const created = createdDayOf(row.activity)
// The footer preview: the user's newest own message, one muted hairline-
// separated line. Absent from rows folded before the fold carried it —
// the row just stays off the card.
const lastUser = timeline?.lastUser
return (
<button
+20 -86
View File
@@ -1,51 +1,17 @@
/**
* The Context Insights page — the plugin's first-level panel, opened from
* the sidebar's panel list (insightPage.ts), a sibling of the shipped
* Plugins and Automation tasks pages. Registered on the layout's keyed
* `main` slot, so the shell's center column mounts it when the panel is
* selected and unmounts it on leave — the page's mount IS its open, and its
* data rides the root-scope `useSessions`/`useWorkspaces` standard kit
* (every list row's host-cached projection values), so the page draws every
* session's insight without opening one log.
* The Context Insights page: the plugin's first-level panel, registered on the layout's keyed `main` slot, so its
* mount IS its open. Data comes from the root-scope sessions/workspaces standard kit — every list row's host-cached
* projection values — so the page draws each session's insight without opening a log.
*
* The chrome keeps the shipped first-level pages' discipline — one scroll
* region that opens with the page heading, the title beside the preferences
* chip, the scope cluster (the calendar date-range picker beside the range
* group, with the DeepSeek balance capsule to their left) —
* but the content column spans the pane's full width rather than the shipped
* pages' centered 960px frame: the dashboard's grids and legends adapt to the
* pane, so the width reads as more dashboard, not wider lines. The page
* element is the `lc-ov` query container, so every fold keys off the pane's
* own width. Hover tips portal to <body> (hoverTip.tsx) because the container
* would otherwise capture their fixed positioning.
* Unlike the shipped first-level pages' centered 960px frame, the content column spans the pane so the grids and
* legends adapt to it, and the page element is the `lc-ov` query container the folds key off. Hover tips portal to
* <body>, because that container would otherwise capture their fixed positioning.
*
* The cluster holds ONE scope: a picked calendar range (an ordered day pair,
* resolved into a closed window by dayRangeWindow) replaces the rolling
* preset, and clicking any preset hands it back — the preset chips and the
* picker never both claim it. Everything the presets scope (KPI band,
* aggregate cards, skill card, session list) reads that one resolved window,
* so a picked range narrows the whole page at once.
*
* The body's first row is a 1:1 column pair: the KPI metrics band (the
* range's six figures — sessions, billed tokens, cost, cache hit, tool
* calls, active time — three per row) beside the last-7-days usage chart
* (each day's billed tokens and estimated cost as a bar pair, folded off the
* merged daily ledger). Below it, the aggregate stats row folds the same
* range's sessions into the two donut cards the per-session Context tab
* opens with — Token Stats (the composition-split billed volume) and Timing
* Stats (the summed totals). The body is then a 3:7 column pair: the
* insight column (the activity heatmap over the scope's skill-load card)
* beside the session column (search,
* group chips, and the card grid); the heatmap keeps its own fixed 8-week
* window and PINs both column mates to a picked day (the page's drill-down
* gesture). The skill card's row click is the column's second drill-down:
* the session column narrows to the pinned skill's loaders within the same
* scope, and the card's description/origin/path enrichment rides the
* plugin's skill-catalog route (client/skills.ts), read per open off the
* current session's workspace.
* A session card click jumps to that session through the harness's own
* selection verb (openSessionVia), whose navigation returns the center column
* to the conversation.
* The cluster holds ONE scope: a picked calendar range replaces the rolling preset and clicking a preset hands it
* back, so they never both claim it, and everything the presets scope reads that one resolved window. Below the KPI
* band and the usage chart, the heatmap pins both its column mates to a picked day, and a skill-card row click
* narrows the session column to that skill's loaders within the same scope. A session card jumps through the
* harness's own `openSessionVia`, whose navigation returns the center column to the conversation.
*/
import { useEffect, useMemo, useState, type ReactElement } from 'react'
@@ -79,9 +45,7 @@ import { makeOverviewSkills } from './overviewSkills'
import { IconSettings } from '../primitives'
export interface OverviewPanelProps {
/** The root standard kit's sessions seat (absent on a harness without it). */
useSessions?: unknown
/** The root standard kit's workspaces seat (the cards' breadcrumb grouping). */
useWorkspaces?: unknown
}
@@ -96,9 +60,6 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
const OverviewCard = makeOverviewCard(kit)
const BalanceCapsule = makeBalanceCapsule(ctx, kit)
const DateRange = makeDateRange(kit)
// The first row's aggregate pair: the range's sessions folded into the two
// donut cards the per-session Context tab opens with (Timing Stats reused
// verbatim over the summed totals).
const Donut = makeDonut()
const OverviewTokens = makeOverviewTokens(kit, Donut)
const StatsTiming = makeStatsTiming(kit, Donut)
@@ -106,24 +67,22 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
const OverviewSkills = makeOverviewSkills(kit)
const ErrorBoundary = makeErrorBoundary(t)
/** The active locale tag, read per render — the slot outlet re-renders on a locale switch. */
// Read per render: the slot outlet re-renders on a locale switch.
function activeLocale(): string {
const locale = ctx.locale
return typeof locale.getLocale === 'function' ? locale.getLocale().active : 'en'
}
/** The display currency follows the active locale (zh → CNY). */
/** The display currency follows the active locale. */
function activeCurrency(): CostCurrency {
return activeLocale() === 'zh' ? 'cny' : 'usd'
}
function OverviewBody(props: OverviewPanelProps): ReactElement | null {
const { book } = useModelPrices()
// The hook-level standard-kit reads (unconditional; guarded inside).
const snapshot = sessionsSnapshotOf(props)
const wsSnapshot = workspacesSnapshotOf(props)
const [preset, setPreset] = useState<OverviewRange>('30d')
// The calendar picker's own range; while set it replaces the preset scope.
const [custom, setCustom] = useState<DayRange | null>(null)
const [day, setDay] = useState<string | null>(null)
const [query, setQuery] = useState('')
@@ -142,8 +101,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
// The skill card's metadata read: the registry resolves per SESSION (the
// workspace selects the project layer, the preset selects the discovery
// scope), so the current session anchors it; a session-less list still
// serves the global layers. A switch re-reads; a failed read leaves the
// card unenriched rather than stale.
// serves the global layers. A switch re-reads; a failed read leaves the card unenriched rather than stale.
const currentRow = (rows ?? []).find(row => row.current)
const currentCwd = currentRow?.cwd
const currentId = currentRow?.id
@@ -169,8 +127,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
const now = Date.now()
const allRows = rows ?? []
// The scope window every figure below reads: the calendar's own picked
// range while one is set (a malformed key keeps the preset, never an
// unprovable window), else the preset's rolling floor.
// range while one is set (a malformed key keeps the preset, never an unprovable window), else the preset's rolling floor.
const scope = (custom !== null ? dayRangeWindow(custom) : null) ?? rangeWindowOf(preset, now)
// The head's date span, read off that same window so the picker and the
// presets can never disagree: a picked range's own two days, a preset's
@@ -209,26 +166,17 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
return (
<section className="lc-ov-page" aria-label={t('ov.title')} {...zoneProps}>
{bubble}
{/* The page's one scroll region: the shipped first-level pages' own
discipline — heading, stats, and body scroll as one. */}
{/* The page's one scroll region. */}
<div className="lc-ov-pagescroll">
<div className="lc-ov-pagecontent">
<div className="lc-ov-pagehead">
<h1 className="lc-ov-pagetitle">{t('ov.title')}</h1>
{/* The preferences entry: one quiet chip beside the title, the same
best-effort preferences jump the Context tab's plugin-info row
rides. The jump selects the Plugins panel, which unmounts this
page on its own; the long hint rides the hover title. */}
<button type="button" className="lc-ov-settings" title={t('plugin.settingsOpen')} onClick={() => { openPluginSettings() }}>
<IconSettings size={14} />{t('plugin.settings')}
</button>
{/* 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. */}
<BalanceCapsule />
{/* 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. */}
<div className="lc-ov-headctl">
<DateRange
value={span}
@@ -254,10 +202,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
<div className="lc-empty">{t('ov.unavailable')}</div>
) : (
<>
{/* 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. */}
<div className="lc-ov-first">
<div className="lc-ov-kpis">
<div className="lc-stat lc-ov-kpi">
@@ -293,9 +238,6 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
</div>
<OverviewUsage days={days} currency={currency} today={todayKey()} />
</div>
{/* 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). */}
<div className="lc-ov-stats">
<OverviewTokens tokens={kpi.tokenParts} />
<StatsTiming timing={kpi.timing} />
@@ -305,9 +247,6 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
<div className="lc-card lc-ov-heat-card">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('ov.heat.title')}</span>
{/* 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). */}
<span className="lc-heat-ctl">
<span className="lc-card-sub">{t('ov.heat.sub')}</span>
<div className="lc-gran" role="group" aria-label={t('ov.heat.metric')}>
@@ -324,12 +263,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
</div>
<Heatmap days={days} metric={metric} selected={day} onSelect={setDay} today={todayKey()} />
</div>
{/* 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. */}
<OverviewSkills
stats={skillLoadsOf(ranged, { scope, day, sort: skillSort })}
day={day}
+11 -25
View File
@@ -37,11 +37,8 @@ import { copySkillPath } from '../skills'
import { IconCheck, IconCopy } from '../primitives'
import type { ViewKit } from '../viewkit'
/**
* The render bound on listed skills — a hostile corpus can coin a name per
* injection; the overflow line keeps the tally honest without a
* thousand-row card.
*/
/** The render bound on listed skills — a hostile corpus can coin a name per
* injection; the overflow line keeps the tally honest without a thousand-row card. */
const MAX_ROWS = 20
/** The description's display bound — a registry description can run paragraphs. */
@@ -79,11 +76,9 @@ export function skillColorOf(name: string): string {
return SKILL_COLORS[hash % SKILL_COLORS.length]
}
/**
* The origin bucket's display label: the registry's known discovery sources
/** The origin bucket's display label: the registry's known discovery sources
* localize; an unknown or provider-named source displays raw (it is the
* registry's data, not UI chrome), and a source-less entry has no label.
*/
* registry's data, not UI chrome), and a source-less entry has no label. */
function originLabelOf(t: ViewKit['t'], info: SkillInfo): string {
const source = info.source
if (source === 'project-dsh' || source === 'project-agents') return t('ov.skills.src.project')
@@ -156,15 +151,13 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
// The bar's denominator is the heaviest row's tally across the whole
// scope, wherever the sort put it — keying on the first row overflows
// the card the moment another ordering (sessions / recent) leads with a
// lighter row, and keeping the scope's denominator stops the bars from
// jumping while a name filter narrows the rows.
// lighter row, and keeping the scope's denominator stops the bars from jumping while a name filter narrows the rows.
const max = stats.reduce((top, s) => 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<HTMLDivElement | null>(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) =
</button>
)
// 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
? <div key={s.name} className="lc-ov-skill-unit">{rowButton}{detailBlock}</div>
: <Fragment key={s.name}>{rowButton}</Fragment>
+2 -5
View File
@@ -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'
+3 -5
View File
@@ -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<string, DayTotals | undefined> = 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 (
<div key={c.key} className="lc-ov-usage-col" data-lc-tip={label}>
+6 -14
View File
@@ -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 (
<svg width={12} height={12} className="lc-pi-labelicon" viewBox="-2 -2 28 28" fill="none" aria-hidden>
@@ -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) => (
<a className="lc-pi-row group/pi" href={href} target="_blank" rel="noreferrer">
<div className="lc-pi-label">{icon}{label}</div>
@@ -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(<span key="update" className="lc-pi-update">{'↑ v' + update}</span>)
// 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 (
<div className="lc-card flex-1 min-w-[min(240px,100%)]">
<div className="lc-card-title">
+18 -36
View File
@@ -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<RequestDetailProps> {
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)
+12 -39
View File
@@ -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 `<pre>`. 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 `<mark>` 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 `<pre>` 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<HTMLDivElement>
}
/** 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 `<mark>`, 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 `<mark>`. 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<RichMode>('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<MarkdownChrome>(() => ({
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 <RawText text={props.text} />
}
// 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('')
+3 -15
View File
@@ -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 {
+4 -13
View File
@@ -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: {
+11 -22
View File
@@ -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 }
+25 -187
View File
@@ -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<number>()
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<string>()
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<ReadonlyMap<string, ContextTimeline>>(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<string, number>()
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<FlowNodeKey, FlowBox>, 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 ? (
<span key="prices" className="lc-stat-tip-prices">
@@ -335,8 +212,6 @@ export function makeStatsContext(
))}
</span>
) : 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' ? <span key="cny">{t('stats.costTipCny')}</span> : null,
@@ -349,8 +224,6 @@ export function makeStatsContext(
...notes(deepseek),
unpriced ? <span key="unavailable">{t('stats.costUnavailable')}</span> : 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'),
<span key="billed" className="lc-stat-tip-row">{t('stats.billedTip')}</span>,
@@ -358,10 +231,8 @@ export function makeStatsContext(
...notes(subDeepseek),
subUnpriced ? <span key="unavailable">{t('stats.costUnavailable')}</span> : 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<HTMLDivElement | null>(null)
const nodeEls = useRef(new Map<FlowNodeKey, HTMLDivElement>())
const [links, setLinks] = useState<FlowLink[]>([])
@@ -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(
? <div className={className}>{body}</div>
: <a className={className} href={href} target="_blank" rel="noreferrer noopener">{body}</a>
}
// 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 => (
<span key={label} className={'lc-flow-pill' + cls + (value === 0 ? ' lc-flow-pill-dim' : '') + (tip === undefined ? '' : ' lc-stat-tipped group/tip')}>
<span className="lc-flow-pill-label">
@@ -430,9 +293,6 @@ export function makeStatsContext(
{tip !== undefined && <span className="lc-tip lc-stat-tip group-hover/tip:opacity-100" role="tooltip">{tip}</span>}
</span>
)
// 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 => (
<span
@@ -452,30 +312,18 @@ export function makeStatsContext(
<i>{label}</i>
</span>
)
// 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 => (
<span className="lc-flow-pair"><b>{fmt(tokens)}</b><i>/</i><b>{costText}</b></span>
)
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(
<span className="lc-card-title-text">{t('stats.title')}</span>
</div>
<div className="lc-stats lc-flow" ref={flowRef}>
{/* 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. */}
<svg className="lc-flow-svg" aria-hidden="true">
{links.map((l, i) => (
// Fixed four-link set in a fixed order: the index IS the identity
// (zero-measured boxes produce identical d strings — jsdom, SSR).
// Fixed four-link set in fixed order: the index IS the identity (zero-measured boxes produce identical d strings).
<g key={i}>
<path className="lc-flow-ribbon" d={l.d} stroke={l.color} />
<path className="lc-flow-dash animate-lc-agent-flow motion-reduce:animate-none" d={l.d} stroke={l.color} />
@@ -518,8 +361,7 @@ export function makeStatsContext(
<div className="lc-flow-col">
<div className="lc-flow-node" ref={nodeRef('inputs')}>
{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. */}
<div className="lc-flow-pills">
{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). */}
<div className="lc-flow-rows">
<div className="lc-flow-row">
<span className="lc-flow-row-label">{t('stats.currentAgent')}</span>
+10 -21
View File
@@ -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),
+3 -6
View File
@@ -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 (
+1 -2
View File
@@ -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) {
+79 -304
View File
@@ -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<number, number>()
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<ContextEventRecord | undefined>(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 (
<div
@@ -528,25 +406,17 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => 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<string | null>(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<VisibleMax | null>(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<HTMLDivElement | null>(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<HTMLElement>('.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<HTMLElement>('.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 ? (
<>
<span className="lc-axis-top">{fmtSigned(maxUp)}</span>
{/* 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
? <span className="lc-axis-q3">{fmtSigned(Math.round(maxUp - span / 4))}</span>
: null}
@@ -1024,9 +820,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
</>
)}
</div>
{/* 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. */}
<div className="lc-chart-wrap">
<div
className={'lc-chart-scroll' + (props.activeTurn !== null ? ' lc-chart-dim' : '')}
@@ -1041,26 +836,22 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
>
<div
className="lc-chart"
// The shared category hover rides a plain attribute: the CSS lights that key's segment in EVERY bar
// and recedes the rest, so the memoized bars never re-render on a cross-card hover change.
// The shared category hover rides a plain attribute: the CSS lights that key's segment in every bar,
// so the memoized bars never re-render on a cross-card hover.
data-catdim={props.hoverCat ?? undefined}
onMouseLeave={() => { props.onHover(null); setDnaHit(null) }}
>
<div className="lc-grid lc-grid-top" />
{/* 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) ? <div className="lc-grid lc-grid-q3" /> : null}
{(!delta || q1Clear) ? <div className="lc-grid lc-grid-q1" /> : null}
{!delta ? <div className="lc-grid lc-grid-mid" /> : 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. */}
<div className="lc-grid lc-grid-zero" style={delta ? { top: `${18 + upPx}px` } : undefined} />
{requests.map((req, i) => (
<ChartBar
// Granularity belongs in the key: a turn aggregate IS its last step's record (the same
// seq), so a step ↔ turn switch would otherwise REUSE that bar's DOM node and its finished
// entrance rise would not replay — every turn's last step bar would pop in unanimated.
// Granularity belongs in the key: a turn aggregate IS its last step's record, so a switch would
// reuse the DOM node and never replay its rise.
key={`${req.seq}:${props.granularity}`}
req={req}
marker={markers[i]}
@@ -1073,8 +864,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => 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 ? <DnaHighlights slices={dnaSlices} /> : 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) ? (
<svg
className="lc-duration"
@@ -1115,9 +902,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
</svg>
) : null}
</div>
{/* 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.
*/}
<div className="lc-turns" style={labelFont !== '' ? { fontSize: labelFont } : undefined} onMouseLeave={() => { 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
})}
</div>
</div>
{/* 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 ? (
<div className="lc-chart-tip">{tipRowsOf(hoveredReq).map((row, i) => <span key={i}>{row}</span>)}</div>
) : null}
</div>
{/* 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 ? (
<div className="lc-axis lc-axis-r">
<span className="lc-axis-top">{fmtDuration(maxActiveMs)}</span>
+7 -16
View File
@@ -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<string>()
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<string | null>(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
+38 -132
View File
@@ -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<string, Record<string, PriceTriple>>
/**
* 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<string, PriceCandidate[]> }
/** 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/<its own id>` 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/<its own id>`
* marks a first-party provider, while mirrors and gateways ride a generic or foreign package. */
export function priceIndexOf(prices: ModelPrices, npmOf: Record<string, string | null>): PriceIndex {
const byModel = new Map<string, PriceCandidate[]>()
const push = (key: string, cand: PriceCandidate): void => {
@@ -126,11 +84,7 @@ export function priceIndexOf(prices: ModelPrices, npmOf: Record<string, string |
return { byModel }
}
/**
* The candidate carrying the rate shared by a candidate group, or null: the
* largest equal-rate group wins, a tie refuses. Agreeing mirrors are noise;
* disagreeing ones price nothing rather than guess.
*/
/** The candidate whose rate the most candidates share, or null on a tie. */
function majorityCandidate(cands: PriceCandidate[]): PriceCandidate | null {
const groups: { cand: PriceCandidate; n: number }[] = []
for (const c of cands) {
@@ -142,16 +96,10 @@ function majorityCandidate(cands: PriceCandidate[]): PriceCandidate | null {
return groups.length > 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<string, PriceTriple> | null {
const v: unknown = book[id]
return v !== null && typeof v === 'object' ? (v as Record<string, PriceTriple>) : 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<string, PriceTriple>, 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<string, PriceTriple>, 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
+18 -51
View File
@@ -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
+4 -15
View File
@@ -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 (`<sidebar>px <centre> <rightbar>` —
* 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 (`<sidebar>px <centre> <rightbar>`) 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) {
+32 -114
View File
@@ -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<FileOpKind | 'image', FileKindTotal> & { 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<string, FileEntry>()
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<number, number>()
@@ -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<string, unknown>
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<object>(), 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
+7 -24
View File
@@ -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`
+2 -11
View File
@@ -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<string, unknown> }
declare let exports: Record<string, unknown>
// 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<string, string>
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
+15 -46
View File
@@ -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
+43 -128
View File
@@ -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<string, unknown> } | 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<stri
return { type: e.type, seq: e.seq, data }
}
/** All string texts of an event's message-content shape joined (compaction summaries). */
function textOf(blocks: unknown): string | null {
if (!Array.isArray(blocks)) return null
let out = ''
@@ -55,12 +40,8 @@ function textOf(blocks: unknown): string | null {
return out.trim() === '' ? null : out
}
/**
* The system prompt's exact rendered text — every text block joined with NO
* normalization: a whitespace-only prompt is still the prompt the model
* received (the host prices it as text), so it must render rather than read
* as absent. Null when the content carries no text block at all.
*/
/** The system prompt's exact text, every block joined with NO normalization: a
* whitespace-only prompt is still the prompt the model received, so it must render rather than read as absent. */
function systemTextOf(blocks: unknown): string | null {
if (!Array.isArray(blocks)) return null
let out = ''
@@ -75,11 +56,7 @@ function systemTextOf(blocks: unknown): string | null {
return seen ? out : null
}
/**
* One assistant content block → the snapshot block vocabulary the browser
* already renders (`kind`: text/reasoning/image/tool-call); unmappable
* blocks pass through raw and degrade to the generic JSON section.
*/
/** One assistant content block → the snapshot block vocabulary; unmappable blocks pass through raw. */
function assistantBlockOf(block: unknown): Record<string, unknown> {
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<string, unknown> {
}
}
/**
* 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<number, ConversationNodeLike> {
const nodes = new Map<number, ConversationNodeLike>()
const calls = new Map<string, { name: string; argsRaw: string }>()
@@ -137,9 +109,8 @@ export function pageNodesOf(entries: readonly unknown[]): Map<number, Conversati
? source.compactionId
: null
if (compactionId !== null) {
// A compaction checkpoint: the model-visible envelope never renders —
// the marker shows its summary instead (null when the page cut left
// the summary event outside).
// A compaction checkpoint: the model-visible envelope renders its
// summary instead (null when the page cut left the summary outside).
nodes.set(seq, { kind: 'compaction', seq, summary: summaries.get(compactionId) ?? null })
continue
}
@@ -161,9 +132,8 @@ export function pageNodesOf(entries: readonly unknown[]): Map<number, Conversati
continue
}
if (type === 'tool/result') {
// The V4 first-class tool-role message: the lifted `toolCallId` /
// `isError` live ON the message (the durable source mirrors the call
// id), and the content is the direct block list.
// The V4 first-class tool-role message: `toolCallId`/`isError` live on
// the message and the content is the direct block list.
const message = data.message !== null && typeof data.message === 'object' ? data.message as Record<string, unknown> : null
const source = message?.source !== null && typeof message?.source === 'object' ? message.source as Record<string, unknown> : null
const callId = typeof message?.toolCallId === 'string' ? message.toolCallId
@@ -183,9 +153,7 @@ export function pageNodesOf(entries: readonly unknown[]): Map<number, Conversati
return nodes
}
/** The `page` verb of a history face, re-proved and bound to its owner.
* Hostile objects (any accessor backed by host state can throw) degrade to
* undefined instead of escaping the read. */
/** Bind the face's `page` verb; a hostile accessor degrades to undefined. */
function readPageOf(face: unknown): SessionPageFace['page'] | undefined {
if (face === null || typeof face !== 'object') return undefined
try {
@@ -196,11 +164,8 @@ function readPageOf(face: unknown): SessionPageFace['page'] | undefined {
}
}
/**
* The gateway history page verb, resolved through the DECLARED inject
* (see {@link watchHistoryFaces}) and bound up front: a method extracted
* unbound loses `this`, and the traced `remote` proxy that hands it out
* requires the inject to resolve at all.
/** The gateway history page verb from the DECLARED inject, bound up front: a
* method extracted unbound loses `this`, and the traced `remote` proxy that hands it out requires the inject to resolve at all.
*/
let declaredPage: SessionPageFace['page'] | undefined
@@ -211,44 +176,30 @@ function setPageFace(page: SessionPageFace['page'] | undefined): void {
for (const listener of [...faceListeners]) listener()
}
/** The page face resolved so far, for non-React readers (the fetcher builders). */
export function historyFace(): SessionPageFace['page'] | undefined {
return declaredPage
}
/** Subscribe to face resolution and revocation (plugin reload/HMR). */
export function subscribeHistoryFace(listener: () => 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<unknown>) | 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<unknown>) |
}
}
/**
* 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<number, ConversationNodeLike>()
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<string, unknown> }): HeaderEpochContent | null {
const { type, data } = event
if (type === 'system/message') {
@@ -365,17 +291,9 @@ function headerContentOf(event: { type: string; data: Record<string, unknown> })
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
}
+3 -10
View File
@@ -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, string | number>) => string
@@ -327,8 +325,7 @@ export const DICT_ZH: Record<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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',
+13 -55
View File
@@ -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 `<svg>` tags: the sheet's strokes in paint order, whitespace-folded. */
const SHEET_MARKUP = sheetMarkup
.slice(sheetMarkup.indexOf('>') + 1, sheetMarkup.lastIndexOf('</svg>'))
.trim()
.replace(/>\s+</g, '><')
/** 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 => `<rect ${CANVAS_ATTRS} fill="${fill}"${rest}/>`
/** 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 (
<svg
@@ -101,28 +72,15 @@ export function ContextIcon({ size = 20, className, mono = false }: ContextIconP
)
}
/**
* The sidebar panel-list glyph (`sidebar.panellist`): the emblem in `mono` at
* the size the shell asks for, so the shell-owned row's hover/active colors
* paint it like the shipped panel glyphs. The shell owns the row's label,
* and the row's selected state its own styling, so — as on the shipped
* glyphs — the owner props' `active` goes unread.
*/
/** The sidebar panel-list glyph: the emblem in `mono`, so the shell-owned row's hover/active colors paint it
* like the shipped rows; the row's label and selected state are the shell's, so owner `active` goes unread. */
export function InsightPanelIcon({ size = 18 }: { size?: number }): ReactElement {
return <ContextIcon size={size} mono />
}
/**
* 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 (
+15 -71
View File
@@ -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 <style data-plugin> tag at factory execution (the web boot
// loader and the HMR receiver claim tags carrying data-plugin). Import order
// IS cascade order across same-specificity rules: the Tailwind utilities
// first (the sibling sheets keep winning same-specificity ties), then base,
// then the per-component sheets in their original section order.
// Import order IS cascade order among same-specificity rules: the Tailwind utilities sheet stays first,
// then base, then the per-component sheets, which keep winning the ties.
import './styles/tailwind.css'
import './styles/base.css'
import './styles/stats.css'
@@ -66,36 +42,22 @@ import './styles/dateRange.css'
const NS = 'dsh-context'
function apply(ctx: ClientCtx): void {
// Bilingual dictionaries, registered via ctx.effect so a stop or HMR reload
// disposes them; the tab label thunk and all UI text follow the active
// locale through the bound translate — missing keys resolve through the
// harness chain (en fallback, then the key).
ctx.effect(() => {
return ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN })
}, 'dsh-context: dictionaries')
const t = ctx.locale.bind(NS)
const kit = makeViewKit(t)
// History face of the harness gateway remotes, resolved through the
// DECLARED inject — a non-declared read of the traced `remote.session`
// proxy throws ("cannot get property … without inject") and would take
// the browser down with the view. Injection waits for the service; a
// harness that never composes the namespace never fires the callback and
// the targeted fetches simply stay absent.
// History faces are resolved through the DECLARED inject: a non-declared read of the traced remote proxy
// throws "cannot get property … without inject" and takes the view down (see historyPage.ts).
watchHistoryFaces(ctx)
const settings = createContextSettings()
const ContextView = makeContextView(ctx, kit, settings)
// Placement: the per-user `defaultPlacement` preference picks which
// registration carries the view — the conversation tab, the right Sidebar
// (every supported line, optional by contract), or both (the default). Each
// mount owns its disposer so the watcher takes down exactly what a
// preference flip drops (see placement.ts).
ctx.effect(() => watchPlacement(settings, {
tab: () => ctx.slots.inject('conversation.view', () => {
return ctx.slots.register(
// order 20 renders right of Chat (0) and Trajectory (10); the locale
// namespace put the framework `t` seat on the component's props too.
// order 20 renders right of Chat (0) and Trajectory (10).
{ name: 'conversation.view', id: 'context', order: 20, locale: NS, label: () => t('tab') },
props => h(ContextView, props),
)
@@ -103,10 +65,8 @@ function apply(ctx: ClientCtx): void {
sidebar: () => watchSidebarContextTab(ctx, ContextView, t, NS),
}), 'dsh-context: placement')
// Chat → Context jump: an icon in each finalized reply's action row that
// opens the Context tab on the right Sidebar pinned to that reply's turn —
// falling back to the conversation tab wherever the sidebar serves no tab
// (see contextJump.tsx; the relay and view activation live in viewFocus.ts).
// Chat → Context jump: opens the right Sidebar's tab pinned to the reply's turn where served, else the
// conversation tab (contextJump.tsx; relay and activation in viewFocus.ts).
const ContextJump = makeContextJumpButton(ctx, kit)
ctx.slots.inject('conversation.chat.assistant-actions', () => {
return ctx.slots.register(
@@ -116,9 +76,6 @@ function apply(ctx: ClientCtx): void {
)
})
// `/context` slash command: opens the context modal (see command.ts for
// the trigger source). The modal itself renders from the input overlay
// slot, opened per session through the hooks-compartment store.
registerContextCommand(ctx, kit)
const ContextModal = makeContextModal(ctx, kit, settings)
ctx.slots.inject('conversation.input.overlay', () => {
@@ -132,13 +89,8 @@ function apply(ctx: ClientCtx): void {
)
})
// The Context Insights page (see components/overviewPanel.tsx): the
// cross-session insight surface as a first-level panel, a sibling of the
// shipped Plugins and Automation tasks pages. The page registers on the
// layout's keyed `main` slot and the sidebar entry on `sidebar.panellist`,
// both under one id (insightPage.ts); the shell owns the entry row and the
// panel switching, so no plugin-side open state exists. The `insightsEntry`
// preference mounts or unwinds the pair.
// Context Insights page: the panel on the keyed `main` slot and its sidebar entry on
// `sidebar.panellist`, both mounted or unwound by the `insightsEntry` preference (insightPage.ts).
const OverviewPanel = makeOverviewPanel(ctx, kit)
ctx.effect(() => watchInsightPage(
ctx,
@@ -149,8 +101,6 @@ function apply(ctx: ClientCtx): void {
NS,
), 'dsh-context: insight page')
/** The injected face the preference card rides: the settings store as the
* framework's hooks-compartment `useContextSettings` seat, plus the set verb. */
const cardFace = (): {
hooks: { contextSettings: typeof settings.store }
set: (field: SettingsField, value: string) => void
@@ -159,13 +109,8 @@ function apply(ctx: ClientCtx): void {
set: (field, value) => { settings.set(field, value) },
})
// Per-user display preferences on the Config-form generation's transport
// and seat (the only ones the supported lines serve): the card rides the
// configForms service and the Plugins page's keyed `plugins.bundle.config`
// slot, alive only while the Host serves the namespace (the entry Config's
// volatile preference fields). The deferred inject fires only where the
// service exists — a host without it never runs the callback (no pending
// fiber, no throw), and the preferences simply hold their schema defaults.
// Preferences ride the configForms service and the Plugins page's keyed `plugins.bundle.config` slot.
// The deferred inject fires only where the service exists, so a host without it keeps the schema defaults.
ctx.inject(['configForms'], (raw) => {
const c = raw as ClientCtx & { configForms?: ConfigFormsFace }
const forms = c.configForms
@@ -173,8 +118,7 @@ function apply(ctx: ClientCtx): void {
c.effect(() => settings.attach(forms.get(NS)), 'dsh-context: config forms')
const PluginConfigCard = makePluginConfigCard(kit)
c.effect(() => forms.whileServed([NS], () => {
// slots.inject's disposer is the registration's disposer (the typed
// local face reads unknown; the harness contract returns a disposer).
// slots.inject's disposer is the registration's disposer.
return c.slots.inject('plugins.bundle.config', () => {
return c.slots.register(
{ name: 'plugins.bundle.config', key: NS, locale: NS, inject: cardFace },
+7 -40
View File
@@ -1,29 +1,7 @@
/**
* The Context Insights page — the plugin's first-level panel, a sibling of
* the shipped Plugins and Automation tasks pages.
*
* Two registrations, one identity:
* - the full page itself on the layout's root keyed `main` slot, under the
* panel key — the shell's center column renders it whenever the panel is
* selected (keyed entryKey switching mounts it on entry and unmounts it
* on leave, so the page's mount IS its open);
* - the sidebar entry on the root list `sidebar.panellist` under the same
* id — the shell owns the row (button, label, active state, and the
* click that selects the panel), the plugin contributes only the glyph
* and the localized label thunk.
*
* Both seats ship on every supported line (the compat matrix pins both
* spellings per baseline), so the registrations ride plain `slots.inject`
* declaration injects, like the conversation tab's.
*
* The per-user `insightsEntry` preference gates the pair: 'show' mounts both
* registrations, 'hide' unwinds them (a hidden entry leaves the page no
* opener). The watcher mirrors placement.ts's preference-glued mounting;
* each mount owns its disposers so a preference flip takes down exactly what
* it drops.
*
* @module dsh-context/client/insightPage
*/
/** The Context Insights page: two registrations under one id — the page on the layout's root keyed `main`
* slot (keyed entry switching makes the page's mount its open) and the shell-owned sidebar row on
* `sidebar.panellist`. The `insightsEntry` preference gates the pair: 'hide' unwinds both, leaving the
* page no opener; each mount owns its disposers. */
import { createElement as h } from 'react'
import { InsightPanelIcon } from './icon'
@@ -31,11 +9,8 @@ import type { Translate } from './i18n'
import type { ContextSettings, InsightsEntry } from './settings'
import type { ClientCtx } from './services'
/**
* The id shared by the sidebar entry and the `main` panel it opens (the
* layout's MainPanelId domain) — namespaced, like the right-Sidebar kind, so
* no shipped or foreign panel collides with it.
*/
/** The id shared by the sidebar entry and the `main` panel it opens — namespaced, like the right-Sidebar
* kind, so no shipped or foreign panel collides with it. */
export const INSIGHT_PANEL_ID = 'dsh-context'
/** The sidebar row's position: after the shipped Plugins (0) and Automation tasks (10) entries. */
@@ -43,14 +18,6 @@ export const INSIGHT_PANEL_ORDER = 20
/**
* Mount the page and its sidebar entry while the preference shows them.
* @param ctx - client root context carrying `slots` and the locale service.
* @param settings - the preferences store; the gate re-reads on every publish.
* @param page - the Insights page component (the root-scope standard kit
* arrives on its props, as on every root seat).
* @param t - the plugin-namespace translate; the label thunk reads it at call
* time, so a language switch relabels the sidebar row.
* @param ns - the plugin's locale namespace, put on both registrations so the
* framework synthesizes the `t` seat for the page too.
* @returns the watcher's disposer (unsubscribes and unwinds any live mount).
*/
export function watchInsightPage(
@@ -72,7 +39,7 @@ export function watchInsightPage(
)))
own(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
{ name: 'sidebar.panellist', id: INSIGHT_PANEL_ID, order: INSIGHT_PANEL_ORDER, label: () => t('ov.title'), locale: ns },
// The owner share is the icon's size (and the row's active flag, unread);
// The owner share is the icon's size (the row's active flag goes unread);
// the registry's component typing widens it to the generic slot props.
props => h(InsightPanelIcon, { size: typeof props.size === 'number' ? props.size : undefined }),
)))
+3 -9
View File
@@ -1,11 +1,5 @@
/**
* One live check for the Plugin-info card: lazy with 1h TTL; registries
* are tried strictly in order — official npm first, npmmirror only as a
* fallback — and one-at-a-time, never in parallel. Each source's failure
* (non-ok response, missing/non-string version, network error) advances
* to the next one; only an all-source failure resolves to null so the
* card silently keeps its static version.
*/
/** Lazy 1h TTL check for the Plugin-info card: npm first, npmmirror as fallback, one at a time; all
* sources failing resolves null. */
import { PLUGIN_NAME } from './meta'
@@ -41,7 +35,7 @@ export function fetchLatestVersion(): Promise<string | null> {
return cached.promise
}
/** Numeric semver compare (pre-release suffix ignored): is `latest` strictly newer than `current`? */
/** Numeric semver compare; the pre-release suffix is dropped (1.2.0-rc.1 == 1.2.0). */
export function isNewerVersion(latest: string, current: string): boolean {
const parse = (v: string): number[] =>
v.replace(/^v/, '').split('-', 1)[0].split('.').map(n => parseInt(n, 10) || 0)
+1 -5
View File
@@ -1,8 +1,4 @@
/**
* Plugin self-metadata for the Plugin info card. Version is substituted at
* build time (tsdown `define` in tsdown.config.ts, read from package.json);
* the typeof guard keeps a dev/test bundle built without defines working.
*/
/** Build-time defines (tsdown.config.ts); the typeof guards cover a bundle built without them. */
declare const __DSH_CTX_VERSION__: string | undefined
declare const __DSH_CTX_REPO__: string | undefined
+4 -14
View File
@@ -1,10 +1,5 @@
/**
* Per-session open-state stores for the /context modal, plus the deferred
* token-consume guards. Module-level so the state survives overlay remounts;
* the trigger source (pick/enter) opens it, the overlay component subscribes
* and renders. Implements the ObservableSnapshot pair the slot `hooks`
* compartment binds as a `useContextModal` selector hook.
*/
/** Per-session open-state stores for the /context modal, plus the deferred token-consume guards;
* module-level so the state survives overlay remounts. */
import type { TokenSpan } from './services'
@@ -37,13 +32,8 @@ export function modalStoreOf(sessionId: string): ModalStore {
return store
}
// ---- deferred token consumption -------------------------------------------
// The `/context` token stays in the composer while the modal is open and is
// consumed only when the modal closes. Each open path records the guard the
// input shell understands (span CAS for menu picks, bare-token equality for
// enter); the close path takes it and dispatches the scoped
// `slash/input-consume-token` event. A stale guard (the user typed meanwhile)
// fails soft inside the shell — the draft is left untouched.
// Each open path records the guard the input shell understands — span CAS for picks, bare-token equality
// for enter — and the close path dispatches `slash/input-consume-token`; a stale guard fails soft.
export type ConsumeGuard =
| { kind: 'span'; span: TokenSpan }
+13 -35
View File
@@ -1,16 +1,9 @@
/**
* The client's model-price book (client/cost.ts prices from it): the
* models.dev registry, fetched through the official @opencode-ai/models
* SDK. The registry payload is untrusted wire input, so `pricesBookOf`
* re-proves every field at the boundary — a non-conforming provider/model/
* cost entry drops whole. The book keeps EVERY provider that prices (keyed
* by the registry's own provider id), so a dsh provider id outside the
* rename table still prices by direct passthrough, and carries each
* provider's `npm` package into the model-side resolution index
* (cost.ts `priceIndexOf`) so unknown provider ids price by model id alone.
* One fetch per page load, kicked on first subscribe; a failure degrades to
* a visible state the cost cell notes, with a backed-off automatic retry —
* never a spinner, and never an unhandled rejection.
* The client's model-price book: the models.dev registry through the official
* @opencode-ai/models SDK. The payload is untrusted wire input, so
* `pricesBookOf` re-proves every field and a non-conforming entry drops whole.
* One fetch per page load, kicked on first subscribe; a failure shows a visible
* state with a backed-off retry, never a spinner or an unhandled rejection.
*/
import { Models } from '@opencode-ai/models'
@@ -19,18 +12,13 @@ import { asRecord } from './services'
import { priceIndexOf } from './cost'
import type { ModelBook, ModelPrices, PriceTriple } from './cost'
/** One finite non-negative registry figure, or null. */
function rateOf(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
/**
* Extract the whole registry's per-model USD rates from a delivered
* providers payload (`/api.json`), keyed by the registry's provider ids,
* with the resolution index built over it. Any shape failure skips just
* that entry; a payload that is not a record at all returns null (the store
* treats it as a failed fetch and retries).
*/
/** Extract the registry's per-model USD rates from a delivered providers payload
* (`/api.json`) and build the resolution index over it. Any shape failure skips
* that entry; a payload that is not a record returns null (a failed fetch). */
export function pricesBookOf(value: unknown): ModelBook | null {
const data = asRecord(value)
if (data === null || Array.isArray(data)) return null
@@ -63,11 +51,8 @@ export function pricesBookOf(value: unknown): ModelBook | null {
return { prices: book, index: priceIndexOf(book, npmOf) }
}
/** The store's observable snapshot, identity-stable between transitions. */
export interface ModelPricesSnap {
/** The extracted book with its resolution index, null until the first successful fetch. */
book: ModelBook | null
/** The last fetch failed (the cost cell notes the outage until a retry lands). */
failed: boolean
}
@@ -75,7 +60,6 @@ type Loader = () => Promise<unknown>
const defaultLoader: Loader = () => Models.make().providers()
/** The retry backoff base; each consecutive failure doubles the wait, capped at 3 doublings. */
const RETRY_BASE_MS = 30_000
let loader: Loader = defaultLoader
@@ -86,17 +70,14 @@ let timer: ReturnType<typeof setTimeout> | null = null
const listeners = new Set<() => void>()
function fail(): void {
// Single-arm invariant: fail() runs exactly once per fetch cycle (only
// fire() calls it, and both fire() callers — kick() and the timer
// callback — are blocked while a timer is armed), so re-arming here can
// never leak a timer.
// fail() runs exactly once per fetch cycle (only fire() calls it, and both
// fire() callers are blocked while a timer is armed), so re-arming cannot leak.
snap = { ...snap, failed: true }
failures++
timer = setTimeout(() => {
timer = null
// Nobody reads the book anymore (the panel that subscribed is gone):
// end the cycle here — the next subscribe kicks a fresh one — instead of
// polling an unreachable registry for the rest of the page's life.
// Nobody reads the book anymore (the panel that subscribed is gone): end the
// cycle instead of polling an unreachable registry for the page's whole life.
if (listeners.size === 0) return
void fire()
}, RETRY_BASE_MS * 2 ** Math.min(failures - 1, 3))
@@ -124,7 +105,6 @@ function kick(): void {
void fire()
}
/** The useSyncExternalStore seam: subscribing also kicks the first fetch. */
export const subscribeModelPrices = (fn: () => void): (() => void) => {
listeners.add(fn)
kick()
@@ -135,12 +115,11 @@ export const subscribeModelPrices = (fn: () => void): (() => void) => {
export const getModelPricesSnap = (): ModelPricesSnap => snap
/** The stats board's read of the price book (null until the fetch lands). */
/** The stats board's read of the price book. */
export function useModelPrices(): ModelPricesSnap {
return useSyncExternalStore(subscribeModelPrices, getModelPricesSnap)
}
/** Test isolation: drop the book, the retry timer, and the listeners. */
export function resetModelPrices(): void {
if (timer !== null) {
clearTimeout(timer)
@@ -152,7 +131,6 @@ export function resetModelPrices(): void {
listeners.clear()
}
/** Test seam: replace the loader (the SDK fetch) with a stub; null restores the default. */
export function setModelPricesLoader(next: Loader | null): void {
loader = next ?? defaultLoader
}
+5 -15
View File
@@ -1,19 +1,9 @@
/**
* Stop a horizontal scroller's overscroll from reaching the browser's history navigation — the trackpad
* swipe-back (left edge) and swipe-forward (right edge) gestures. The spec answer is `overscroll-behavior-x:
* contain`, the harness's own idiom for its horizontal scrollers and what the sheets beside this module's
* callers already set; it covers Chromium and Firefox, but WebKit still navigates on horizontal overscroll with
* the property set (bug 240183), where a canceled wheel is the only lever. Without either, one swipe past the
* edge of the trend chart or the agent graph leaves the whole app for another page in the tab's history.
*/
/** Stop a horizontal scroller's overscroll from reaching browser history navigation (trackpad
* swipe-back/forward). `overscroll-behavior-x: contain` covers Chromium and Firefox, but WebKit still
* navigates with it set (bug 240183), so the canceled wheel below is the only lever there. */
/**
* Cancel the horizontal-dominant wheel gestures a scroller cannot consume — the ones the browser would
* otherwise read as a history swipe. Vertical-dominant gestures are left alone: they belong to the page's own
* scrolling, and the scrollers this guards only overflow horizontally.
* @param el - the horizontal scroll container (the listener must be non-passive, so it cannot ride React's passive wheel seat).
* @returns the disposer removing the listener.
*/
/** Cancel the horizontal-dominant wheel gestures a scroller cannot consume, so the browser never reads
* them as a history swipe. The listener must be non-passive, so it cannot ride React's passive wheel seat. */
export function containHorizontalOverscroll(el: HTMLElement): () => void {
const onWheel = (e: WheelEvent): void => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
+79 -278
View File
@@ -1,23 +1,12 @@
/**
* The Context Dashboard's data layer: everything the panel renders is derived
* here, off the root-scope `useSessions` standard prop. Each session-list
* row carries the host-cached projection values — this plugin's
* `contextTimeline` (composition, counts, cost) and `contextActivity` (the
* daily ledger) — so the overview joins every session's insight WITHOUT
* opening a single session log.
* The Context Insights page's data layer, derived off the root-scope `useSessions`
* snapshot: every session-list row already carries the host's
* `contextTimeline` and `contextActivity`, so the panel reads every session's
* insight without opening a log.
*
* The row IS the team: subagent-origin sessions never list (the workspace
* browser's own exclusion), so every row folds its WHOLE descendant subtree
* into itself — the card's billed tokens and cost, the KPI band, the usage
* chart, and the heatmap all read the family scope, each member contributing
* its own composition and daily ledger, nothing counted twice.
*
* The list is harness data (untrusted at the boundary): the snapshot is
* re-proved field by field, every projection value passes its services.ts
* sanitizer, and each row's derivation is isolated — one hostile row drops
* to a metadata-only card or out of the list, never an error card. All
* functions here are pure (the hook-level read lives in
* {@link sessionsSnapshotOf}), so the panel's rendering stays a trivial map.
* Subagent-origin sessions never list (the workspace browser's exclusion), so
* each row folds its whole descendant subtree, and one family scope feeds the
* card, KPI band, chart, and heatmap.
*/
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
@@ -30,38 +19,25 @@ import { agentRowOf } from './agentTree'
import { activityOf, asRecord, timelineOf, type ClientCtx, type SessionsFace } from './services'
import type { ActivityDay, ContextActivity, ContextTimeline, SessionCostUsage, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types'
/** One session-list row joined with its (sanitized) projection values. */
export interface OverviewRow {
id: string
/** Display title: durable title, project basename, then the raw id (the list's own ladder). */
title: string
cwd?: string
updatedAt: number
running: boolean
/** The session the conversation currently shows — its card carries the "current" mark. */
current: boolean
/** The sanitized timeline head, or null when the host folded nothing for this session yet. */
timeline: ContextTimeline | null
/**
* The TEAM's merged daily ledger (this session's own plus every descendant
* subagent's, day by day), or null when no member carries one. Subagent-origin
* sessions never list (the workspace browser's exclusion, see rowsOfSnapshot),
* so each family folds into exactly one row and nothing counts twice.
*/
/** The TEAM's merged daily ledger: this session's own plus every descendant subagent's. */
activity: ContextActivity | null
/** Every subtree member's sanitized head, self first, ALL levels — the parts split proportions each member's OWN composition. */
/** Every subtree member's sanitized head, self first; each part split proportions its own composition. */
family: (ContextTimeline | null)[]
/** The subtree's merged billed usage — the card stat, KPI band, and token sort's family figure (null: nothing billed anywhere). */
/** The subtree's merged billed usage; null when nothing was billed. */
familyCost: SessionCostUsage | null
}
/**
* The guarded standard-prop hook read behind both snapshot readers: the seat
* must be a function (absent = a harness without the service), and a throwing
* seat/snapshot reads as null. Called unconditionally at the top of the panel
* component (the seat is a real hook; the identity selector keeps the raw
* snapshot so the pure derivations can ride useMemo).
*/
/** The guarded standard-prop read behind both snapshot readers. Called
* unconditionally at the top of the panel: the seat is a hook, and the
* identity selector keeps the snapshot reference-stable for useMemo. */
function standardSnapshotOf(seat: unknown): unknown {
if (typeof seat !== 'function') return null
try {
@@ -71,28 +47,22 @@ function standardSnapshotOf(seat: unknown): unknown {
}
}
/** The `useSessions` snapshot (absent seat → the panel's unavailable note). */
export function sessionsSnapshotOf(props: { useSessions?: unknown }): unknown {
return standardSnapshotOf(props.useSessions)
}
/** The `useWorkspaces` snapshot — the session → workspace grouping join. */
export function workspacesSnapshotOf(props: { useWorkspaces?: unknown }): unknown {
return standardSnapshotOf(props.useWorkspaces)
}
/**
* Session id → workspace title, projected off the workspaces snapshot (the
* registry's own membership lists, not path guessing). Null when the
* snapshot is unusable — the breadcrumb then shows the project name alone.
* A session no workspace claims simply has no crumb group (the workspace
* browser's "Ungrouped" bucket is a container, not a name to print).
*/
/** Session id → workspace title, read off the registry's own membership lists
* rather than guessed from paths. A session no workspace claims has no crumb
* group: "Ungrouped" is a container, not a name to print. */
export function sessionGroupsOf(snapshot: unknown): Record<string, string> | null {
const state = asRecord(snapshot)
if (state === null || !Array.isArray(state.items)) return null
const groups: Record<string, string> = {}
// Widened honestly: a Record index read can miss at runtime.
// Widened: a Record index read can miss at runtime.
const claimed: Record<string, string | undefined> = groups
for (const item of state.items) {
const workspace = asRecord(item)
@@ -113,22 +83,16 @@ export function projectOf(cwd: string | undefined): string | undefined {
return title === '' ? undefined : title
}
/** The group-filter chip key of the ungrouped bucket (a title can never collide — the registry dedupes names). */
/** The group-filter chip key of the ungrouped bucket; a title can never collide (the registry dedupes names). */
export const UNGROUPED_KEY = '__ungrouped__'
/** One group chip: its filter key and the rows currently in it. */
export interface GroupCount {
key: string
count: number
}
/**
* The group chips' counts, in workspace-registry order with the ungrouped
* bucket last, computed over the ALREADY scoped rows (range/day/query
* applied) so the chips mirror the scope the user set. Groups with no row
* in scope drop out (a chip that can yield no card is noise); the panel
* keeps a vanished selection's chip alive itself.
*/
/** The group chips' counts, in registry order with the ungrouped bucket last,
* computed over the already scoped rows so each chip mirrors the user's scope; a group with no row in scope drops out. */
export function groupCountsOf(rows: readonly OverviewRow[], snapshot: unknown): GroupCount[] {
const state = asRecord(snapshot)
if (state === null || !Array.isArray(state.items)) return []
@@ -161,19 +125,13 @@ export function groupCountsOf(rows: readonly OverviewRow[], snapshot: unknown):
return groups
}
/** Whether one row belongs to the group-filter selection (the ungrouped bucket matches claimless rows). */
export function inGroup(row: OverviewRow, group: string, groups: Record<string, string> | null): boolean {
// Widened honestly: a Record index read can miss at runtime.
const byId: Record<string, string | undefined> = groups ?? {}
const title = byId[row.id]
return group === UNGROUPED_KEY ? title === undefined : title === group
}
/**
* The workspace snapshot's archived-session set, re-proved: an absent seat,
* a hostile shape, or a throwing accessor archives nothing — filtering fail
* open to the unfiltered list, never fail closed to an empty one.
*/
/** Fail open: an absent seat, a hostile shape, or a throwing accessor archives nothing, so filtering never empties the list. */
function archivedSetOf(workspaces: unknown): Set<string> {
try {
const value = asRecord(workspaces)?.archivedSessionIds
@@ -184,16 +142,9 @@ function archivedSetOf(workspaces: unknown): Set<string> {
}
}
/**
* Join the raw session-list snapshot into render-ready rows, or null when
* the snapshot is unusable (absent service, hostile root — the panel's
* unavailable note, distinct from a real empty list). Blank rows (a
* never-engaged session's placeholder) are not insight material and drop
* out; archived rows drop too (the raw list carries every session — the
* workspace browser hides its archive set, and the overview must not
* surface ghosts its sibling surface hides); every other row derives in
* isolation, so one throwing row costs just itself.
*/
/** Join the raw session-list snapshot into render-ready rows, or null when it
* is unusable (distinct from a real empty list). Blank placeholder and
* archived rows drop out; one throwing row costs just itself. */
export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): OverviewRow[] | null {
const state = asRecord(snapshot)
if (state === null) return null
@@ -209,10 +160,9 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie
const row = asRecord(byId[id])
if (row === null) continue
if (row.blank === true) continue
// Subagent-origin sessions are the workspace browser's own exclusion
// (its tree predicate): they surface under their parent's catalog, and
// Subagent-origin sessions are the workspace browser's own exclusion:
// a plain open() cannot address them (the host demands the durable
// parent address) — so the overview lists main-line sessions only.
// parent address), so the overview lists main-line sessions only.
if (row.origin === 'subagent') continue
const displayTitle = typeof row.displayTitle === 'string' && row.displayTitle !== '' ? row.displayTitle : undefined
const title = typeof row.title === 'string' && row.title !== '' ? row.title : undefined
@@ -234,16 +184,10 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie
// A hostile row (throwing accessor) drops whole; the list keeps working.
}
}
// The family fold: every listed row owns its WHOLE subagent subtree (all
// levels — the walk mirrors the Context tab's cost fold, agentTree
// subagentCostFoldOf: childrenOf off non-blank rows' parentId, BFS with a
// seen-set so a lineage cycle cannot loop). Each member's own sanitized
// timeline and ledger join the row's family; the row's own `activity` then
// becomes the team's merged ledger.
// Mirrors the Context tab's own cost fold (agentTree subagentCostFoldOf):
// childrenOf off non-blank rows' parentId, BFS with a seen-set against cycles.
const childrenOf = new Map<string, string[]>()
for (const id of Object.keys(byId)) {
// The row loop's own isolation, mirrored: a hostile (throwing) row drops
// out of the lineage walk the same way it dropped out of the list.
try {
const row = agentRowOf(byId[id])
if (row === null || row.blank || row.parentId === undefined) continue
@@ -274,17 +218,10 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie
return rows
}
/**
* Merge several sessions' daily ledgers into one family's: per day the tokens
* and requests sum and the pricing records merge (each member's own entry,
* same day — the fee prices off the same book downstream either way), and the
* skill tables merge name by name (loads sum, last-load instant takes the
* max). Null when no member carries a ledger, matching a lone session's
* day-less state.
*/
/** Merge several sessions' daily ledgers into one family's: per day the tokens
* and requests sum, the pricing records merge, and the skill tables merge name by name (loads sum, last-load takes the max). */
function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | null {
const days: ContextActivity['days'] = {}
// Widened honestly: a Record index read can miss at runtime.
const byKey: Record<string, ContextActivity['days'][string] | undefined> = days
let any = false
for (const activity of members) {
@@ -314,9 +251,7 @@ function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | n
return any ? { days } : null
}
/** Two days' skill tables merged name by name: loads sum, the last-load instant takes the max. */
function mergeSkillTallies(a: ActivityDay['skills'], b: NonNullable<ActivityDay['skills']>): NonNullable<ActivityDay['skills']> {
// Widened honestly: a Record index read can miss at runtime.
const out: Record<string, { n: number; last: number } | undefined> = { ...a }
for (const name of Object.keys(b)) {
const tally = b[name]
@@ -326,49 +261,33 @@ function mergeSkillTallies(a: ActivityDay['skills'], b: NonNullable<ActivityDay[
return out as NonNullable<ActivityDay['skills']>
}
// ---- range / filter / sort -------------------------------------------------
/** The range group's preset keys — each a rolling window anchored on `now`. */
/** Preset range keys, each a rolling window anchored on `now`. */
export type OverviewRange = 'today' | '7d' | '30d' | 'all'
/**
* A resolved scope window: inclusive epoch-ms bounds, each null when that end
* is open. The presets only ever pin the start ("last 7 days" is a floor, not
* a closed interval); the calendar picker's own range closes both ends.
*/
/** A resolved scope window: inclusive epoch-ms bounds, each null when that end
* is open. The presets pin only the start; the calendar picker closes both. */
export interface RangeWindow {
start: number | null
end: number | null
}
/** The open window — the "all" preset. Frozen: it is handed straight to every scope reader. */
/** The open window (the "all" preset), frozen because it is handed to every scope reader. */
export const OPEN_WINDOW: RangeWindow = Object.freeze({ start: null, end: null })
/**
* A calendar-picked range: two inclusive local day keys, ALWAYS ordered
* (`from <= to`). The picker sorts the pair on its second click (a backwards
* drag just swaps the ends), and {@link dayRangeWindow} sorts it again at the
* seam, so no reader downstream — window, ledger predicate, or the chip that
* names it — can ever see a reversed range, and an inverted pair can never
* filter the page down to nothing.
*/
/** A calendar-picked range: two inclusive local day keys, always ordered. Both
* the picker and {@link dayRangeWindow} sort the pair, so no reader can see a
* reversed range and an inverted pair cannot filter the page to nothing. */
export interface DayRange {
from: string
to: string
}
/** Two picked ends in calendar order — a backwards pair swaps rather than inverting. */
export function orderedDayRange(a: string, b: string): DayRange {
return a <= b ? { from: a, to: b } : { from: b, to: a }
}
/**
* The preset's window (epoch ms). "Today" opens on the local calendar day's
* midnight — date-field arithmetic (the harness's own `setHours(0, 0, 0, 0)`
* idiom) keeps a DST-short or long day exact, where an epoch-ms subtract
* would drift into the neighbouring day. "All" is the open window; every
* other preset is a floor with an open end, exactly as before the calendar
* picker arrived.
/** The preset's window (epoch ms). "Today" opens on the local midnight —
* date-field arithmetic keeps a DST-short or long day exact, where an epoch-ms subtract would drift into the neighbouring day.
*/
export function rangeWindowOf(range: OverviewRange, now: number): RangeWindow {
if (range === 'all') return OPEN_WINDOW
@@ -376,7 +295,6 @@ export function rangeWindowOf(range: OverviewRange, now: number): RangeWindow {
return { start, end: null }
}
/** The preset's start instant, the shared floor of {@link rangeWindowOf}. */
function rangeStartOf(range: Exclude<OverviewRange, 'all'>, now: number): number {
if (range === 'today') {
const midnight = new Date(now)
@@ -386,13 +304,8 @@ function rangeStartOf(range: Exclude<OverviewRange, 'all'>, now: number): number
return now - (range === '7d' ? 7 : 30) * 86_400_000
}
/**
* A picked range's window: the start day's midnight through the end day's
* last millisecond, so both picked days count whole. The pair is ordered on
* the way in, which is what keeps an end picked before its start from reading
* as an empty window. Null when a key is malformed — the caller then keeps the
* preset scope rather than filtering on a window it cannot prove.
*/
/** A picked range's window: the start day's midnight through the end day's last
* millisecond, so both picked days count whole. Null when a key is malformed — the caller then keeps the preset scope. */
export function dayRangeWindow(range: DayRange): RangeWindow | null {
const ordered = orderedDayRange(range.from, range.to)
const start = startOfDayKey(ordered.from)
@@ -403,28 +316,19 @@ export function dayRangeWindow(range: DayRange): RangeWindow | null {
export type OverviewSort = 'recent' | 'tokens' | 'context'
/**
* The team card's cumulative billed tokens (the subtree's merged cost
* buckets' sum), or null when nothing was billed anywhere — the sort and the
* card stat share this one figure.
*/
/** The team card's cumulative billed tokens, or null when nothing was billed; the sort and the card stat share this figure. */
export function billedOf(row: OverviewRow): number | null {
const totals = usageTotalsOf(row.familyCost)
return totals?.total ?? null
}
/** The session's turn tally: the split head's precomputed count, else the retained records' count. */
export function turnsOf(timeline: ContextTimeline | null): number {
if (timeline === null) return 0
return timeline.counts?.turns ?? timeline.requests.length
}
/**
* The session's first active day (the ledger's earliest key) — the card's
* creation-date line. The harness's client-facing list rows carry no
* per-session creation time, so the first billed day stands in; undefined
* when the ledger is absent (an older host, or no model requests yet).
*/
/** The session's first active day (the ledger's earliest key) — the card's
* creation-date line: the list rows carry no creation time, so the first billed day stands in. */
export function createdDayOf(activity: ContextActivity | null): string | undefined {
const days = activity?.days
if (days === undefined) return undefined
@@ -435,13 +339,6 @@ export function createdDayOf(activity: ContextActivity | null): string | undefin
return first
}
/**
* The panel's row pipeline: the scope window (by last-activity), then the
* heatmap's picked day (sessions contributing to that day's merged ledger),
* then the search box (title, directory, or last-message substring). Each
* stage keeps the rows it cannot prove out of the result — never an
* exception.
*/
export function filterRows(
rows: readonly OverviewRow[],
opts: { scope: RangeWindow; day: string | null; query: string },
@@ -465,7 +362,6 @@ export function filterRows(
})
}
/** Order the filtered rows; the input array is never mutated. */
export function sortRows(rows: readonly OverviewRow[], sort: OverviewSort): OverviewRow[] {
const copy = [...rows]
if (sort === 'tokens') copy.sort((a, b) => (billedOf(b) ?? -1) - (billedOf(a) ?? -1))
@@ -477,40 +373,25 @@ export function sortRows(rows: readonly OverviewRow[], sort: OverviewSort): Over
/** The session grid renders this many cards per page. */
export const OVERVIEW_PAGE_SIZE = 12
/**
* The paged window over the sorted rows: the requested page clamped into
* the live range, so a list that shrank between renders (a refresh, a
* narrowed filter) keeps the view valid instead of showing a blank page.
*/
/** The requested page clamped into the live range, so a list that shrank between renders keeps the view valid. */
export function pageOf<T>(rows: readonly T[], page: number): { items: T[]; index: number; count: number } {
const count = Math.max(1, Math.ceil(rows.length / OVERVIEW_PAGE_SIZE))
const index = Math.min(Math.max(0, page), count - 1)
return { items: rows.slice(index * OVERVIEW_PAGE_SIZE, (index + 1) * OVERVIEW_PAGE_SIZE), index, count }
}
// ---- aggregations ----------------------------------------------------------
/** One skill's loads across the scoped rows: its tally, its loader count, and its last load instant. */
export interface SkillLoadStat {
name: string
loads: number
/** How many session rows (agent families — the same granularity the list filter reads) loaded it in scope. */
/** How many session rows (agent families) loaded it in scope. */
sessions: number
/** The last load's instant (epoch ms) — the row's relative-time label. */
last: number
}
/** The skill card's row orderings (the title's segmented toggle); omission reads as 'loads'. */
export type SkillSort = 'loads' | 'recent'
/**
* The scope's ledger-day predicate: the heatmap's pinned day admits exactly
* that day, else the scope window admits its own days whole (the ledger is
* day-grained: "today" starts at this very day's key and the 7d/30d windows
* admit their start day whole, the same resolution the heatmap reads at; a
* picked range closes the top at its end day's key); an open window admits
* every day.
*/
/** The scope's ledger-day predicate: the heatmap's pinned day admits exactly
* that day, else the scope window admits its days whole. */
function skillDayPredicate(opts: { scope: RangeWindow; day: string | null }): (key: string) => boolean {
if (opts.day !== null) return key => key === opts.day
const { start, end } = opts.scope
@@ -519,15 +400,9 @@ function skillDayPredicate(opts: { scope: RangeWindow; day: string | null }): (k
return key => (floor === null || key >= floor) && (ceiling === null || key <= ceiling)
}
/**
* The Insights page's skill card data: every scoped row's family ledger (the
* activity merge already folds the subtree) folded name by name over the
* ledger days the scope admits. Each name tallies its loads, counts its
* loader rows once per row (a row loading it on two days is one loader), and
* keeps the freshest load instant. The sort ranks by the picked key, the
* remaining keys tiebreak volume-first then recent-first, and the name is the
* final tiebreak so every ordering is stable.
*/
/** The Insights page's skill card data: every scoped row's family ledger folded
* name by name over the admitted days — loads per name, one loader count per
* row, the freshest load instant. Ties break on the name. */
export function skillLoadsOf(
rows: readonly OverviewRow[],
opts: { scope: RangeWindow; day: string | null; sort?: SkillSort },
@@ -537,8 +412,7 @@ export function skillLoadsOf(
for (const row of rows) {
const days = row.activity?.days
if (days === undefined) continue
// A row counts once per name toward the loader tally, however many of its
// admitted days carry the name.
// A row counts once per name toward the loader tally, however many of its admitted days carry the name.
const rowLoaded = new Set<string>()
for (const key of Object.keys(days)) {
if (!admit(key)) continue
@@ -566,10 +440,8 @@ export function skillLoadsOf(
}
}
/**
* Whether the row's family ledger loaded the named skill within the scope —
* the session-list filter behind the skill card's click-to-drill-down.
*/
/** Whether the row's family ledger loaded the named skill within the scope —
* the drill-down filter behind the skill card's click. */
export function rowLoadedSkill(
row: OverviewRow,
name: string,
@@ -584,21 +456,16 @@ export function rowLoadedSkill(
return false
}
/** The merged billed-bucket totals behind the KPI band and the cost estimate. */
export interface UsageTotals {
input: number
cacheRead: number
cacheWrite: number
output: number
/** input + cacheRead + cacheWrite + output — the whole billed volume. */
total: number
}
/**
* Sum one cost usage's buckets (already sanitized per bucket by the
* timeline boundary). Null when no side carried a bucket record, so the
* caller's cells keep their dash instead of a fabricated zero.
*/
/** Sum one cost usage's buckets (already sanitized per bucket at the timeline
* boundary). Null when no side carried a bucket record, so the caller's cells keep their dash rather than a fabricated zero. */
export function usageTotalsOf(usage: SessionCostUsage | null | undefined): UsageTotals | null {
if (usage === null || usage === undefined) return null
const totals: UsageTotals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0 }
@@ -623,15 +490,9 @@ export function usageTotalsOf(usage: SessionCostUsage | null | undefined): Usage
return totals
}
/**
* The range's summed timing totals — the aggregate Timing Stats card's
* source, folded from the rows' host-folded whole-session totals exactly as
* the KPI band folds the scalar figures. Null when no row carries timing.
* The additive-optional fields (the decode split, the throughput seat) sum
* their carriers only: a row cached before a field existed contributes
* nothing to it, and a field no row carries stays absent — the card keeps
* its un-split shape / no-chip fallback instead of materialized zeros.
*/
/** The range's summed timing totals, folded from the rows' host-folded
* whole-session totals exactly as the KPI band folds the scalar figures. The
* additive-optional fields sum their carriers only, so a field no row carries stays absent instead of materializing zeros. */
export function timingSumOf(rows: readonly OverviewRow[]): TimingTotals | null {
let out: TimingTotals | null = null
for (const row of rows) {
@@ -657,7 +518,6 @@ export function timingSumOf(rows: readonly OverviewRow[]): TimingTotals | null {
addOptionalOf(out, t, 'speedMs')
for (const name of Object.keys(t.tools)) {
const tool = t.tools[name]
// Widened honestly: a Record index read can miss at runtime.
const outTools: Record<string, ToolTimingTotals | undefined> = out.tools
const prev = outTools[name]
out.tools[name] = prev === undefined ? { ...tool } : { calls: prev.calls + tool.calls, ms: prev.ms + tool.ms }
@@ -666,7 +526,7 @@ export function timingSumOf(rows: readonly OverviewRow[]): TimingTotals | null {
return out
}
/** One additive-optional timing field's in-place merge: absent on both sides stays absent (never a materialized undefined). */
/** One additive-optional timing field's in-place merge; absent on both sides stays absent, never a materialized undefined. */
function addOptionalOf(target: TimingTotals, source: TimingTotals, key: OptionalTimingKey): void {
const a = target[key]
const b = source[key]
@@ -676,27 +536,16 @@ function addOptionalOf(target: TimingTotals, source: TimingTotals, key: Optional
type OptionalTimingKey = 'reasoningMs' | 'reasoningBlocks' | 'textMs' | 'textBlocks'
| 'toolArgMs' | 'toolArgBlocks' | 'speedTokens' | 'speedMs'
/**
* One aggregate slice of the Token Stats card: a composition category (or
* `output`) with its summed billed estimate.
*/
export interface TokenPartTotal {
key: string
color: string
value: number
}
/**
* The range's billed tokens split by WHAT they are — the Context tab's Token
* card categorization, folded across the rows' FAMILIES: every subtree
* member's `billedParts` estimate (its OWN composition ratios proportioning
* its own provider-reported prompt total, output exact) sums by category,
* and every member's parts total its own billed figure, so the aggregate's
* total stays the exact merged billed volume while the split inherits the
* per-session card's `≈` estimate convention. Null when nothing billed
* anywhere. A non-finite part estimate (a hostile fast-path composition)
* drops whole instead of poisoning the sums.
*/
/** The range's billed tokens split by category, folded across the rows'
* families: each member's `billedParts` sum by category while its parts total
* its own billed figure, so the aggregate total stays the exact merged billed
* volume. Null when nothing billed; a non-finite part drops whole. */
export function tokenPartsOf(rows: readonly OverviewRow[]): { parts: TokenPartTotal[]; total: number } | null {
const sums = new Map<string, TokenPartTotal>()
let total = 0
@@ -716,7 +565,6 @@ export function tokenPartsOf(rows: readonly OverviewRow[]): { parts: TokenPartTo
for (const part of billedParts(member.current, null, usage)) {
if (!Number.isFinite(part.value) || part.value <= 0) continue
total += part.value
// Widened honestly: a Map get can miss at runtime.
const sumsGet: Map<string, TokenPartTotal | undefined> = sums
const prev = sumsGet.get(part.key)
sums.set(part.key, prev === undefined
@@ -729,35 +577,20 @@ export function tokenPartsOf(rows: readonly OverviewRow[]): { parts: TokenPartTo
return { parts: [...sums.values()], total }
}
/** The KPI band's figures, priced from the models.dev book (null cost until the book lands). */
export interface OverviewKpis {
/** Sessions in the current range filter. */
sessions: number
/** Sessions in the whole list (the range cell's "of N total" sub-line). */
listed: number
/** Cumulative billed tokens across the range's sessions. */
tokens: number
/** Their turn tally. */
turns: number
/** Estimated spend in the display currency (null: nothing priced). */
cost: number | null
/** Sessions whose spend the book could price (the cost cell's sub-line). */
costSessions: number
/** Cache-hit share of billed input, truncated (null: nothing billed). */
cacheHit: string | null
/** Sessions whose usage feeds the cache-hit rate (the cache-hit cell's sub-line). */
usageSessions: number
/** Their completed tool calls. */
toolCalls: number
/** Their summed tool-run time (the tool-calls cell's sub-line). */
toolsMs: number
/** Their completed model calls. */
calls: number
/** Their summed wall time (the sessions' active time). */
wallMs: number
/** The range's billed tokens split by composition category (null: nothing billed) — the Token Stats card's slices. */
tokenParts: { parts: TokenPartTotal[]; total: number } | null
/** The range's summed timing totals (null: no timed session) — the Timing Stats card's source. */
timing: TimingTotals | null
}
@@ -767,8 +600,7 @@ export function kpisOf(
book: ModelBook | null | undefined,
currency: CostCurrency,
): OverviewKpis {
// The KPI band's billed volume and spend ride the same family scope as the
// cards and the chart: each row's subtree-merged usage.
// Billed volume and spend ride the same family scope as the cards and chart.
const usage = mergeCostUsage(...rows.map(row => row.familyCost))
const totals = usageTotalsOf(usage)
let turns = 0
@@ -779,9 +611,7 @@ export function kpisOf(
let costSessions = 0
let usageSessions = 0
for (const row of rows) {
// Each qualifying sub-line counts the sessions its own figure covers: a
// session with usage but no book rates feeds the cache-hit rate while
// pricing to nothing.
// Each sub-line counts the sessions its own figure covers: a session with usage but no book rates feeds the cache-hit rate.
if (estimateSessionCost(row.familyCost, book, currency) !== null) costSessions++
if (usageTotalsOf(row.familyCost) !== null) usageSessions++
turns += turnsOf(row.timeline)
@@ -809,35 +639,22 @@ export function kpisOf(
}
}
/** One merged day of the daily ledgers: billed tokens, model requests, the sessions active that day, and the day's priced fee. */
export interface DayTotals {
tokens: number
requests: number
sessions: number
/**
* The day's estimated spend in the display currency (null: nothing priced —
* no pricing record folded, no book yet, or no model the book prices).
*/
/** The day's estimated spend; null when nothing priced — no pricing record, no book, or no priced model. */
cost: number | null
}
/**
* Merge every row's daily ledger into one — the heatmap's and the usage
* chart's data (each row's ledger is already its family's merge, see
* rowsOfSnapshot). A row counts toward a day only when its family entry
* carries activity, mirroring the day filter's predicate, so the cell's
* tooltip previews the click; a zeroed entry is skipped whole. Each day's
* pricing records merge into one SessionCostUsage and price off the SAME
* book/estimator the KPI band's cost cell rides (null fee on anything
* unpriced). The merged record stays small even over long histories.
*/
/** Merge every row's daily ledger into one — the heatmap's and usage chart's
* data. A day counts only when the family entry carries activity, mirroring
* the day filter, and prices off the same book and estimator as the KPI band. */
export function aggregateDays(
rows: readonly OverviewRow[],
book: ModelBook | null | undefined,
currency: CostCurrency,
): Record<string, DayTotals> {
// The walk merges into a richer record (each day's fee raw material rides
// along), then every merged day prices once — no second lookup pass.
const merged: Record<string, { tokens: number; requests: number; sessions: number; fees: SessionCostUsage[] }> = {}
const byKey: Record<string, { tokens: number; requests: number; sessions: number; fees: SessionCostUsage[] } | undefined> = merged
for (const row of rows) {
@@ -863,8 +680,7 @@ export function aggregateDays(
}
const days: Record<string, DayTotals> = {}
for (const [key, day] of Object.entries(merged)) {
// No fee records merge to null (nothing priced); records that merge but
// match no book price null the same way.
// No fee records, or records no book prices, both merge to a null fee.
days[key] = {
tokens: day.tokens,
requests: day.requests,
@@ -875,14 +691,8 @@ export function aggregateDays(
return days
}
// ---- presentation helpers --------------------------------------------------
/**
* Re-pull the session-list baseline so host-side projection backfills
* (host/backfill.ts) reach a long-connected browser. Fire-and-forget: the
* verb is re-proved and every failure swallows — the panel renders off the
* rows it already has either way.
*/
/** Re-pull the session-list baseline so host-side projection backfills
* (host/backfill.ts) reach a long-connected browser; failures swallow and the panel renders off the rows it already has. */
export function refreshSessions(ctx: ClientCtx): void {
try {
const sessions = ctx.get('sessions') as SessionsFace | undefined
@@ -892,30 +702,21 @@ export function refreshSessions(ctx: ClientCtx): void {
} catch { /* hostile service — no refresh */ }
}
// The plugin's warm-up trigger route (host/backfill.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 plugin's warm-up trigger route (host/backfill.ts), re-declared here: the
// client bundle inlines every import and the host module must never reach it.
const BACKFILL_ROUTE = '/api/dsh-context/backfill'
/**
* Summon the host's projection warm-up (host/backfill.ts): the dashboard is
/** Summon the host's projection warm-up (host/backfill.ts): this page is
* the rows' only reader, so the host defers the corpus-wide cold reads until
* this surface first opens (one pass per host process — later opens no-op
* server-side, and the host answers at once). Fire-and-forget: an older host
* without the route, or a transport hiccup, just leaves the panel on the
* rows it already has.
*/
* this surface first opens (one pass per host process). */
export function requestActivityBackfill(): void {
try {
void fetch(BACKFILL_ROUTE, { method: 'POST' }).catch(() => { /* the rows arrive on a later open */ })
} catch { /* hostile transport — the panel keeps its rows */ }
}
/**
* The row's relative-activity label ("3m ago"), unit-stepped: under a
* minute reads "just now", then minutes, hours, days. A future or invalid
* timestamp reads as "just now" (clock skew is not an error worth a dash).
*/
/** The row's relative-activity label ("3m ago"), unit-stepped. A future or
* invalid timestamp reads as "just now" (clock skew is not an error worth a dash). */
export function relativeTime(t: (key: string, params?: Record<string, string | number>) => string, updatedAt: number, now: number): string {
const diff = now - updatedAt
if (!Number.isFinite(diff) || diff < 60_000) return t('ov.time.now')
+2 -19
View File
@@ -1,31 +1,14 @@
/**
* Placement gating for the Context view: the per-user `defaultPlacement`
* preference (the settings namespace's `defaultPlacement` field) picks which
* registrations carry it — the conversation tab, the right Sidebar panel, or
* both.
*
* Each mount is a factory returning its own disposer, so a preference flip
* takes down only what the new value drops and mounts only what it adds; the
* underlying `slots.inject` / deferred-inject disposers are the harness's own
* dynamic-mount idiom. A mount landing no disposer (a harness face that
* returns nothing) simply has nothing to unwind.
*
* @module dsh-context/client/placement
*/
/** Placement gating for the Context view: the per-user `defaultPlacement` preference picks which
* registrations carry it — the conversation tab, the right Sidebar panel, or both. */
import type { ContextSettings } from './settings'
import type { DefaultPlacement } from '../shared/types'
/** One registration factory per placement; each returns its mount's disposer. */
export interface PlacementMounts {
tab(): unknown
sidebar(): unknown
}
/**
* Mount per the current preference and keep the registrations glued to it
* until the returned disposer runs.
*/
export function watchPlacement(settings: ContextSettings, mounts: PlacementMounts): () => void {
let tab: (() => void) | undefined
let sidebar: (() => void) | undefined
+5 -19
View File
@@ -1,35 +1,21 @@
/**
* Named imports from the ui-primitives platform module, resolved defensively.
*
* Every supported line ships the `Icon*OutlineRegular` / `Icon*OutlineMedium`
* vocabulary, but the import arrives through the shell's module table at
* runtime: a renamed or absent export reads as `undefined`, which renders as
* React error #130 inside the Context tab. Each icon here resolves by name
* through a bounded read; a missing or non-component export (a future rename,
* a partial module mock, a hostile namespace) degrades to a render-nothing
* fallback — a missing glyph, never a crashed tab.
*/
/** Named imports from the ui-primitives platform module, resolved defensively: an absent or renamed
* export would render as React error #130 inside the Context tab, so each icon resolves by name
* through a bounded read and degrades to a render-nothing fallback. */
import type { ReactElement } from 'react'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
/** One platform icon component, as the call sites render it. */
export type IconComponent = (props: IconProps) => ReactElement | null
/** The platform module's export surface, read by name. */
const ns = primitives as unknown as Record<string, unknown>
/**
* Resolve one icon by its export name; absent or not a component (or a
* namespace whose property READ throws — partial mocks, hostile modules)
* renders nothing. `source` overrides the platform namespace for tests.
*/
/** Resolve one icon by export name; a missing, non-component, or hostile-namespace read renders nothing. */
export function resolveIcon(name: string, source: Record<string, unknown> = ns): IconComponent {
try {
const found = source[name]
if (typeof found === 'function') return found as IconComponent
} catch { /* a hostile namespace: the fallback below */ }
} catch { /* the fallback below */ }
return () => null
}
+6 -21
View File
@@ -1,33 +1,18 @@
/**
* Scroll an element's nearest scrollable ancestor so the element tops that
* scrollport. The jump's landing reveal needs this host-agnostic: the
* conversation tab scrolls the shared `[data-conversation-scroll]` container
* while the right Sidebar's scroller differs per shell layout (the tab root
* itself on the supported lines), so the walk takes whichever ancestor
* actually scrolls — overflowing style alone is not enough, a grow-with-
* content box reports `auto` yet never moves — and the rect delta lands the
* anchor flush regardless of the container's current position.
* `document.body` ends the walk: the page itself never scrolls. False —
* never a throw — when no ancestor scrolls (the layout already shows the
* element) or the chain turns hostile.
*
* @module dsh-context/client/revealScroll
*/
/** Scroll an element's nearest operative scrollable ancestor so the element tops that scrollport.
* Host-agnostic: the conversation tab scrolls a shared container, while the right Sidebar's scroller
* differs per shell layout. False — never a throw — when no ancestor scrolls or the chain turns hostile. */
export function revealInScrollParent(anchor: Element): boolean {
try {
for (let el = anchor.parentElement; el !== null && el !== document.body; el = el.parentElement) {
const oy = window.getComputedStyle(el).overflowY
// The OPERATIVE scroller, not just an overflow-styled one: a box that
// grows with its content (the Context tab's own `.lc-root` on layouts
// where an OUTER container scrolls) reports auto yet never moves, and
// taking it here would scroll a no-op while the real scroller waits
// further up the chain.
// The OPERATIVE scroller, not just an overflow-styled one: a box that grows with its content
// (the Context tab's own `.lc-root` where an outer container scrolls) reports auto yet never moves.
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) {
el.scrollTop += anchor.getBoundingClientRect().top - el.getBoundingClientRect().top
return true
}
}
} catch { /* hostile chain: the reveal degrades to nothing */ }
} catch { /* the reveal degrades to nothing */ }
return false
}
+94 -390
View File
@@ -1,17 +1,9 @@
/**
* Client-side harness boundary — the exact API surface this plugin consumes
* from the harness web half, plus the sanitizers that re-prove every
* delivered value at that boundary.
* Client-side harness boundary: the harness web-half surface this plugin
* consumes, plus the sanitizers that re-prove every delivered value.
*
* The plugin bundles its own code but relies on the reader to deliver the
* framework standard kit to slot components (`sessionId`, `useChat`,
* `useProjection`, `t` …); only the small faces below are referenced across
* modules. The INTERFACES are type-only (the runtime services come from the
* user's harness); the `*Of` functions are the runtime guards the
* no-white-screen guarantee rides on. Data arrives as pushed session
* projections (`useProjection` standard seat); the ONE exception is the
* gateway history page read (`remote.session.page`, see historyPage.ts) that
* fetches a request-header epoch's content on demand.
* The interfaces are type-only — the runtime services come from the user's
* harness, so every face is re-proved at its call site.
*/
import type { Context } from '@deepseek-ai/cordis'
@@ -27,15 +19,12 @@ export interface LocaleService {
export interface SlotRegistration {
name: string
/** List slots dispatch on id + order. */
id?: string
order?: number
/** Keyed slots (e.g. plugins.bundle.config) dispatch on the entry key. */
key?: string
/** optional dictionary namespace; the framework then synthesizes the `t` seat. */
/** Dictionary namespace; the framework synthesizes the `t` seat from it. */
locale?: string
label?: () => string
/** optional business face factory; a `hooks` compartment binds selector hooks onto props. */
inject?: (sessionId?: string) => unknown
}
@@ -47,100 +36,54 @@ export interface SlotsService {
): unknown
}
/**
* One guide-page capsule a right-Sidebar tab type contributes (dsh
* 0.1.7-rc.2+): the glyph, the title, and the optional one-line description,
* exactly the fields `SidebarRightGuideEntry` carries.
*/
/** The harness `SidebarRightGuideEntry` subset a right-Sidebar tab type contributes. */
export interface SidebarGuideEntryLike {
/** Ascending position among every registered type's entries. */
order: number
title: () => string
/**
* One line under the title on what picking the capsule opens; the guide
* renders it only while it lists few enough entries. Optional, so a line
* whose guide body ignores it simply goes without.
*/
description?: () => string
icon?: ComponentType<{ size?: number }>
}
/** One right-Sidebar tab type registration (the fields this plugin uses). */
export interface SidebarTabDefinitionLike {
/** This implementation's identity, unique across every registration. */
id: string
/** What `openTab` names; also the page address's discriminator. */
kind: string
/** The tab chip's text, captured when the tab opens. */
title: () => string
/** Entry capsules for the guide page (omitted = the type stays off it). */
guide?: readonly SidebarGuideEntryLike[]
}
/**
* The right Sidebar's tab-type registry (`ctx.sidebarRightTabs`), as far as
* this plugin consumes it. OPTIONAL by contract: a deployment may strip the
* service (and a below-baseline host, gated onto the fallback units, serves
* none of this half's seats), so the plugin reaches it through a deferred
* inject and stays fully functional (no pending fiber, no throw) without it.
*/
/** The right Sidebar's tab-type registry (`ctx.sidebarRightTabs`), reached
* through a deferred inject: a deployment without it composes fully (no pending fiber, no throw). */
export interface SidebarTabsFace {
register(definition: SidebarTabDefinitionLike): () => void
}
/**
* The right Sidebar's navigation face (`ctx.sidebarRight`), as far as this
* plugin consumes it: `openResource` claims a `dsh-resource://file/…` address
* through the shipped preview type and reveals the column in the same step.
* The same optional generation as {@link SidebarTabsFace}; the caller re-proves
* the verb at runtime and falls back to the system opener when it is absent or
* refuses the address.
*/
/** The right Sidebar's navigation face (`ctx.sidebarRight`): `openResource`
* claims a `dsh-resource://file/…` address through the shipped preview type.
* The caller falls back to the system opener when it is absent or refuses. */
export interface SidebarResourceFace {
openResource(address: string): void
}
/**
* The conversation node, as far as the Context browser consumes it: the
* framework's finalized chat nodes carry the source surface event's `seq`
* plus the full content — the browser joins its surface nodes on `seq` to
* show actual content without carrying it through the projection.
*/
/** A finalized chat node: it carries the source surface event's `seq`, so the
* browser joins its nodes on `seq` instead of projecting their content. */
export interface ConversationNodeLike {
kind: string
seq: number
/**
* The durable message id (assistant nodes on the harness chat nodes; absent
* on synthetic/interrupted replies) — the key the chat's assistant-action
* seat addresses a finalized reply by.
*/
/** Durable message id; absent on synthetic/interrupted replies. */
messageId?: unknown
content?: readonly unknown[]
blocks?: readonly unknown[]
call?: { name: string; argsRaw: string } | null
isError?: boolean
summary?: string | null
/**
* Nested Code-Mode call tree (dsh's recursive ToolCallBlock[]) on a tool
* result whose call ran sub-dispatches — a PTC `run_code` program. Consumed
* structurally only (fileActivity): every block is re-proved at runtime and
* malformed shapes drop out instead of throwing.
*/
/** Nested dsh ToolCallBlock[] tree of a PTC `run_code` dispatch; re-proved structurally in fileActivity. */
subCalls?: readonly unknown[]
/** The tool result's bounded presentation meta (a search's matched files), as the join delivers it. */
/** Tool-result presentation meta (a search's matched files), as the join delivers it. */
meta?: unknown
}
/**
* A durable image attachment reference, as far as this plugin consumes it
* (dsh's `ImageAttachmentRef`, minimally re-typed so the plugin stays free
* of an attachment-package dependency). The durable log holds only this ref
* — never inline bytes. On every supported line the width/height/bytes
* describe the NORMALIZED raster under a deployment-resolvable policy
* (defaults: total-pixel budget 2048×2048, long edge capped at 8192px);
* `originalDimensions` carries the pre-normalization size when normalization
* reduced the image.
*/
/** dsh's `ImageAttachmentRef`, re-typed to stay free of an attachment-package
* dependency. The durable log holds only this ref, never inline bytes. */
export interface ImageRefLike {
attachmentId: string
name?: string
@@ -150,25 +93,13 @@ export interface ImageRefLike {
originalDimensions?: { width: number; height: number }
}
/** Loads a session-authorized display URL for one durable image reference. */
export type ImageLoader = (attachment: ImageRefLike) => Promise<string>
/**
* The harness conversation client service, minimally typed for image
* resolution — the same call the chat view's own message images ride on
* (`ctx.uiConversation.imageUrl`).
*/
export interface UiConversationFace {
imageUrl?(sessionId: string, attachment: ImageRefLike): Promise<string>
}
/**
* A session-authorized durable-image loader over the harness conversation
* face (`uiConversation.imageUrl`), or undefined when the service is not
* composed — the caller degrades to metadata-only cards. Hostile snapshots
* and throwing service reads are caught: this helper can never take a
* render down.
*/
/** Session-authorized image loader; undefined off an absent/hostile service (metadata-only cards). */
export function imageLoaderOf(
ctx: ClientCtx,
sessionId: string | undefined,
@@ -184,21 +115,10 @@ export function imageLoaderOf(
return undefined
}
/**
* The `useChat` standard seat (the finalized chat nodes live on a per-view
* `ChatSnapshot` whose `legacy` slice keeps the plain `ConversationNode[]`).
* Minimally typed: the selector receives the harness snapshot (untrusted —
* re-proved outside), and the slice it returns must be reference-stable so
* the framework's selector-hook equality can gate re-renders.
*/
/** The `useChat` seat. The selector must return a reference-stable slice: a fresh object re-renders the view on every event. */
export type UseChatLike = <T>(selector: (snapshot: unknown) => T) => T
/**
* The conversation-window nodes this plugin joins on, from the `useChat`
* seat (`ChatSnapshot.legacy.nodes`). Returns undefined when the seat does
* not deliver a real array (absent seat, foreign harness, hostile snapshot)
* — callers render without the join, never an error.
*/
/** `ChatSnapshot.legacy.nodes`, or undefined when the seat delivers no real array; callers render without the join. */
export function conversationNodesOf(props: {
useChat?: UseChatLike
}): readonly ConversationNodeLike[] | undefined {
@@ -214,36 +134,21 @@ export function conversationNodesOf(props: {
return undefined
}
/**
* The framework standard kit of a session-scope slot component, as far as
* this plugin consumes it: the resolve session id and the key-addressed
* projection reader that delivers the `contextTimeline` value (undefined =
* the host unit is absent or no value has arrived yet).
*/
/** The framework standard kit of a session-scope slot component. */
export interface SessionStandardProps {
sessionId?: string
useProjection?: (key: string) => unknown
/** The chat-view snapshot seat (see {@link UseChatLike}). */
/** The chat-view snapshot seat. */
useChat?: UseChatLike
}
/**
* The Context view's props: the framework standard kit plus this plugin's own
* host marker. The right Sidebar's panel registration sets `host`, so the SAME
* view drops the head cards a narrow column cannot serve.
*/
export interface ContextViewProps extends SessionStandardProps {
/** Set only by the right-Sidebar registration; absent in the conversation tab and the /context modal. */
/** Set only by the right-Sidebar registration, which drops the head cards a narrow column cannot serve. */
host?: 'sidebar'
}
/**
* Read one projection key through the standard seat, narrowed at the
* boundary: null when the seat is absent (a harness without the projection
* pipeline) or the delivered value fails the narrow. The seat is a real
* hook — call this unconditionally at the top of the component, one call
* per key, in a stable order.
*/
/** Read one projection key through the standard seat, narrowed at the
* boundary. The seat is a hook: call it unconditionally at the top of the component, one call per key, in a stable order. */
export function projectionOf<T>(props: SessionStandardProps, key: string, narrow: (value: unknown) => T | null): T | null {
if (typeof props.useProjection !== 'function') return null
return narrow(props.useProjection(key))
@@ -254,38 +159,23 @@ export type ClientCtx = Context & {
slots: SlotsService
}
/**
* Narrow an unknown projection value to a string-keyed record, or null when
* it is not one. The boundary type is Record<string, unknown> on purpose:
* every field read below must re-prove itself (the no-white-screen
* guarantee), so no field may borrow the wire type before its check.
* Shared by every sanitizer here and by the agent-tree derivation
* (agentTree.ts) — the ONE record guard for the whole client half.
*/
/** The boundary type is `Record<string, unknown>` on purpose, so every field read re-proves itself before borrowing a wire type. */
export function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || value === undefined || typeof value !== 'object') return null
return value as Record<string, unknown>
}
/**
* Safe finite-number read: a missing/non-numeric/NaN field degrades to 0
* instead of leaking into the UI as NaN percentages or broken arithmetic.
*/
/** A missing, non-numeric, or NaN field degrades to 0. */
export function numOf(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
/** Shared per-item collection guard: drop non-object entries, keep the rest. */
export function objectsOf<T>(value: unknown): T[] {
if (!Array.isArray(value)) return []
return value.filter((v): v is T => v !== null && typeof v === 'object')
}
/**
* The collection/floor block every timeline-shaped payload carries — the
* full snapshot (timelineOf) and the split detail (timelineSource.ts) share
* this exact field set, re-proved per field at the boundary.
*/
/** The collection/floor block shared by the full snapshot (timelineOf) and the split detail (timelineSource.ts). */
export function collectionsOf(data: Record<string, unknown>): Pick<ContextTimeline,
'requests' | 'events' | 'nodes' | 'droppedNodes' | 'archive'
| 'surfaceFloor' | 'archiveFloor' | 'fileOps' | 'fileOpsFloor' | 'spans'> {
@@ -303,21 +193,12 @@ export function collectionsOf(data: Record<string, unknown>): Pick<ContextTimeli
}
}
/**
* The fast path's collection check: a real array whose entries are ALL
* records. A null/primitive entry would pass a bare Array.isArray yet throw
* on the first property read downstream (`req.seq` on null), so it sends the
* value down the sanitizing slow path, where `objectsOf` drops it.
*/
/** A null/primitive entry passes a bare `Array.isArray` yet throws on the first property read, so it takes the sanitizing slow path. */
function recordsOnly(value: unknown): boolean {
return Array.isArray(value) && value.every(e => e !== null && typeof e === 'object')
}
/**
* Narrow a delivered `unsupported` gate record (the host's baseline gate —
* see host/fallback.ts): both version strings re-proved, anything else
* degrades to null (no gate shown) instead of rendering garbage.
*/
/** The host's baseline-gate record (host/fallback.ts); anything but two version strings degrades to null. */
export function unsupportedOf(value: unknown): { current: string; minimum: string } | null {
const data = asRecord(value)
if (data === null) return null
@@ -325,13 +206,7 @@ export function unsupportedOf(value: unknown): { current: string; minimum: strin
return { current: data.current, minimum: data.minimum }
}
/**
* The session-cost raw material, re-proved per provider/model/period/bucket
* (the shape the client's cost.ts prices): a branch, model, or period that
* is not a plain record drops whole — never a half-proved row — and bucket
* fields zero out via numOf, so garbage can only price as zero, never as
* NaN. Absent stays absent.
*/
/** Session-cost raw material: an unproved branch drops whole and buckets zero out, so garbage prices as zero, never NaN. */
function costOf(value: unknown): ContextTimeline['cost'] | undefined {
const data = asRecord(value)
if (data === null || Array.isArray(data)) return undefined
@@ -361,7 +236,6 @@ function costOf(value: unknown): ContextTimeline['cost'] | undefined {
return out
}
/** The fast-path structural check for `cost`: every branch is a plain record (bucket fields re-prove in costOf/cost.ts). */
function costFastOk(value: unknown): boolean {
if (value === undefined) return true
const data = asRecord(value)
@@ -372,27 +246,14 @@ function costFastOk(value: unknown): boolean {
})
}
/**
* Narrow a delivered projection value to a RENDER-SAFE context timeline —
* the client's no-white-screen guarantee against backend/parse failures.
*
* A value that is not a record at all (capability absent, nothing delivered
* yet) stays `null` and callers show the loading screen. A record that fails
* the wire shape (corrupt checkpoint restore, a failed/older host payload,
* plugin drift) is SANITIZED instead of rejected: every collection becomes
* an array, non-object entries are dropped, `current` becomes a numeric
* breakdown, and wrong-typed scalars are dropped or zeroed — so the whole
* tab still renders with every usable piece of data instead of throwing
* during render and unmounting the conversation view.
*/
/** Narrow a delivered projection value to a RENDER-SAFE context timeline: a
* non-record stays `null` (callers show the loading screen), while a record
* failing the wire shape is SANITIZED, so a corrupt payload cannot throw during render and unmount the conversation view. */
export function timelineOf(value: unknown): ContextTimeline | null {
const data = asRecord(value)
if (data === null) return null
const current = data.current
// The wire shape check for the cheap pass-through path: `current` must be
// a full numeric breakdown (the host always sends all seven fields), and
// every collection must be a real list. Anything else takes the slow path
// and is rebuilt into the safe shape below.
// Fast-path shape check: the host always sends a full numeric breakdown and real collections; anything else is rebuilt below.
const numericBreakdown = current !== null && typeof current === 'object'
&& ['system', 'tools', 'user', 'inject', 'skill', 'assistant', 'tool', 'total']
.every(k => typeof (current as Record<string, unknown>)[k] === 'number')
@@ -404,14 +265,13 @@ export function timelineOf(value: unknown): ContextTimeline | null {
&& systemsFastOk(data.systems)
&& timingFastOk(data.timing)
&& costFastOk(data.cost)) {
// Well-formed: pass the delivered value through untouched (cheap, and reference-stable so plain re-renders stay zero-copy).
// The untouched pass-through keeps the value reference-stable for the selector equality.
return data as unknown as ContextTimeline
}
const safeCurrent: Record<string, unknown> = current !== null && typeof current === 'object' ? current as Record<string, unknown> : {}
const cost = costOf(data.cost)
const timing = timingOf(data.timing)
// The baseline-gate record survives sanitizing: a fallback payload that
// somehow fails the fast path must still pop the gate modal.
// The baseline-gate record survives sanitizing: a fallback payload failing the fast path must still pop the gate modal.
const unsupported = unsupportedOf(data.unsupported)
// The split-generation head fields survive sanitizing too.
const counts = countsOf(data.counts)
@@ -448,12 +308,7 @@ export function timelineOf(value: unknown): ContextTimeline | null {
return safe
}
/**
* The live system-prompt nodes, re-proved per entry and sorted by seq: an
* entry missing a finite seq/time/tokens drops out (the browser then falls
* back to the header epoch), so a hostile collection can never produce a NaN
* prompt figure or an unfetchable seq. Absent or empty stays absent.
*/
/** Live system-prompt nodes sorted by seq; a dropped entry leaves the browser on the header epoch. */
function systemsOf(value: unknown): ContextTimeline['systems'] {
const list = objectsOf<Record<string, unknown>>(value)
const out: SystemPromptNode[] = []
@@ -467,14 +322,7 @@ function systemsOf(value: unknown): ContextTimeline['systems'] {
return out.sort((a, b) => a.seq - b.seq)
}
/**
* The fast path's check for the live system-prompt nodes: every entry must
* carry the three finite numbers the browser reads — `seq` for the per-step
* resolution, `time` for the DNA band, `tokens` for its width. A primitive
* entry, or one whose fields are not numbers, sends the payload down the
* sanitizing slow path (`systemsOf` drops it) instead of leaking `undefined`
* into the bar math. An absent list is fine.
*/
/** Every entry must carry finite `seq`/`time`/`tokens`, else the payload takes the sanitizing slow path. */
function systemsFastOk(value: unknown): boolean {
if (value === undefined) return true
if (!Array.isArray(value)) return false
@@ -487,12 +335,7 @@ function systemsFastOk(value: unknown): boolean {
})
}
/**
* The split head's count figures, re-proved field by field: a present-but-
* partial record zeroes its unreadable fields (the stats board's no-NaN
* guarantee), an absent or non-record value stays absent (legacy generation
* — callers derive the counts from the collections instead).
*/
/** Split-head counts; an absent value stays absent (legacy generation) and callers derive the counts from the collections. */
function countsOf(value: unknown): ContextTimeline['counts'] {
const data = asRecord(value)
if (data === null) return undefined
@@ -506,7 +349,6 @@ function countsOf(value: unknown): ContextTimeline['counts'] {
}
}
/** The split head's newest-request summary; absent or shapeless stays absent. */
function lastOf(value: unknown): ContextTimeline['last'] {
const data = asRecord(value)
if (data === null) return undefined
@@ -519,16 +361,8 @@ function lastOf(value: unknown): ContextTimeline['last'] {
}
}
/**
* Narrow a delivered projection value to the official token-meter
* `contextPressure` projection (provider-anchored occupancy of the next
* request). Absent key or value = the meter's projection is not composed
* (e.g. a harness without the session-projection registry) — callers fall
* back to their derived anchor, so the UI degrades gracefully. The three
* fields are independent last-wins records on the wire (dsh's strict wire
* schema), so each is re-proved on its own: a wrong-typed field drops out,
* the readable ones survive.
*/
/** The token-meter `contextPressure` projection. Its three fields are independent last-wins records of different moments (dsh's
* `ContextPressureProjection`), so each is re-proved alone and an absent value sends callers to their derived anchor. */
export function contextPressureOf(value: unknown): ContextPressure | null {
const data = asRecord(value)
if (data === null) return null
@@ -539,13 +373,7 @@ export function contextPressureOf(value: unknown): ContextPressure | null {
return out
}
/**
* Narrow a delivered projection value to the official token-meter
* `contextBreakdown` projection (the heuristic composition rows of the chat
* ring's panel). Every figure must be a finite number — a partial/corrupt
* value degrades to null so the composition card falls back to the fold's
* own sums instead of mixing sources.
*/
/** The token-meter `contextBreakdown` projection; a partial value degrades to null so the card uses the fold's own sums. */
export function contextBreakdownOf(value: unknown): ContextBreakdown | null {
const data = asRecord(value)
if (data === null) return null
@@ -556,15 +384,9 @@ export function contextBreakdownOf(value: unknown): ContextBreakdown | null {
return { systemTokens, toolsTokens, messageTokens }
}
/**
* Narrow a delivered projection value to the official token-meter
* `tokenUsage` projection (durable cumulative provider usage). Absent key or
* value = the meter's projection is not composed (or no request has reported
* usage yet) — callers drop the affected figures instead of estimating. The
* wire schema is strict with all four buckets REQUIRED (dsh token-meter's
* projectionSchema), so a partial/corrupt value degrades the whole value to
* null instead of undercounting the billed total.
*/
/** The token-meter `tokenUsage` projection. Its wire schema requires all four
* buckets (dsh token-meter's `projectionSchema`), so a partial value degrades
* whole to null rather than undercount the billed total. */
export function tokenUsageOf(value: unknown): TokenUsage | null {
const data = asRecord(value)
if (data === null) return null
@@ -576,30 +398,17 @@ export function tokenUsageOf(value: unknown): TokenUsage | null {
return { uncachedInputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
}
/**
* A non-negative finite number (the timing totals' every field): NaN or a
* negative degrades to 0 instead of leaking into donut shares.
*/
/** A NaN or negative value degrades to 0 instead of leaking into donut shares. */
function msNumOf(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
}
/**
* The OPTIONAL timing scalars (the generation split's spans and block counts):
* a real non-negative number passes, anything else — including absence — reads
* as undefined so the field stays absent on the narrowed value (see
* `timingOf`).
*/
/** An optional timing scalar; anything but a non-negative number stays undefined, so the field never materializes as a zero. */
function optNumOf(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
}
/**
* Cheap whole-value check for the pass-through path of `timelineOf`: absent
* timing passes; present timing must already be well-formed (every scalar
* numeric, every per-name row shaped) — anything else sends the payload down
* the sanitizing slow path.
*/
/** Whole-value check for `timelineOf`'s pass-through path; anything but a well-formed timing object takes the sanitizing slow path. */
function timingFastOk(value: unknown): boolean {
if (value === undefined) return true
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
@@ -607,9 +416,7 @@ function timingFastOk(value: unknown): boolean {
for (const k of ['wallMs', 'ttftMs', 'genMs', 'calls', 'toolsMs', 'toolCalls']) {
if (typeof t[k] !== 'number') return false
}
// The generation split and the block counts are optional but, when present,
// must be finite non-negative numbers — the same gate the slow path applies,
// so a hostile bucket cannot slip through the fast path (see `timingOf`).
// Optional split/block scalars must pass the same non-negative gate the slow path applies.
for (const k of ['reasoningMs', 'textMs', 'toolArgMs', 'reasoningBlocks', 'textBlocks', 'toolArgBlocks']) {
const v = t[k]
if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v) || v < 0)) return false
@@ -625,13 +432,7 @@ function timingFastOk(value: unknown): boolean {
return true
}
/**
* Narrow a delivered timing totals value (see TimingTotals) to a RENDER-SAFE
* shape — the timing card's no-white-screen guarantee. A value that is not a
* record stays null (the card renders its empty state); wrong-typed scalars
* zero out and per-name rows failing the shape drop individually, so one
* hostile row never blanks the ranking.
*/
/** Narrow delivered TimingTotals to a render-safe shape; one row failing the shape drops alone, so it never blanks the ranking. */
export function timingOf(value: unknown): TimingTotals | null {
const data = asRecord(value)
if (data === null) return null
@@ -639,8 +440,7 @@ export function timingOf(value: unknown): TimingTotals | null {
const rawTools = data.tools
if (rawTools !== null && typeof rawTools === 'object' && !Array.isArray(rawTools)) {
for (const k in rawTools) {
// A JSON-delivered record can carry an own '__proto__' key; assigning it
// would set the prototype instead of a row — skip it.
// An own '__proto__' key would set the prototype instead of a row.
if (k === '__proto__' || !Object.hasOwn(rawTools, k)) continue
const row = (rawTools as Record<string, unknown>)[k]
if (row === null || typeof row !== 'object') continue
@@ -659,9 +459,7 @@ export function timingOf(value: unknown): TimingTotals | null {
toolCalls: msNumOf(data.toolCalls),
tools,
}
// The optional scalars stay ABSENT when the host did not serve them (a row
// cached before the split / the counts) or served a non-number: the card
// renders the un-split shape / no qualifier instead of meaningless zeros.
// Optional scalars stay absent when the host served none, so the card renders the un-split shape instead of meaningless zeros.
const reasoning = optNumOf(data.reasoningMs)
if (reasoning !== undefined) totals.reasoningMs = reasoning
const reasoningBlocks = optNumOf(data.reasoningBlocks)
@@ -678,25 +476,17 @@ export function timingOf(value: unknown): TimingTotals | null {
}
/**
* Narrow a delivered projection value to the plugin's `contextHeaders`
* (request-header epoch METADATA — boundaries, token prices, attribution).
* Absent key = an older Host half without the companion unit — the Context
* browser degrades its system/tools sections to a metadata-only note.
* The plugin's `contextHeaders` projection: request-header epoch METADATA
* (boundaries, token prices, attribution); epoch CONTENT is fetched on demand
* (historyPage.ts), never carried here.
*
* Entry-level shape is checked too: a malformed epoch (corrupt payload with
* a missing tools list, a wrong-typed systemTokens, or a tool row whose
* name/tokens the browser reads blindly — `tool.name.toLowerCase()` and
* `b.tokens - a.tokens` throw on junk) would crash the browser's
* tools/sections reads, so the WHOLE projection degrades to null and the
* card falls back to its metadata-only note. The epoch CONTENT is
* not part of this value — the browser fetches it per epoch on demand.
* One malformed epoch degrades the WHOLE projection to null, because
* downstream reads call `tool.name.toLowerCase()` and `b.tokens - a.tokens`
* blindly.
*
* The pre-#37 wire generation carries the system TEXT instead of its token
* price (a host still running the old view — stale watch build, an app not
* restarted since the upgrade — serves it from its cache verbatim), so the
* two generations are normalized to the metadata shape here: unpriced legacy
* entries get the shared meter heuristic applied, priced ones and
* new-shape values pass through untouched.
* A pre-#37 host serving from its cache still carries the system TEXT in
* place of its token price, so unpriced entries get the shared meter
* heuristic here.
*/
export function headersOf(value: unknown): ContextHeaders | null {
const headers = asRecord(value)
@@ -730,16 +520,8 @@ export function headersOf(value: unknown): ContextHeaders | null {
} as unknown as ContextHeaders
}
/**
* Narrow a delivered `contextActivity` value (the per-day ledger the Context
* Overview's heatmap and usage chart read off each session-list row) to a
* render-safe shape. Absent or non-record stays null (an older host serves no
* such key — the heatmap renders its empty note). Per-day re-proved: a
* malformed key or a wrong-typed figure drops just that entry, a malformed
* pricing record drops just the fee (the day's tokens survive), a hostile day
* record can never produce a NaN cell, and a well-formed payload passes
* through untouched (reference-stable for the selector equality).
*/
/** The per-day `contextActivity` ledger behind the Overview heatmap and usage
* chart. Absent stays null (an older host serves no such key); per-day re-proof drops just the bad entry or fee. */
export function activityOf(value: unknown): ContextActivity | null {
const data = asRecord(value)
if (data === null) return null
@@ -758,15 +540,11 @@ export function activityOf(value: unknown): ContextActivity | null {
dirty = true
continue
}
// The day's pricing record (additive-optional): re-proved by the SAME
// sanitizer the timeline's session-cost rides. Absent stays absent; a
// record with no readable branch drops whole (empty ≠ zero — a truly
// metered day always carries a branch), keeping the day's own figures.
// The day's pricing record (additive-optional); an empty result drops whole (empty ≠ zero) and the day's figures survive.
const proved = costOf(entry.cost)
const cost = proved !== undefined && Object.keys(proved).length > 0 ? proved : undefined
if (entry.cost !== undefined && cost === undefined) dirty = true
// The day's skill table (additive-optional): per-name re-proved — a
// malformed tally drops just that name, a malformed record drops whole.
// The day's skill table (additive-optional): a malformed tally drops just that name.
const skills = skillTalliesOf(entry.skills)
if (entry.skills !== undefined && skills === undefined) dirty = true
days[key] = {
@@ -776,17 +554,11 @@ export function activityOf(value: unknown): ContextActivity | null {
...(skills !== undefined ? { skills } : {}),
}
}
// Fully well-formed: pass the delivered value through untouched (cheap, and
// reference-stable for the selector equality); otherwise the sanitized copy.
// The untouched pass-through keeps the value reference-stable for the selector equality.
return dirty ? { days } : data as unknown as ContextActivity
}
/**
* One day entry's skill-load table, re-proved name by name: a tally needs a
* finite non-negative count and a finite load instant; anything less drops
* just that name. Absent/non-record stays undefined (an older host's day, or
* a load-less one); an empty-after-scrubbing record drops whole.
*/
/** One day's skill-load table; a name needs a finite count and load instant, and an empty-after-scrubbing record drops whole. */
function skillTalliesOf(value: unknown): ActivityDay['skills'] | undefined {
const data = asRecord(value)
if (data === null || Array.isArray(data)) return undefined
@@ -832,11 +604,7 @@ export interface TriggerPick {
export type SourcePickOutcome = 'handled' | undefined
/**
* The harness input-trigger service (`ctx.inputTriggers`), as far as this
* plugin consumes it: registering one '/' source whose candidates, picks,
* and enter adjudication all stay on the client.
*/
/** The harness input-trigger service (`ctx.inputTriggers`): one '/' source whose candidates, picks, and enter stay client-side. */
export interface InputTriggersFace {
registerSource(src: {
trigger: '/'
@@ -862,33 +630,18 @@ export interface SessionScopeFace {
export interface SessionsFace {
scope(id: string): SessionScopeFace | undefined
/**
* Re-pull the session-list baseline (the rows' projection column included).
* The overview rides it on open so host-side backfill rows (backfill.ts)
* reach a long-connected browser without a reload.
*/
/** Re-pull the session-list baseline; the overview rides it on open so backfill rows (backfill.ts) reach a long-connected browser. */
refresh?(): Promise<unknown>
}
/** One history page row: the raw event plus the optional host-computed view. */
export interface HistoryEntryLike {
event?: unknown
}
/**
* The seq-anchored history page verb of the harness gateway remotes: the
* session namespace mounts as a traced cordis service literally named
* `remote.session`. The plugin resolves it through the DECLARED inject
* (`watchHistoryFaces` in historyPage.ts — a non-declared read of the
* traced proxy throws), so reads are undefined on harnesses that never
* mount it rather than crashing. `throughSeq` is the inclusive log cut (a
* seq that must exist in the log), `beforeSeq` the exclusive upper bound,
* and the response wraps the rows in a `ClientResult`-style envelope. Rows
* are `SessionHistoryRecord`s — `{type:'event', event}` entries plus packed
* `{type:'chunks', …}` runs the mapper skips (every event the fold needs —
* user/assistant messages, tool calls/results, compaction summaries — is
* always served verbatim; only streaming deltas pack).
*/
/** The gateway's seq-anchored history page verb (`remote.session.page`):
* `throughSeq` is the inclusive log cut and must exist in the log, `beforeSeq`
* the exclusive upper bound. Rows are `SessionHistoryRecord`s —
* `{type:'event', event}` plus packed `{type:'chunks', …}` runs the mapper skips, since only streaming deltas pack. */
export interface SessionPageFace {
page(request: {
address: { kind: 'session'; sessionId: string }
@@ -898,13 +651,8 @@ export interface SessionPageFace {
}, signal?: AbortSignal): Promise<unknown>
}
/**
* The connection service face, as far as this plugin consumes it: the
* generic Connection RPC caller (the harness's unary channel transport)
* plus the loopback fact the harness's own open affordances gate on.
*/
/** The connection service face: the generic RPC caller and the loopback fact the harness's own open affordances gate on. */
export interface ConnectionFace {
/** Whether the page reaches the Host on the operator's own machine. */
isLoopback?: boolean
rpc?: {
call?(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise<unknown>
@@ -916,10 +664,7 @@ const OPEN_CHANNEL = '/api'
const CAN_OPEN_ENDPOINT = 'session/canOpenWorkspacePath'
const OPEN_ENDPOINT = 'session/openWorkspacePath'
/**
* The connection's bound generic-RPC caller, or undefined when the service
* is absent or hostile — every read is guarded, so this can never throw.
*/
/** The connection's bound generic-RPC caller, or undefined off an absent/hostile service. */
export function rpcCallOf(ctx: ClientCtx): ((channel: string, endpoint: string, payload: unknown) => Promise<unknown>) | undefined {
try {
const rpc = asRecord((ctx.get('connection') as ConnectionFace | undefined)?.rpc)
@@ -931,16 +676,10 @@ export function rpcCallOf(ctx: ClientCtx): ((channel: string, endpoint: string,
return undefined
}
/**
* The SESSION's workspace root — the `cwd` its session-list row carries (the
* host session canon, not the host process's own launch directory) — or
* undefined when the face is absent, the snapshot is malformed, or the row
* names no cwd. Every field is re-proved — the no-white-screen guarantee.
*/
/** The SESSION's workspace root — the `cwd` on its session-list row, not the host process's launch directory. */
export function workspaceOf(ctx: ClientCtx, sessionId: string | undefined): string | undefined {
if (typeof sessionId !== 'string' || sessionId === '') return undefined
// The snapshot and its rows are host data — a hostile object may throw on
// the call or on property access, and the card must never blank over it.
// Host data: a hostile object may throw on the call or on property access.
try {
const sessions = ctx.get('sessions') as { list?: { getSnapshot(): unknown } } | undefined
const snapshot = typeof sessions?.list?.getSnapshot === 'function' ? sessions.list.getSnapshot() : undefined
@@ -953,14 +692,9 @@ export function workspaceOf(ctx: ClientCtx, sessionId: string | undefined): stri
}
}
/**
* Whether this deployment can hand a path to the user's native desktop: the
* page must reach the Host on the operator's own machine (`isLoopback`, the
* harness's own gate) AND the session controller's opener capability remote
* must answer true. The capability is an RPC round-trip now (the synchronous
* host-description fact is gone), so the answer is asynchronous; every
* absence, hostility, or transport failure resolves false — never a rejection.
*/
/** Whether this deployment can hand a path to the native desktop: loopback AND
* the session controller's opener capability answer over an RPC round-trip;
* every absence, hostility, or transport failure resolves false. */
export async function canOpenPathsOf(ctx: ClientCtx): Promise<boolean> {
const call = rpcCallOf(ctx)
if (call === undefined) return false
@@ -975,12 +709,7 @@ export async function canOpenPathsOf(ctx: ClientCtx): Promise<boolean> {
}
}
/**
* The system path opener over the session controller's open remote, or
* undefined when the connection carries no RPC caller. Fire-and-forget:
* rejections (unknown path, no desktop, offline) swallow — the affordance
* is best-effort by nature.
*/
/** The system path opener over the session controller's open remote, or undefined without an RPC caller; fire-and-forget. */
export function openPathVia(ctx: ClientCtx): ((path: string) => void) | undefined {
const call = rpcCallOf(ctx)
if (call === undefined) return undefined
@@ -994,19 +723,12 @@ export function openPathVia(ctx: ClientCtx): ((path: string) => void) | undefine
}
}
/**
* The right Sidebar's resource opener over `ctx.sidebarRight`, or undefined
* when this harness serves no such column (a stripped deployment) or the face
* is hostile — the caller then keeps its system-open degradation. Synchronous,
* and it reports whether the column took the address: `openResource` throws
* for a no-type-claims address or with no session surface mounted, and an
* unwired preview must fall back rather than become an inert click. The face
* is re-proved at call time (the service can land or be revoked across an HMR
* reload), so the returned closure reads it per open.
*/
/** The right Sidebar's resource opener, or undefined when the column is absent
* — the caller keeps its system-open degradation. `openResource` throws for an
* unclaimed address or with no session surface, so the caller learns whether
* the address was taken. The face is re-proved per open: a service can land or be revoked across an HMR reload. */
export function openResourceVia(ctx: ClientCtx): ((address: string) => boolean) | undefined {
// The face is untrusted at the boundary, so the raw service is re-proved as a
// record with a callable `openResource` (never trusted off the cast).
// The untrusted face is re-proved as a record with a callable `openResource`.
const faceOf = (): SidebarResourceFace | undefined => {
try {
const face = asRecord(ctx.get('sidebarRight'))
@@ -1025,24 +747,15 @@ export function openResourceVia(ctx: ClientCtx): ((address: string) => boolean)
face.openResource(address)
return true
} catch {
// No preview type claims it, or no session surface is mounted: the caller
// falls back instead of showing an inert affordance.
// No preview type claims it, or no session surface is mounted.
return false
}
}
}
/**
* Jump to one session — the harness's own session-selection verb (issue #90).
* Every supported line selects through the view owner (`uiWorkspace`
* .openSession, the sidebar row click's own verb, re-spelled
* `openSession(target: SessionTarget)` by the 0.1.6 selection refactor; a
* plain session id satisfies the target union). The face is re-proved per
* call (a service can land or be revoked across an HMR reload) and the verb
* is invoked bound (the service instance reads its own state); a composition
* without the workspace module, or a hostile face, swallows — the jump is
* best-effort by nature.
*/
/** Jump to one session through the view owner's own selection verb
* (`uiWorkspace.openSession`) — the sidebar row click rides the same verb and
* the sessions service carries no selection verb. An absent or hostile face swallows, so a dead jump stays on the page. */
export function openSessionVia(ctx: ClientCtx, id: string): void {
try {
const workspace = asRecord(ctx.get('uiWorkspace'))
@@ -1051,17 +764,8 @@ export function openSessionVia(ctx: ClientCtx, id: string): void {
} catch { /* absent or hostile face — the jump is best-effort */ }
}
/**
* On-demand full content for one surface-node seq: resolves the joined
* conversation node, `null` when the durable log does not hold the seq,
* rejects on transport/RPC failure (the caller distinguishes the three).
*/
/** On-demand content for one surface-node seq: `null` when the durable log lacks the seq, rejects on transport failure. */
export type ContentFetcher = (seq: number) => Promise<ConversationNodeLike | null>
/**
* On-demand CONTENT for one `contextHeaders` epoch seq: the fetched system
* prompt and tool schemas (see historyPage.ts), `null` when the durable log
* does not hold the epoch, rejects on transport/RPC failure (the caller
* distinguishes the three).
*/
/** On-demand system prompt and tool schemas for one `contextHeaders` epoch seq (historyPage.ts); `null` when the log lacks the epoch. */
export type HeaderFetcher = (seq: number) => Promise<HeaderEpochContent | null>
+10 -38
View File
@@ -1,42 +1,24 @@
/**
* The plugin's user-settings binding (browser half). The Host-served
* `dsh-context` namespace carries per-user display preferences; the Context
* tab reads them at mount, and the Plugins page's configuration card writes
* them through the bound form. Both degrade to the schema defaults when the
* settings surface is absent (a host without the Config-form transport) or
* read-only (remote browser in memory mode).
*
* The scope faces are minimally re-typed here (the services.ts discipline):
* the runtime service comes from the user's harness, and type-only imports
* of the contract package would still be erased — spelling the consumed
* members keeps the dependency graph honest.
*/
/** The plugin's user-settings binding (browser half): the Host-served `dsh-context` namespace carries per-user
* display preferences, degrading to the schema defaults wherever that surface is absent or read-only. */
import type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types'
// The preference vocabulary is declared once in shared/types.ts; re-exported
// here so client-side consumers keep their canonical import path.
export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types'
/** The bound settings form (the ctx.configForms.get result), as consumed. */
/** The bound settings form (`ctx.configForms.get`), as consumed. */
export interface SettingsScopeLike {
getSnapshot(): { status: string; value: unknown; writable: boolean }
subscribe(listener: () => void): () => void
set(field: string, value: unknown): Promise<void>
}
/**
* The ctx.configForms service face (the Config-form generation's settings
* transport), as consumed: forms bind per namespace, and `whileServed` keeps
* a registration alive while the Host serves any of them. The bound form
* satisfies {@link SettingsScopeLike} (same snapshot/subscribe/set shape).
*/
/** The `ctx.configForms` service face, as consumed; the bound form satisfies {@link SettingsScopeLike}
* (same snapshot/subscribe/set shape). */
export interface ConfigFormsFace {
get(namespace: string): SettingsScopeLike
whileServed(namespaces: readonly string[], register: () => () => void): () => void
}
/** The preference snapshot the card renders and the view reads at mount. */
export interface SettingsState {
/** Scope sync: loading until the first Host section, unavailable when unserved. */
status: 'loading' | 'ready' | 'unavailable'
@@ -105,16 +87,11 @@ export function createContextSettings(): ContextSettings {
state = next
for (const listener of listeners) listener()
}
// Republish from the bound scope's current snapshot; the attach sync and
// the failed-write rollback share this one read. Returns the scope's valid
// placement and insights entry, if it carries them.
const sync = (bound: SettingsScopeLike): { placement?: DefaultPlacement; insightsEntry?: InsightsEntry } => {
const snap = bound.getSnapshot()
const prefs = prefsOf(snap.value)
// Fail open: a config problem must never leave an entry hidden. A valid
// value wins; one the plugin cannot understand degrades to the field's
// default; a section without the field (older Host half) keeps the
// current state.
// Fail open: a config problem must never leave an entry hidden. A value the plugin cannot understand
// degrades to the field's default; a section without the field (older Host half) keeps the current state.
const raw = snap.value !== null && typeof snap.value === 'object'
? snap.value as Record<string, unknown>
: undefined
@@ -155,18 +132,13 @@ export function createContextSettings(): ContextSettings {
},
set(field, value) {
publish({ ...state, ...prefsOf({ [field]: value }) })
// The scope write settles asynchronously and its promise REJECTS on a
// transport failure (dsh keeps only its internal queue tail fulfilled)
// — never let it float unhandled. Roll the optimistic echo back to the
// scope's truth; a refused (non-2xx) write needs nothing here, the
// scope's own recovery re-reads the Host and republishes via subscribe.
// The scope write's promise REJECTS on a transport failure (dsh keeps only its queue tail fulfilled),
// so never let it float: roll the optimistic echo back to the scope's truth; a refused write recovers via subscribe.
const bound = scope
if (bound === undefined) return
void bound.set(field, value).catch(() => {
const truth = sync(bound)
// A visibility gate that failed to persist must not keep an entry
// hidden on an unpersisted echo: with no valid value in the scope's
// truth, degrade each gate to its default.
// A gate that failed to persist must not keep an entry hidden on an unpersisted echo.
if (field === 'defaultPlacement' && truth.placement === undefined) {
publish({ ...state, placement: 'all' })
}
+8 -30
View File
@@ -1,46 +1,26 @@
/**
* Best-effort jump to this plugin's preferences on the Config-form
* generation (every supported line): the preferences live on the bundle's
* page of the Plugins main panel — the jump clicks the panel's sidebar entry
* (its shipped aria-label), then follows the page with a bounded poll for the
* bundle card's open control, found by the shipped, locale-independent
* `data-plugin-package` list identity: the card lists once the page's package
* load settles, and inside it the one button that is not the enable switch is
* the title that opens the page. A poll that never finds the card (still
* loading, plugin absent, or the page already open on the detail view — the
* list unmounts there) degrades to the card list, and every step stays
* silent: a shell that doesn't match (different layout, missing sidebar)
* degrades the whole jump to a silent no-op — this path must never throw
* into the caller's render.
*/
/** Best-effort jump to this plugin's preferences on the Plugins main panel: click the panel's sidebar
* entry, then poll for the bundle card's open control. Every step is silent and never throws into the
* caller's render; a shell that does not match degrades the whole jump to a no-op. */
/**
* The shipped Plugins label in both locales — the main-panel entry's
* aria-label.
*/
/** The shipped Plugins label in both locales — the main-panel entry's aria-label. */
const PLUGIN_LABELS = new Set(['Plugins', '插件'])
/** The bundle card's shipped list identity (locale-independent). */
const PACKAGE_KEY = 'dsh-context'
/** How long the card poll follows the panel: 40 ticks at the 100ms tick, enough for the page's package load. */
/** 40 ticks at the 100ms tick: enough for the page's package load. */
const POLL_TICKS = 40
/** Buttons the jump may operate on, in document order. */
function buttonsOf(doc: Document): HTMLButtonElement[] {
return [...doc.querySelectorAll<HTMLButtonElement>('button')]
}
/** The Plugins main-panel entry, matched by its shipped aria-label. */
function findPanelEntry(doc: Document): HTMLButtonElement | undefined {
return buttonsOf(doc).find(b => PLUGIN_LABELS.has(b.getAttribute('aria-label') ?? ''))
}
/**
* The bundle card's open control: the one button inside the card that is not
* the enable switch (the primitives switch carries `role="switch"`); the
* title button's own aria-label is localized.
*/
/** The bundle card's open control: the one button inside the card that is not the enable switch (the
* primitives switch carries `role="switch"`); the title button's own aria-label is localized. */
function findCardOpenControl(doc: Document): HTMLButtonElement | undefined {
const card = doc.querySelector(`[data-plugin-package="${PACKAGE_KEY}"]`)
if (card === null) return undefined
@@ -53,8 +33,6 @@ export function openPluginSettings(
schedule: (run: () => void, ms: number) => void = (run, ms) => { window.setTimeout(run, ms) },
): void {
try {
// The panel entry is the one seat both locales ship; without it this
// shell has no Plugins page and the jump is a no-op.
const panel = findPanelEntry(doc)
if (panel === undefined) return
panel.click()
@@ -69,5 +47,5 @@ export function openPluginSettings(
if (left > 0) schedule(() => { poll(left - 1) }, 100)
}
schedule(() => { poll(POLL_TICKS) }, 200)
} catch { /* the settings surface doesn't match: silent no-op */ }
} catch { /* the settings surface doesn't match */ }
}
+12 -53
View File
@@ -1,27 +1,11 @@
/**
* The right Sidebar's Context tab (dsh 0.1.7-rc.2+).
* The right Sidebar's Context tab. It reuses the Context conversation-view component verbatim:
* `sidebar.right.pane.tab` is a session-scoped seat delivering the same framework standard kit as
* `conversation.view`, so panel and tab are one component with one data path.
*
* The tab reuses the Context conversation-view component VERBATIM: the
* `sidebar.right.pane.tab` seat is session-scoped and delivers the same
* framework standard kit (`sessionId`, `useProjection`, `useChat`, the locale
* `t` seat) the `conversation.view` seat does, so the panel and the tab are
* one component with one data path. The tab type contributes a guide entry, so
* the sidebar's guide page offers "Context" and picking it opens the panel —
* the product's own path, exactly as the shipped Files type does: a capsule of
* glyph, title, and description line, plus the chip-title seat that puts the
* same glyph beside the label once the tab is open (`icon.tsx`).
*
* OPTIONAL BY CONTRACT. `ctx.sidebarRightTabs` and the seats ship on every
* supported line, but a deployment may strip them (and a below-baseline host,
* gated onto the fallback units, serves none of this half's seats); the
* registration therefore rides a DEFERRED inject (the plugin's hard injects
* stay `slots` + `locale`), so a composition without the service never fires
* the callback, the plugin fiber never pends, and nothing is registered. The
* registry is re-proved structurally and the whole registration is guarded: a
* foreign or hostile registry (a throwing `register`, a taken id/kind) leaves
* the sidebar without the tab instead of taking the browser down.
*
* @module dsh-context/client/sidebar
* OPTIONAL BY CONTRACT: `ctx.sidebarRightTabs` may be stripped, so the registration rides a deferred
* inject (the plugin's hard injects stay `slots` + `locale`) and the plugin fiber never pends. A hostile
* registry is guarded: a throwing `register` or a taken id/kind costs only the sidebar tab.
*/
import { ContextIcon, makeContextTabTitle } from './icon'
@@ -31,41 +15,22 @@ import type { Translate } from './i18n'
/** The tab type's identity in the sidebar's tab system (also its body-seat key). */
export const SIDEBAR_CONTEXT_ID = 'dsh-context'
/**
* The kind `openTab` names. Namespaced rather than the bare `context`: the
* registry THROWS when a kind collides with another registration in a
* non-coexisting band, and a foreign plugin may well own `context`.
*/
/** Namespaced rather than the bare `context`: the registry throws when a kind collides with another
* registration in a non-coexisting band, and a foreign plugin may well own `context`. */
export const SIDEBAR_CONTEXT_KIND = 'dsh-context'
/** The guide capsule's position: after the shipped Files entry (order 10). */
const GUIDE_ORDER = 20
/**
* Register the Context tab type, its body, and its chip title on the right
* Sidebar, if — and only if — this harness serves the sidebar tab registry.
* @param ctx - client root context carrying `slots` and the locale service.
* @param view - the Context view component factory result (the same one the
* conversation tab mounts).
* @param t - the plugin-namespace translate; the label thunks read the active
* locale at call time, so a language switch relabels the guide entry.
* @param ns - the plugin's locale namespace, put on the body registration so
* the framework synthesizes the `t` seat for the panel too.
* @returns the deferred inject's disposer (placement toggling calls it to
* take the mount down; a no-op on faces that return no handle).
*/
/** @returns the deferred inject's disposer; a no-op on faces that return no handle. */
export function watchSidebarContextTab(
ctx: ClientCtx,
view: (props: ContextViewProps) => unknown,
t: Translate,
ns: string,
): () => void {
// Deferred: a harness without the right Sidebar never fires this, and the
// plugin is simply a conversation tab there — no pending fiber, no error.
const fiber = ctx.inject(['sidebarRightTabs'], (raw) => {
const injected = raw as unknown as ClientCtx & { sidebarRightTabs?: SidebarTabsFace }
// Every registration that already landed, so a partial failure below
// unwinds exactly what this callback owns.
const disposers: (() => void)[] = []
const own = (result: unknown): void => {
if (typeof result === 'function') disposers.push(result as () => void)
@@ -88,27 +53,21 @@ export function watchSidebarContextTab(
{ name: 'sidebar.right.pane.tab', key: SIDEBAR_CONTEXT_ID, locale: ns },
(props: { sessionId?: string } & Record<string, unknown>) => view({ ...props, host: 'sidebar' }),
)))
// The chip title seat: the emblem beside the label, the files type's
// own idiom. Registered under the type id so the kit dispatches it for
// this type's tabs only.
// Under the type id, so the kit dispatches it for this type's tabs only.
own(injected.slots.inject('sidebar.right.pane.tab.title', () => injected.slots.register(
{ name: 'sidebar.right.pane.tab.title', key: SIDEBAR_CONTEXT_ID },
makeContextTabTitle(t),
)))
} catch {
// A foreign registry that throws on register leaves the sidebar without
// this tab; the conversation tab and every other seat keep working.
for (const dispose of disposers) dispose()
return undefined
}
// The inject callback's own disposer owns every registration: cordis
// unloads them with the injected fiber (plugin stop, HMR reload).
// The inject callback's own disposer owns every registration; cordis unloads them with the injected fiber.
return () => {
for (const dispose of disposers) dispose()
}
})
// The cordis inject handle disposes the callback's fiber; re-proved at
// runtime because a harness face may return no handle at all.
// Re-proved at runtime: a harness face may return no handle at all.
const handle = fiber as { dispose?: () => unknown } | undefined
return () => { void handle?.dispose?.() }
}
+11 -36
View File
@@ -1,27 +1,15 @@
/**
* The skill catalog behind the Insights page's skill card: one POST to the
* plugin's `/api/dsh-context/skills` route (host/skills.ts) per panel open,
* anchored on the current session (its workspace selects the registry's
* project layer; its preset selects the discovery scope). The card joins the
* answer by name onto the activity ledger's load tallies, so a missing or
* failed read is no error state — the rows render their tallies unenriched.
*/
/** The skill catalog behind the Insights page's skill card: one POST to the
* plugin's `/api/dsh-context/skills` route per panel open, anchored on the
* current session; a failed read renders the tallies unenriched. */
import type { SkillInfo } from '../shared/types'
import { asRecord } from './services'
// The skills route of host/skills.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 skills route of host/skills.ts, re-declared here: the client bundle
// inlines every import and the host module must never reach it.
const SKILLS_ROUTE = '/api/dsh-context/skills'
/**
* Narrow the route's payload to render-safe entries (the boundary rigor
* every parser owes untrusted input): each entry's name is the join key and
* must prove itself, the description defaults to empty, path and source ride
* only when proved — an entry failing the shape drops whole and the readable
* siblings keep serving.
*/
/** Narrow the route's payload: the name is the join key and must prove itself; path and source ride only when proved. */
export function skillInfoListOf(value: unknown): SkillInfo[] | null {
const list = asRecord(value)?.skills
if (!Array.isArray(list)) return null
@@ -39,17 +27,9 @@ export function skillInfoListOf(value: unknown): SkillInfo[] | null {
return out
}
/**
* One route read for one viewing context, narrowed through `skillInfoListOf`
* into the card's name-keyed join map. The session id lets the host resolve
* the session's workspace AND preset scope (web compositions mount skill
* discovery on agent presets — a cwd alone lists only the global layers);
* the cwd rides as the fallback when the session cannot be observed. Never
* rejects and never reports an empty answer: a served catalog with no usable
* entry reads the same as no route at all (`null` — the rows render
* unenriched either way). Later names win the map slot, matching the
* registry's own last-wins merge.
*/
/** One route read, narrowed into the card's name-keyed join map. The session id
* lets the host resolve the workspace AND the preset scope (discovery mounts on
* agent presets, so a cwd alone lists only global layers). Never rejects; later names win. */
export async function readSkillCatalog(
cwd: string | undefined,
sessionId: string | undefined,
@@ -73,7 +53,6 @@ export async function readSkillCatalog(
}
}
/** The clipboard write behind the card's path footer (test seam; the real write). */
type TextCopier = (text: string) => Promise<void>
/** The clipboard write through the platform API (absent off a secure context). */
@@ -85,16 +64,12 @@ const defaultCopier: TextCopier = async (text) => {
let copier: TextCopier = defaultCopier
/** Test seam: replace the clipboard write; null restores the default. */
export function setSkillCopier(next: TextCopier | null): void {
copier = next ?? defaultCopier
}
/**
* Copy the skill's path for the card's footer click. Resolves whether the
* write landed, so the footer's "copied" flash never claims a write that
* failed (an insecure context, a denied permission).
*/
/** Copy the skill's path for the card's footer click, resolving whether the write
* landed so the "copied" flash never claims a failed write. */
export async function copySkillPath(path: string): Promise<boolean> {
try {
await copier(path)
+1 -19
View File
@@ -1,39 +1,21 @@
/* The agent network card: session-family graph, node rings, inspector and legend (agentGraph.tsx). */
/* ---- Agent network card: session-family graph at the foot of the Context tab ---- */
.lc-agents-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.lc-agents-chip { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-agents-chip-on { color: var(--dsw-alias-state-success-primary); border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 40%, transparent); }
/* `overscroll-behavior-x: contain` plus overscroll.ts: a swipe past the stage's edge must not chain into the
browser's history navigation (the trend chart's scroller carries the same guard). */
/* With overscroll.ts: a swipe past the stage edge must not chain into browser history navigation. */
.lc-agents-stage { overflow-x: auto; overscroll-behavior-x: contain; margin: 0 -4px; padding: 0 4px; }
.lc-agents-svg { display: block; margin: 0 auto; max-width: none; }
/* Direct segment per link, family-colored (the stroke comes from the link);
a running child brightens its channel and layers a traveling pulse of the
same hue over it. */
/* Link stroke color comes from the link itself; width/opacity ride `stroke-[1.5px] stroke-opacity-45`
utilities in agentGraph.tsx, the live state re-asserts opacity here (sheet order beats the utility tie).
The flow dash's pulse rides the `animate-lc-agent-flow` utility; linecap/dasharray stay here. */
.lc-agents-link-live { stroke-opacity: 0.85; }
.lc-agents-flow { stroke-linecap: round; stroke-dasharray: 2 7; }
.lc-agent-node { outline: none; }
.lc-agent-clickable { cursor: pointer; }
/* The halo carries node state: the resting fill and the hover/focus wash ride `fill-transparent` and
`group-hover/agent`/`group-focus-visible/agent` variants in agentGraph.tsx, brand tint for self,
breathing green while running (the glow rides the `animate-lc-agent-glow` utility), a faint green
rest for done. */
.lc-agent-halo { transition: fill var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-agent-self .lc-agent-halo { fill: var(--dsw-alias-bg-layer-2); }
.lc-agent-running .lc-agent-halo { fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); }
.lc-agent-done .lc-agent-halo { fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 7%, transparent); }
/* One fused ring per node: composition arcs fill the occupied share, a neutral remainder marks the free window.
The track's fill/stroke and the arcs' fill/width ride utilities in agentGraph.tsx; the free remainder's
color-mix stays here (it is a state-class style, not a static one). */
.lc-agent-seg { transition: stroke-dasharray 300ms ease, stroke-dashoffset 300ms ease; }
.lc-agent-free { stroke: color-mix(in srgb, var(--dsw-alias-label-secondary) 18%, transparent); }
.lc-agent-pct { font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; pointer-events: none; }
/* The caption is HTML inside a foreignObject, so long agent names wrap freely instead of truncating. */
.lc-agent-caption { width: 100%; text-align: center; pointer-events: none; line-height: 1.25; }
/* Labels wrap word-wise; only an extremely compressed (narrow stage) layout ellipsizes past 3 lines. */
.lc-agent-label { font-size: 11px; color: var(--dsw-alias-label-primary); overflow-wrap: break-word; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
.lc-agent-tokens { font-size: 10px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-agents-solo { padding: 4px 0 10px; }
-1
View File
@@ -1,4 +1,3 @@
/* Attachment grids (browser.tsx inline grid + images.tsx cards). */
.lc-att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 8px 10px; }
.lc-att-item { display: flex; gap: 8px; align-items: center; min-width: 0; width: 100%; box-sizing: border-box; padding: 6px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: transparent; font: inherit; text-align: left; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-att-thumb { width: 64px; height: 64px; flex: none; padding: 0; display: flex; align-items: center; justify-content: center; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); }
+24 -107
View File
@@ -1,129 +1,55 @@
/*
* Shared base for every plugin surface: the Context tab's root/card chrome,
* the `.lc-gran` segmented toggle, list-column anatomy, empty/error states,
* and the composer-hiding rule. Imported FIRST of the plugin's sheets —
* later component sheets re-tint the shared chrome at equal specificity
* (e.g. the file card's purpose chips over `.lc-gran-btn:hover`), so this
* order is load-bearing.
*/
/* Imported FIRST of the plugin's sheets: later sheets re-tint the shared chrome at equal specificity, so this order is load-bearing. */
.lc-root { padding: 16px 20px 32px; overflow-y: auto; height: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); font-size: 13px; }
/*
Right Sidebar panel: the body draws NO padding of its own (dockkit leaves
each tab its own insets), so the root keeps a 12px frame on all four sides —
the harness's own body inset — instead of the wide shared chrome. The root
KEEPS its own scroller: the supported lines wrap the tab in a
definite-height `.tabBody` whose `overflow: hidden` would clip a grown root
out of reach — `height: 100%` resolves against whichever definite-height
host the running shell provides (and degrades to the root growing under an
auto-height one, where the pane scrolls instead: one scroller either way).
Scoped by the panel's own markers, which exist only inside the right column
(the open panel and the float host that carries a tab dragged out of it);
the conversation tab and the /context modal are unaffected.
/** The right-column panel keeps its own scroller: the supported shell wraps the tab in a definite-height body whose `overflow: hidden` would otherwise clip a grown root.
*/
[data-sidebar-right-open] .lc-root,
[data-sidebar-right-float-host] .lc-root { padding: 12px; }
/* The Context tab chip's emblem: held at its drawn size in the title's flex
line (dockkit's `.tabTitle`; the files chip's own titleIcon idiom). */
.lc-title-icon { flex: none; }
/*
The chip's close control is OUT of flow over the title's last 14px, and an
active/hovered chip fades the 16px before it (dockkit `.tabTitle`'s mask) so
the text dies away under the close. The shipped files chip stays readable
only because its short label ends before that zone; our icon plus a longer
label runs into it. This gutter makes the fade land past the text: 30px is
the mask's own lead-in (`calc(100% - 30px)`), the one dockkit chrome
constant this touches — re-check it if that mask ever moves.
*/
/* 30px is dockkit `.tabTitle`'s own mask lead-in; re-check if that mask moves. */
.lc-title-label { padding-right: 30px; }
/*
Border roles: l2 (0.1 black ≈ #E5E5E5 on white) draws the VISIBLE edges —
cards and controls, matching dsh's own Button/Input chrome; l1 stays for
hairline dividers and passive chips, where a stronger line is just noise.
*/
.lc-card { background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
/*
The Context tab runs the card rhythm at HALF the shared chrome — padding
inside each card, and the gaps between cards (the `.lc-cols` row gap + row
margin, and the stacked-column cards' bottom margin) all halved. This block
must sit BEFORE the `.lc-cols`/`.lc-col` margin-zeroing rules below so their
equal-or-higher specificity keeps row children at 0 while column-stacked
cards take the halved margin. The /context modal sits outside `.lc-root` and
keeps the shared values (and its cards stay non-containers).
Every tab card is ALSO the `lc-card` query container: card-internal reflows
(the donut cards in stats.css, the events/file rows' two-line anatomy) key
off the card's own width, not the viewport — the pane depends on the shell's
sidebar state. Inline-size containment is safe: every card's width comes
from its flex row, never from its contents; the one fixed-position overlay
(the image lightbox) portals to <body>, so the layout-containment fixed-CB
trap cannot catch it. Border-box is load-bearing on the narrow pane: cards
carry the `min-w-[min(360px,100%)]` floor while riding this sheet's padding,
and the plugin ships no preflight reset — a content-box card reads the floor
as its content width and overflows its row by its own chrome (issue #65).
*/
/* Every tab card is the `lc-card` inline-size query container; it must stay BEFORE the `.lc-cols`/`.lc-col` margin-zeroing rules below, and border-box is required by the `min-w-[min(360px,100%)]` floor the cards ride. */
.lc-root .lc-card { box-sizing: border-box; padding: 7px 8px; margin-bottom: 7px; container: lc-card / inline-size; }
.lc-root .lc-cols { gap: 7px; margin-bottom: 7px; }
/* Wraps under width pressure: toggle groups/kind chips in a header drop to
their own line instead of spilling past the card edge. */
.lc-card-title { font-weight: 600; margin-bottom: 10px; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.lc-card-title-text { flex: none; white-space: nowrap; }
.lc-gran, .lc-kinds { margin-left: auto; display: flex; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; padding: 1px; }
/* A title carrying TWO toggle groups wraps them so the auto-margin push happens once — two bare .lc-gran siblings would split the free space and drift apart. */
/* A title carrying two toggle groups wraps them so the row keeps a single auto-margin push. */
.lc-trend-ctl, .lc-heat-ctl { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.lc-trend-ctl .lc-gran, .lc-heat-ctl .lc-gran { margin-left: 0; }
/* The heat title's hint text loses its own auto margin inside the wrapper. */
.lc-heat-ctl .lc-card-sub { margin-left: 0; }
/* The trend title's modifier group (DNA / adaptive / duration as one multi-toggle segmented group)
rides the title text's right — its auto margin is neutralized so the group hugs the title and the
control cluster's own auto margin remains the row's single right push (the browser card's DNA
toggle idiom). */
.lc-trend-mods { margin-left: 0; flex: none; }
.lc-gran-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-gran-btn:hover { color: var(--dsw-alias-label-primary); }
/* Disabled chips: inert and visually receded, keeping the group's layout so the row does not reflow. */
.lc-gran-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.lc-gran-btn:disabled:hover { color: var(--dsw-alias-label-secondary); }
.lc-gran-on, .lc-gran-on:hover { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }
/* The events card's kind filters carry their per-kind event tally: a tabular
count set off from the label, dimmed while the chip is off and inherited
(slightly translucent) on the lit chip. */
.lc-kind-n { margin-left: 4px; font-variant-numeric: tabular-nums; opacity: 0.65; }
.lc-gran-on .lc-kind-n { opacity: 0.85; }
/*
Shared tooltip-bubble chrome: pair `.lc-tip` with the owner's own tip class,
then position locally (top/bottom anchor, width cap, whitespace); the reveal
rides a `group-hover/tip` variant on the owner. The component sheets carry
the four shippers (.lc-stat-tip / .lc-brief-tip / .lc-fa-meta-tip / .lc-bar-tip);
a new tip never re-declares this chrome.
*/
/* Shared tooltip chrome: pair with the owner's own tip class, then position locally; new tips never re-declare it. */
.lc-tip { position: absolute; z-index: 6; width: max-content; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 6px 10px; font-size: 12px; color: var(--dsw-alias-label-primary); box-shadow: var(--dsw-shadow-lv3, 0 2px 8px rgba(0, 0, 0, 0.18)); pointer-events: none; opacity: 0; transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-events, .lc-fa-list { display: flex; flex-direction: column; gap: 2px; height: 320px; overflow-y: auto; }
/*
The row owns the 14px vertical rhythm (lc-cols gap); cards drop their own bottom margin (incl. the last in a column) so every section boundary measures exactly one 14px whichever way the flex wraps.
*/
/* The `.lc-cols` row owns the vertical rhythm; cards drop their own bottom margin so every section boundary measures one gap whichever way the flex wraps. */
.lc-cols { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; }
.lc-cols > .lc-card { margin-bottom: 0; }
.lc-col > .lc-card:last-child { margin-bottom: 0; }
.lc-col-browser { display: flex; flex-direction: column; }
.lc-col-browser > .lc-card { flex: 1; }
/*
Head band: TWO rows — the session's shape at a 3:1 split beside the plugin
card (context / plugin), then the two donut cards together (token /
timing). The rows are `.lc-cols` flex-wrap rows and the cards ride the
shared column idiom (`min-w-[min(360px,100%)]` in the components): the
stats card's `flex-[3]` against the plugin card's `flex-1` thirds the row,
and the min-width floor is the fold — below (360 + 240)px + gap of row
width the 3:1 pair wraps onto full-width lines, below 2 x 360px + gap the
equal donut pair does (~360px being where the donut cards' ring+legend
single-line and the count grid both start to cramp, issue #48; the plugin
card's 240px floor leans on its rows' ellipsis + hover recovery). Cards
under that end of the band sit under their own lc-card reflows (stats.css:
the ring stacks over its legend, the cell grid folds), so the two-card
line is the trade this floor buys. Engines without container queries keep
the wide layout. The right Sidebar's panel renders only the donut row (it
drops the context stats / plugin info row — see contextView.tsx); the same
wrap then stacks the pair in the narrow pane.
*/
/** Head band: TWO rows — the session's shape at a 3:1 split beside the plugin
* card (context / plugin), then the two donut cards together (token /
* timing). The rows are `.lc-cols` flex-wrap rows and the cards ride the
* shared column idiom (`min-w-[min(360px,100%)]` in the components): the
* stats card's `flex-[3]` against the plugin card's `flex-1` thirds the row,
* and the min-width floor is the fold — below (360 + 240)px + gap of row
* width the 3:1 pair wraps onto full-width lines, below 2 x 360px + gap the
* equal donut pair does (~360px being where the donut cards' ring+legend
* single-line and the count grid both start to cramp, issue #48; the plugin
* card's 240px floor leans on its rows' ellipsis + hover recovery). Cards
* under that end of the band sit under their own lc-card reflows (stats.css:
* the ring stacks over its legend, the cell grid folds), so the two-card
* line is the trade this floor buys. Engines without container queries keep
* the wide layout. The right Sidebar's panel renders only the donut row (it
* drops the context stats / plugin info row — see contextView.tsx); the same wrap then stacks the pair in the narrow pane. */
/* The context card's flow diagram stretches to the plugin card's height, so the row reads as one level band. */
.lc-head > .lc-card:has(.lc-stats) { display: flex; flex-direction: column; }
.lc-head .lc-stats { flex: 1; }
@@ -132,16 +58,7 @@
.lc-error-msg { font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 4px 8px; max-width: 100%; overflow-wrap: anywhere; }
.lc-error-retry { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 4px 14px; border-radius: 6px; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-foot { color: var(--dsw-alias-label-secondary); font-size: 12px; margin-top: 4px; }
/* Hides the composer while a plugin surface owns the pane, but keeps it for approval/question/plan-review prompts. */
[data-conversation-scroll]:has(.lc-root) > [data-composer-seat]:not(:has([data-approval-key], [data-question-key], [data-plan-review-key])) { display: none; }
/*
The shell's transcript-width drag handles are FOLLOWING SIBLINGS of the shared scroller (children of
the skeleton body, dsh ConversationRoot), not its descendants — a descendant combinator here silently
matches nothing (issue #50), hence the `~`. While a plugin surface owns the ring, the strips
(z-index 8, full-height col-resize) glow and drag straight through the Context tab's full-bleed pane,
and hover above the /context modal's mask (the modal rides the composer seat's z-index 7 stacking
context). Hide them while `.lc-root` (the tab) or `.lc-modal-backdrop` (the modal, on any tab) is
mounted; switching back to Chat or closing the modal unmounts both and restores the handles. The
harness's own opt-out, `data-conversation-composer-overlay`, would also re-home scrolling into the
pane, so the strips are neutralized here instead.
*/
/* The harness's transcript-width drag handles are FOLLOWING SIBLINGS of the shared scroller, so `~` is required — a descendant combinator matches nothing (issue #50). Hidden while `.lc-root` or `.lc-modal-backdrop` is mounted, because they would drag through the pane and hover above the modal mask. */
[data-conversation-scroll]:has(.lc-root, .lc-modal-backdrop) ~ [data-width-handle] { display: none; }
+10 -34
View File
@@ -1,12 +1,6 @@
/* The Context browser: category rows, element rows, delta pills and disclosure chevrons (browser.tsx). */
/* Title-row toggles: the DNA group's margin is zeroed so the delta-baseline .lc-gran keeps the shared
auto margin, pushing that group + the picker right (two auto margins would split the free space and
drift the groups apart). */
/* The DNA group's margin is zeroed so the delta-baseline `.lc-gran` keeps the row's single auto-margin push. */
.lc-br-dna-ctl { margin-left: 0; flex: none; }
/* DNA mode: per-item bands read as one gel strip — a card-colored divider keeps adjacent same-category bands
distinguishable. The divider scales with the band (`min(0.5px, 12.5%)` of its own width): long sessions pin hundreds
of bands at ≤1px, where a fixed divider would cover the whole band and blank the strip. A gradient layer over
the segment's inline `backgroundColor` — a fixed inset box-shadow cannot scale. */
/* The divider scales with its band (`min(0.5px, 12.5%)` of its own width): long sessions pin hundreds of bands at ≤1px, where a fixed divider would cover the whole band. A gradient layer, because a fixed inset box-shadow cannot scale. */
.lc-br-bar-dna .lc-stacked-seg { background-image: linear-gradient(to right, transparent calc(100% - min(0.5px, 12.5%)), var(--dsw-alias-bg-layer-1) 0); }
.lc-br-pick { font: inherit; font-size: 12px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; padding: 3px 6px; max-width: 240px; }
.lc-br-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-bottom: 8px; color: var(--dsw-alias-label-secondary); }
@@ -18,28 +12,19 @@
.lc-br-cat { border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
.lc-br-cat-empty { opacity: 0.55; }
.lc-br-cat-row { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; padding: 7px 10px; cursor: pointer; text-align: left; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/*
Current-composition hover echo: the category row lit by the shared hover link looks hovered — same tint as a physical hover, plus a ring on its color dot.
*/
/* Current-composition hover echo: the shared hover link lights the category row as a physical hover would. */
.lc-br-cat-on { background: var(--dsw-alias-interactive-bg-hover); }
.lc-br-cat-on i { box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); }
/* Expanded category: the open header holds the hover tint, so the header keeps
reading as the raised face over its body — every category card alike. */
.lc-br-cat-open { background: var(--dsw-alias-interactive-bg-hover); }
.lc-br-cat-row i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; flex: none; transition: box-shadow var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-br-cat-label { font-weight: 600; }
.lc-br-cat-count { color: var(--dsw-alias-label-secondary); font-size: 12px; white-space: nowrap; }
.lc-br-count-grp { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 4px; }
/* Disclosure chevron, drawn in CSS (a bordered box rotated -45°→45°) instead
of a ▸ glyph: the box rotates about its true center — no glyph wobble — and
stays crisp at any zoom. */
/* A bordered box instead of a ▸ glyph: it rotates about its true center and stays crisp at any zoom. */
.lc-br-chev { flex: none; width: 12px; height: 14px; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); }
.lc-br-chev::before { content: ''; width: 5px; height: 5px; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: rotate(-45deg); transition: transform var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-br-chev-on::before { transform: rotate(45deg); }
.lc-br-tokens { flex: none; color: var(--dsw-alias-label-secondary); font-size: 12px; }
/*
Δ pills on a category row: count/token swings vs the previous turn's last step, tinted by direction (added = success, freed = error) — the events chips' language.
*/
.lc-br-delta, .lc-br-tdelta { flex: none; font-size: 11px; font-weight: 600; padding: 0 5px; border-radius: 4px; white-space: nowrap; }
.lc-br-delta-up, .lc-br-tdelta-up { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); }
.lc-br-delta-down, .lc-br-tdelta-down { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 15%, transparent); }
@@ -49,28 +34,19 @@
.lc-br-elem { border-radius: 6px; }
.lc-br-elem-on { background: var(--dsw-alias-interactive-bg-active); }
.lc-br-elem-row { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 5px 6px; cursor: pointer; text-align: left; border-radius: 6px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/* Failed tool result: the chat's red run-state dot (6px core on a 10px
halo) leads the collapsed row, so the failure scans without expanding. */
/* The chat's run-state dot, so the failure scans without expanding the row. */
.lc-br-err-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }
/* Shrinkable under width pressure (min-width: 0) so a long tool/call tag ellipsizes instead of forcing the row past the card edge. */
.lc-br-tag { min-width: 0; font-size: 11px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 0 6px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The row's tag slot: one or more fact capsules in a shrinkable group — a tool breadcrumb renders one capsule per
distinct call name (repeats fold into a ×N multiplier), the group ellipsizes like the single capsule it replaces. */
/* One capsule per distinct call name, repeats folded into a ×N multiplier. */
.lc-br-tags { min-width: 0; display: inline-flex; align-items: center; gap: 4px; }
/* Registering-plugin chip on tool-schema rows: package/server names scan on a
brand-tinted pill (the badge tint idiom), in the same face and size as the
tool name they tag (the row inherits the UI font at 12px) so the attribution
reads as part of the name. */
/* Same face and size as the tool name it tags. */
.lc-br-tool-plugin { font-size: 12px; border-radius: 6px; color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); border-color: transparent; }
.lc-br-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The row's muted meta run: the introducing-step stamp (T{t} S{s}) merges with the clock into one
nowrap span, so the meta tail reads as a single unit; the stamp's tooltip explains it on hover. */
/* The introducing-step stamp merges with the clock into one nowrap span. */
.lc-br-time { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; }
/* A PENDING stamp (a live-tail item no request carries yet) is an estimate: the dotted underline
is the quiet differentiator, and its tooltip names the not-yet-sent step. */
/* A PENDING stamp (a live-tail item no request carries yet) is an estimate; its tooltip names the not-yet-sent step. */
.lc-br-time-pending { text-decoration: underline dotted; text-underline-offset: 2px; }
/* Call-hit tally on a tool-definition row (×N): the count sort's magnitude,
shown in the time slot's muted face. */
/* Call-hit tally on a tool-definition row. */
.lc-br-hits { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.lc-br-content { padding: 2px 6px 8px 26px; display: flex; flex-direction: column; gap: 6px; }
.lc-br-dim { color: var(--dsw-alias-label-secondary); }
+7 -22
View File
@@ -1,23 +1,8 @@
/*
* The Insights page's date-range picker (components/dateRange.tsx): the
* trigger chip in the page head, and the portaled calendar panel it opens.
*
* The panel rides the shared `.lc-tip` face and overrides its positioning
* seat — the page is the `lc-ov` query container, so an in-tree popover would
* be captured by the container's containing block AND clipped by the page's
* one scroller; hence the portal to <body> and the fixed seat here (the page's
* hover tips' own escape, hoverTip.tsx). Everything below the seat is
* ordinary component chrome.
*/
/* ---- the trigger: the range itself, as a quiet chip cut like the head's
preferences/balance pills ---- */
.lc-dp { position: relative; flex: none; }
.lc-dp-trigger { display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); white-space: nowrap; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-dp-trigger:hover { border-color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-primary); }
/* A live range takes the chip's own voice: the brand-bordered pill the page
uses for the balance capsule, so the head reads "this scope is custom"
without adding another control. */
/** A live range takes the chip's own voice: the brand-bordered pill the page
* uses for the balance capsule, so the head reads "this scope is custom" without adding another control. */
.lc-dp-trigger-on { border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, transparent); color: var(--dsw-alias-brand-primary); }
/* The two ends, always on show: the start reads left of the end, so the scope
is legible straight off the head without opening the panel. Neither half
@@ -27,6 +12,7 @@
.lc-dp-sep { flex: none; color: var(--dsw-alias-label-tertiary); }
/* ---- the panel: `.lc-tip`'s face, this sheet's fixed seat ---- */
/* Portaled to <body> with a fixed seat: the page is the `lc-ov` query container, so an in-tree popover would be captured by its containing block and clipped by the page's one scroller. */
.lc-dp-panel { position: fixed; z-index: 300; left: 0; top: 0; width: max-content; max-width: calc(100vw - 16px); padding: 8px; pointer-events: auto; opacity: 1; }
/* The month's own nav — the panel's only chrome above the grid. */
@@ -37,11 +23,10 @@
.lc-dp-navbtn:disabled { opacity: 0.35; cursor: not-allowed; }
.lc-dp-navbtn:disabled:hover { background: transparent; color: var(--dsw-alias-label-secondary); }
/* ---- the month grid ----
Fixed 28px cells: a calendar that sized to its content would change width
month to month (a five-week February against a six-week one), and the panel
would jump under the cursor. The grid also sets the panel's own width, so
the month header and the foot hint lay out against a stable measure. */
/** ---- the month grid ---- Fixed 28px cells: a calendar that sized to its content would change width
* month to month (a five-week February against a six-week one), and the panel
* would jump under the cursor. The grid also sets the panel's own width, so
* the month header and the foot hint lay out against a stable measure. */
.lc-dp-wds, .lc-dp-week { display: flex; }
.lc-dp-wd { flex: 0 0 28px; text-align: center; font-size: 10px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
.lc-dp-grid { display: flex; flex-direction: column; outline: none; }
+8 -38
View File
@@ -1,20 +1,10 @@
/* Element-body detail sections: section cards, tool-schema tables, call argument rows, rich-text toggle and the markdown overrides (browser.tsx + richText.tsx). */
/*
---- Detail sections: EVERYTHING in an open element body is a Section (labeled head + body) — prose, tool calls, parameter rows, image grids and raw JSON share one frame; the raw-schema JSON sits below as a collapsed toggle (no card chrome — a debug view, not a primary section). ----
*/
/*
Markdown cards: the section card is a flex child that otherwise refuses to shrink below its content (min-width: auto) — min-width: 0 on the card + overflow-wrap: anywhere on the body make long tokens/URLs/code wrap instead of stretching the card.
*/
/* An open element body frames everything as a Section; the raw-schema JSON sits below as a collapsed toggle, without card chrome. */
.lc-ts-card { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; overflow: hidden; min-width: 0; }
.lc-ts-card-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary); border-bottom: 1px solid var(--dsw-alias-border-l1); }
/* Rich-text heads fold their trailing group (meta + Raw/MD switch + copy) onto a second line under width pressure; the call-name head must stay one-line (it ellipsizes instead), so the wrap is scoped here rather than on the shared rule. */
/* The call-name head must stay one-line (it ellipsizes instead of wrapping), so the wrap is scoped to rich-text heads. */
.lc-ts-card-head-wrap { flex-wrap: wrap; }
.lc-ts-card-head b { color: var(--dsw-alias-label-primary); }
/*
Shrinkable under width pressure: a long mono call name (MCP tool names have
no break points) must ellipsize instead of pushing the head's status/meta
group out of the section card; the Section's label title recovers it.
*/
/* MCP tool names have no break points, so a long name must ellipsize instead of pushing the head's status group out of the card. */
.lc-ts-call-name { font-size: 13px; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-ts-card-count { margin-left: auto; }
.lc-ts-card-right { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
@@ -28,7 +18,7 @@
.lc-ts-line { display: block; position: relative; padding-left: 26px; }
.lc-ts-line::before { content: counter(lc-line); counter-increment: lc-line; position: absolute; left: 0; width: 20px; padding-right: 6px; text-align: right; color: var(--dsw-alias-label-tertiary); font-size: 11px; user-select: none; }
.lc-ts-line:empty::after { content: '\200B'; }
/* Columns squeeze under width pressure (desktop metrics unchanged) — fixed px tracks cannot shrink below 316px and would spill the nested section card. */
/* minmax tracks so the columns squeeze; fixed px tracks bottom out at 316px and spill the nested card. */
.lc-ts-param-row { display: grid; grid-template-columns: minmax(0, 140px) minmax(0, 90px) 56px minmax(0, 1fr); column-gap: 10px; row-gap: 2px; padding: 6px 10px; border-top: 1px solid var(--dsw-alias-border-l1); font-size: 12px; align-items: baseline; }
.lc-ts-param-row:first-of-type { border-top: 0; }
.lc-ts-param-name { font-weight: 600; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -37,13 +27,10 @@
.lc-ts-param-req-off { color: var(--dsw-alias-label-secondary); font-size: 11px; }
.lc-ts-param-desc { grid-column: 1 / -1; color: var(--dsw-alias-label-secondary); line-height: 1.5; overflow-wrap: anywhere; }
.lc-ts-params-empty { padding: 8px 10px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
/*
Tool-call argument rows: name in a narrow left column, value wrapped/mono, height-capped so one huge payload (e.g. a full file write) never blows the card open.
*/
/* The height cap keeps one huge payload (a full file write) from blowing the card open. */
.lc-ts-arg-row { display: grid; grid-template-columns: minmax(0, 96px) 1fr; column-gap: 12px; padding: 6px 10px; border-top: 1px solid var(--dsw-alias-border-l1); font-size: 12px; align-items: start; }
.lc-ts-arg-row:first-of-type { border-top: 0; }
/* Arg rows override the shared name cell: wrap long names instead of
truncating them (the schema table keeps its one-line ellipsis). */
/* Overrides the shared name cell: arg names wrap instead of ellipsizing (the schema table keeps its one-line form). */
.lc-ts-arg-row .lc-ts-param-name { overflow: visible; text-overflow: unset; white-space: normal; overflow-wrap: anywhere; }
.lc-ts-arg-val { color: var(--dsw-alias-label-primary); font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; scrollbar-width: thin; }
.lc-ts-json { display: flex; flex-direction: column; gap: 4px; }
@@ -59,27 +46,10 @@
.lc-find-mark { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 25%, transparent); color: inherit; border-radius: 2px; }
.lc-find-mark-on { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 65%, transparent); }
.lc-ts-desc-md { padding: 4px 10px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; word-break: break-word; overflow-wrap: anywhere; min-width: 0; max-height: 320px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; }
/*
The shared markdown renderer sizes its root/headings for chat bubbles;
inside a detail card the base size sits one step ABOVE the raw text
(13px vs 12px — a proportional face at 13px matches the visual weight
of the raw view's 12px monospace) and the heading scale collapses to a
weight-only bump (specificity beats the renderer's one-class rules).
Inline code keeps the FULL size too — the renderer shrinks it to
0.875em (!important) and table code to 13px, both visibly off. Its
`font` shorthand also drags in a ~22px line-height, TALLER than the
13px/1.55 paragraph line (~20px), so a code chip wrapping onto the
next line sticks to the neighbor line's chip; pin the chip just under
the paragraph line (1.3em + 1px padding ≈ 19px) to reopen the gap.
*/
/* The shared renderer sizes for chat bubbles: inside a card the base sits one step ABOVE the raw text (13px proportional ≈ 12px mono) and headings collapse to a weight-only bump. Inline code keeps the full size too, because the renderer shrinks it to 0.875em and its `font` shorthand would drag in a line-height taller than the paragraph. */
.lc-ts-desc-md > div { font-size: 13px; line-height: 1.55; }
.lc-ts-desc-md div :is(h1, h2, h3, h4, h5, h6) { font-size: 13px; font-weight: 600; margin: 10px 0 4px; }
/*
Line policy: the renderer bakes soft breaks into paragraph text and pairs
every hard break's <br> with a '\n'. pre-line shows each newline exactly
once and hides the redundant <br>, so both spellings break exactly one
line while block separators collapse at the container's normal white-space.
*/
/* The renderer bakes soft breaks into paragraph text and pairs every hard break's `<br>` with a '\n'; `pre-line` shows each newline once and hides the redundant `<br>`. */
.lc-ts-desc-md div p { margin: 6px 0; white-space: pre-line; }
.lc-ts-desc-md div p br { display: none; }
.lc-ts-desc-md div pre { margin: 6px 0; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
+1 -3
View File
@@ -1,11 +1,9 @@
/* The event log: list rows and kind tint pills (events.tsx). */
.lc-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.lc-event-icon { width: 18px; text-align: center; color: var(--dsw-alias-state-warn-primary); }
.lc-event-icon.lc-event-inject { color: var(--color-purple-500); }
.lc-event-icon.lc-event-model { color: var(--dsw-alias-brand-primary); }
.lc-event-icon.lc-event-mode { color: var(--dsw-alias-label-secondary); }
/*
Kind chip: the event classification at a glance — a fixed tint per kind: inject adds (success), compaction/prune free (error/violet), model/mode switch (brand/secondary).
/** Kind chip: the event classification at a glance — a fixed tint per kind: inject adds (success), compaction/prune free (error/violet), model/mode switch (brand/secondary).
*/
.lc-kind { flex: none; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; white-space: nowrap; }
.lc-kind-inject { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); color: var(--dsw-alias-state-success-primary); }
+2 -13
View File
@@ -1,37 +1,27 @@
/* The File Activity card: purpose filter chips, meta strip, file rows and the expanded op log (fileCard.tsx). */
/*
---- File Activity card: the header chips double as purpose/form filters (the shared lc-gran toggle look with an ops count);
the meta strip carries the totals at the left and the count/latest sort toggle at the right; rows speak the browser's
element-row anatomy (chevron, fact chips, right-aligned time) and expand into a per-operation log on a tree-guide rail. ----
*/
.lc-fa-ctl, .lc-br-toolctl { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; }
.lc-fa-ctl .lc-gran { margin-left: 0; }
.lc-fa-n { margin-left: 4px; font-weight: 700; opacity: 0.75; }
.lc-fa-search, .lc-br-tool-search, .lc-find-input { flex: 1; min-width: 80px; font: inherit; font-size: 12px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; padding: 3px 8px; outline: none; }
.lc-fa-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 6px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
/* The sort toggle rides the meta strip's right edge — the shared .lc-gran's own auto margin does the push. */
.lc-fa-meta .lc-fa-sort { flex: none; }
/* The aggregate delta pill carries the card's one styled tip (the shared `.lc-tip` chrome) — it sits outside the scrolling list, so the bubble never clips. */
/* Sits outside the scrolling list, so the tip bubble never clips. */
.lc-fa-meta-delta { position: relative; }
.lc-fa-meta-tip { top: calc(100% + 6px); left: 0; max-width: 260px; white-space: normal; line-height: 1.5; }
/* An expanded file reads as one contained card: layer-2 fill inside a hairline frame. */
.lc-fa-item { border: 1px solid transparent; border-radius: 8px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-fa-item-on { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); }
.lc-fa-row { display: flex; align-items: center; gap: 7px; width: 100%; border: 0; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 6px 8px; cursor: pointer; text-align: left; border-radius: 7px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-fa-form { flex: none; width: 20px; height: 14px; display: flex; align-items: center; justify-content: center; font-size: 11px; opacity: 0.85; }
.lc-fa-lang { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 13px; padding: 0 2px; border-radius: 4px; font-size: 8px; font-weight: 700; line-height: 1; }
/* The chevron sits close to the icon: only half the row gap stays visible. */
.lc-fa-row .lc-br-chev { margin-right: -2px; }
.lc-fa-path { min-width: 0; word-break: break-all; }
.lc-fa-file { cursor: pointer; }
.lc-fa-path em { font-style: normal; color: var(--dsw-alias-label-secondary); }
/* Per-purpose count badges: pill chips, one fixed tint per purpose (read = brand, write = success, search = warn). */
.lc-fa-badge { flex: none; display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; padding: 1px 7px; border-radius: 999px; white-space: nowrap; }
.lc-fa-badge i { display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.lc-fa-b-read { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); }
.lc-fa-b-write { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent); }
.lc-fa-b-search { color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent); }
/* Kind chips wear their badge color in every state (plain colored text while idle, so the group reads at a glance); the active chip adds the filled tint. Hover keeps the kind color over the generic brighten rule. */
/* Kind chips keep their purpose color in every state; only the active chip adds the filled tint. */
.lc-fa-chip-read, .lc-fa-chip-read:hover { color: var(--dsw-alias-brand-primary); }
.lc-fa-chip-write, .lc-fa-chip-write:hover { color: var(--dsw-alias-state-success-primary); }
.lc-fa-chip-search, .lc-fa-chip-search:hover { color: var(--dsw-alias-state-warn-primary); }
@@ -42,7 +32,6 @@
.lc-fa-up { color: var(--dsw-alias-state-success-primary); }
.lc-fa-down { color: var(--dsw-alias-state-error-primary); }
.lc-fa-time { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; font-variant-numeric: tabular-nums; }
/* The expanded op log hangs on a tree-guide rail aligned under the row chevron. */
.lc-fa-ops { margin: 0 8px 8px 15px; padding: 2px 0 2px 10px; border-left: 2px solid var(--dsw-alias-border-l1); display: flex; flex-direction: column; gap: 1px; }
.lc-fa-op { display: flex; align-items: center; gap: 8px; min-width: 0; border: 0; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 6px; text-align: left; border-radius: 5px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-fa-op-link { cursor: pointer; }
+1 -2
View File
@@ -1,4 +1,3 @@
/* The assistant-message jump action (contextJump.tsx). */
/* The chat action-row jump button — mirrors the harness MessageIconActions `.action` chrome so it sits flush beside the shipped icons. */
/* Mirrors the harness MessageIconActions `.action` chrome so the button sits flush beside the shipped icons. */
.lc-jump { display: inline-flex; align-items: center; justify-content: center; width: calc(28px + var(--dsh-content-font-delta, 0px)); height: calc(28px + var(--dsh-content-font-delta, 0px)); padding: 6px; border: 0; border-radius: 28px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-jump svg { width: calc(16px + var(--dsh-content-font-delta, 0px)); height: calc(16px + var(--dsh-content-font-delta, 0px)); }
+2 -6
View File
@@ -1,21 +1,17 @@
/* The /context modal: centered dialog; fixed positioning escapes the composer anchor (contextModal.tsx). */
/* ---- /context modal: centered dialog; fixed positioning escapes the composer anchor ---- */
/* The mask token matches the harness Modal and the lightbox mask; the fallback covers dsh builds without the alias. */
/* Fixed positioning escapes the composer anchor; the card's percentage width resolves against the backdrop, so a docked mask clamps it inside the main column. */
.lc-modal-backdrop { position: fixed; inset: 0; z-index: 200; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.45)); display: flex; align-items: center; justify-content: center; }
/* The card's percentage width resolves against the backdrop, so a docked mask (sidebars both sides) clamps the card inside the main column. */
.lc-modal-card { width: min(720px, calc(100% - 48px)); max-height: min(82vh, 760px); overflow-y: auto; box-sizing: border-box; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); padding: 16px 18px 18px; color: var(--dsw-alias-label-primary); font-size: 13px; }
.lc-modal-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.lc-modal-title { font-weight: 600; font-size: 14px; }
.lc-modal-close { margin-left: auto; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/* ---- the baseline-gate dialog (upgradeGate.tsx): narrower card, version rows, one primary action ---- */
/* ---- the baseline-gate dialog (upgradeGate.tsx) ---- */
.lc-gate-card { width: min(440px, calc(100vw - 48px)); }
.lc-gate-body { color: var(--dsw-alias-label-secondary); line-height: 1.7; }
.lc-gate-versions { display: flex; gap: 24px; margin-top: 14px; }
.lc-gate-version { display: flex; flex-direction: column; gap: 2px; }
.lc-gate-version-label { font-size: 11px; color: var(--dsw-alias-label-secondary); }
.lc-gate-version-value { font-weight: 600; font-family: var(--dsw-font-family-mono, monospace); color: var(--dsw-alias-label-primary); }
/* The muted suffix beside the required version: recovers the body face and de-emphasizes against the mono value. */
.lc-gate-or-newer { margin-left: 6px; font-family: inherit; font-weight: 400; font-size: 11px; color: var(--dsw-alias-label-secondary); }
.lc-gate-actions { display: flex; justify-content: flex-end; margin-top: 16px; }
.lc-gate-ok { border: 0; border-radius: 6px; padding: 6px 16px; font-size: 12px; font-family: inherit; cursor: pointer; background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); transition: filter var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
+30 -192
View File
@@ -1,83 +1,24 @@
/*
* The Context Insights page: the plugin's first-level panel (the sidebar
* panel-list entry opens it in the shell's center column), a sibling of the
* shipped Plugins and Automation tasks pages. The chrome mirrors those
* pages: one scroll region whose content column spans the pane's own
* width and opens with the page heading — the title beside the scope cluster
* (the calendar picker and the range group, with the balance capsule left of
* it) — then the first row (a 1:1 pair: the
* KPI metrics band, the range's six figures three per row, beside the
* last-7-days usage chart's token/cost bar columns) over the aggregate
* stats row (Token Stats | Timing Stats, the range's sessions folded into
* the per-session tab's two donut cards) over a 3:7 body of insight column
* (the activity heatmap) beside the session column (search, chips, card
* grid).
*
* The page element is the `lc-ov` query container: every fold below keys
* off the pane's own width (the pane follows the shell's sidebar state, per
* the harness's container-query idiom). Inline-size containment makes the
* page the containing block for fixed descendants, so the page's hover tips
* CANNOT live in-tree — hoverTip.tsx portals the one bubble to <body> (the
* image lightbox's precedent in base.css).
*/
/* ---- the page chrome (the shipped first-level pages' own frame) ---- */
/* The page is the `lc-ov` query container; inline-size containment makes it the containing block for fixed descendants, so hoverTip.tsx portals its bubble to <body>. */
.lc-ov-page { display: flex; flex-direction: column; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; box-sizing: border-box; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 13px; container: lc-ov / inline-size; }
/* The one scroller: everything — heading, stats, body — scrolls as one. */
.lc-ov-pagescroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }
/* Full-bleed content column: no width cap — the dashboard's grids, legends,
and charts adapt to the pane (the shipped first-level pages keep their own
960px frame), with the shipped pages' clamp(24-48px) inline / 48px bottom
inset. */
/* No width cap: the shipped first-level pages frame at 960px, this page adapts to the pane. */
.lc-ov-pagecontent { padding: 0 clamp(24px, 4vw, 48px) 48px; }
/* The heading row: title beside the preferences chip, the balance capsule
(when it serves) and the range group, wrapping under width pressure. The
28px top inset belongs to the row, so the whole strip above the heading is
its own box. */
.lc-ov-pagehead { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding-top: 28px; margin-bottom: 24px; }
/* macOS hiddenInset titlebar: the shipped pages' heads carry the frame's top
clearance above their own 28px inset, so first-level pages align. */
/* macOS hiddenInset titlebar clearance, so first-level pages align with the frame. */
[data-platform='darwin'] .lc-ov-pagehead { padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); }
/* The first-level page title on the shipped pages' own step (20px/28px/500);
the theme carries no size token for it, so every first-level page states
the same literal. flex: 0 1 auto (it only shrinks) so the preferences
chip rides the title's right edge — the scope cluster's own auto margin
(`.lc-ov-headctl`) keeps the head's right side pinned. */
.lc-ov-pagetitle { flex: 0 1 auto; min-width: 0; margin: 0; font-size: 20px; line-height: 28px; font-weight: 500; }
/* The preferences entry: a quiet chip beside the title, cut like the balance
pill below it (layer-2 fill, l2 border, secondary label), with the long
"open plugin settings" hint on the hover title. */
.lc-ov-settings { flex: none; display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); white-space: nowrap; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/* The head's right cluster: the calendar picker beside the preset range group
(the balance capsule sits left of it, outside). One wrapper so the free space
is pushed once — two bare auto-margin siblings would split it and the cluster
would drift apart (the card-title wrapper idiom, base.css). */
/* One wrapper around the picker and range group, so the head keeps a single auto-margin push. */
.lc-ov-headctl { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lc-ov-settings:hover { border-color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-primary); }
/* The DeepSeek balance pill (balanceCapsule.tsx): a quiet chip between the
title and the range group, one link to the platform's usage console —
flex: none keeps the figure intact and the head's own wrap carries it to
the next row under pressure. */
.lc-ov-balance { flex: none; display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 2px 10px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); font-size: 12px; line-height: 18px; text-decoration: none; color: inherit; }
.lc-ov-balance:hover .lc-ov-balance-value { text-decoration: underline; }
.lc-ov-balance-label { color: var(--dsw-alias-label-secondary); white-space: nowrap; }
.lc-ov-balance-value { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
/* ---- the page's hover tip (hoverTip.tsx): one fixed bubble portaled to
<body> — containment-safe by construction. The shared `.lc-tip` chrome
supplies the face; these rules supply the fixed seat, the pre-line wrap
(labels carry \n rows), and the width cap. Opacity stays inline (the
measure-then-show two-phase), so no reveal class exists here. ---- */
.lc-hovertip { position: fixed; z-index: 300; max-width: min(320px, 50vw); white-space: pre-line; }
/* ---- the panel's first row: a 1:1 column pair — the KPI metrics band
(the range's six figures, three per row — the row pair is the tallened
first row) beside the last-7-days usage chart, whose card
stretches to the same height. The pair folds to one full-width column at
932px of container (the body's own fold point, below which a half-width
chart's seven day groups would crush); the KPI grid then spreads the
full width, and drops to 2×3 at 640px (phone), so no cell ever
ellipsizes its figure. ---- */
/* The pair folds to one column at 932px of container, below which a half-width chart's seven day groups crush. */
.lc-ov-first { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
.lc-ov-kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.lc-ov-kpi { align-items: center; padding: 8px; }
@@ -89,39 +30,19 @@
.lc-ov-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* ---- the first row's usage chart: seven day columns, each pairing a
billed-tokens bar (the heatmap's blue) over a priced-cost bar (green),
both folded off the merged daily ledger. The bars scale on their OWN
window maxima — a $2 week must not flatten under a 169M-token week — and
the plot is the card's flex remainder, so the card fills its half of the
first row whatever the KPI grid's wrapped height. A 4% floor keeps a
tiny day visible; a 0/unpriced figure draws no bar at all. Hairline
gridlines run at 0/25/50/75/100% of the peak, the 0 line solid as the
baseline, and each side carries a tick rail computed off the same maxima
(tokens left, cost right — the cost rail reads '—' while nothing
prices). ---- */
/* Both bars scale on their own window maximum: a $2 week must not flatten under a 169M-token week. */
.lc-ov-usage { flex: 1 1 auto; display: flex; flex-direction: column; margin-bottom: 0; min-width: 0; box-sizing: border-box; }
/* The plot is a 3×2 grid: tick rail | chart | tick rail, with the day strip
as row 2 of the chart's own column — the labels center under their columns
by construction, whatever the rails' width. The 6px gap doubles as the
rail gutter and the chart-to-labels breathing room; the card title's base
margin (and that gap) absorb the tick labels' half-height overhang past
the plot's top/bottom edges (each tick centers on its gridline). */
/* Grid columns: tick rail | chart | tick rail, with the day strip as row 2 of the chart column, so labels center under their own columns. */
.lc-ov-usage-plot { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 42px minmax(0, 1fr) 42px; grid-template-rows: minmax(56px, 1fr) auto; gap: 6px; }
.lc-ov-usage-rail-l { grid-area: 1 / 1; }
.lc-ov-usage-grid { grid-area: 1 / 2; position: relative; min-width: 0; display: flex; }
.lc-ov-usage-rail-r { grid-area: 1 / 3; }
/* The rails hold absolutely-positioned ticks (top = 100% peak, bottom = 0);
a fixed width keeps both sides symmetric and the columns' geometry stable
across tick widths ("169.2M" vs "$0.0075"). */
/* Fixed width keeps both rails symmetric across differing tick widths ("169.2M" vs "$0.0075"). */
.lc-ov-usage-rail { position: relative; width: 42px; font-size: 9px; line-height: 1.2; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-ov-usage-rail-l .lc-ov-usage-tick { right: 0; }
.lc-ov-usage-rail-r .lc-ov-usage-tick { left: 0; }
.lc-ov-usage-tick { position: absolute; transform: translateY(-50%); white-space: nowrap; }
/* The grid spans the bars' region only, so the gridlines never cross the
labels (they live in the plot's own row 2). The row template's 56px floor
keeps percentage-height bars alive on the folded single-column row, where
the card sizes to its own content. */
/* The grid spans the bar region only, so gridlines never cross the labels; the row template's 56px floor keeps percentage-height bars alive when the pair folds. */
.lc-ov-usage-line { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--dsw-alias-border-l2) 70%, transparent); }
.lc-ov-usage-line-0 { background: var(--dsw-alias-border-l2); }
.lc-ov-usage-cols { position: relative; flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 6px; }
@@ -130,14 +51,9 @@
.lc-ov-usage-bar { flex: none; width: 10px; border-radius: 3px 3px 0 0; }
.lc-ov-usage-tokens { background: color-mix(in srgb, var(--color-blue-500) 72%, transparent); }
.lc-ov-usage-cost { background: color-mix(in srgb, var(--color-green-500) 72%, transparent); }
/* Same flex geometry as .lc-ov-usage-cols, so every label centers under its
own column. */
.lc-ov-usage-days { grid-area: 2 / 2; display: flex; gap: 6px; }
.lc-ov-usage-day { flex: 1 1 0; min-width: 0; text-align: center; font-size: 9px; line-height: 1.2; color: var(--dsw-alias-label-secondary); white-space: nowrap; font-variant-numeric: tabular-nums; }
/* Under ~400px of container a full seven-label strip crushes: each column
is ~19px wide but an "MM-DD" label needs ~26px. Thin to every other label
instead — visibility (not display) keeps every slot in the columns'
geometry, so the shown labels stay centered under their own columns. */
/* Under ~400px a full seven-label strip crushes; `visibility` keeps every slot in the columns' geometry. */
@container lc-ov (max-width: 400px) {
.lc-ov-usage-day:nth-child(even) { visibility: hidden; }
}
@@ -145,26 +61,13 @@
.lc-ov-usage-swatch { flex: none; width: 10px; height: 8px; border-radius: 2px; }
.lc-ov-usage-key-label { font-size: 9px; color: var(--dsw-alias-label-secondary); }
/* ---- the aggregate stats row: Token Stats beside Timing Stats between the
KPI band and the 3:7 body. Two half-width cards; the pair folds to one
column at 700px of container — below the body's own 932px fold, where
a half (~340px) still fits the donut + one legend column, but narrower
halves would wrap the legend rows into slivers. ---- */
.lc-ov-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
/* The row's cards zero the .lc-card margin like the column's own, and take
the same border-box (the base .lc-card is content-box; the padding would
otherwise widen both tracks past the card). */
.lc-ov-stats > .lc-card { margin-bottom: 0; min-width: 0; box-sizing: border-box; }
@container lc-ov (max-width: 700px) {
.lc-ov-stats { grid-template-columns: minmax(0, 1fr); }
}
/* ---- the body: a 3:7 column pair — the insight column (the heat) beside
the session column. Neither column scrolls on its own — the page's
`.lc-ov-pagescroll` owns the one bar — and `align-items: start` keeps each
column at its natural height, so the heat card never stretches to the
paged grid's height. Under width pressure the pair folds to one full-width
column. ---- */
/* `align-items: start` keeps the heat card at its natural height instead of stretching to the paged grid. */
.lc-ov-body { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 7fr); gap: 10px; align-items: start; }
.lc-ov-left { min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.lc-ov-right { min-width: 0; }
@@ -172,33 +75,19 @@
.lc-ov-body { display: block; }
.lc-ov-left { margin-bottom: 12px; }
}
/* border-box keeps the card tracks true outer widths: under the harness's
content-box default the padding quietly adds ~30px per card. */
.lc-ov-left > .lc-card { margin-bottom: 0; min-width: 0; box-sizing: border-box; }
.lc-ov-heat-card { display: flex; flex-direction: column; }
/*
* The activity heatmap: fixed-size square cells (8 week columns ≈ a 200px
* block) centered in the card, so the card's height follows the 7 cell rows
* plus the legend line instead of the cells stretching to the card's width —
* the compact GitHub-widget read. Sunday-first, four depth steps, a month
* label over the column where each month begins.
*/
/* Fixed 20px cells centered in the card, so the card's height follows its 7 cell rows instead of the cells stretching to the card's width. */
.lc-heat-wrap { display: flex; flex-direction: column; flex: 1; }
.lc-heat { display: flex; gap: 8px; flex: 1; align-items: center; justify-content: center; }
/* Each column and the weekday gutter reserve the same 18px line above their
cells — the month labels' line — so the centered cell rows stay aligned. */
/* The columns and the weekday gutter reserve the same 18px line for the month labels, so the centered cell rows stay aligned. */
.lc-heat-wds, .lc-heat-col { padding-top: 18px; }
.lc-heat-wds { display: flex; flex-direction: column; gap: 3px; }
/* The gutter self-sizes to its widest label (星期一 vs Mon differ), the spans
stretch to share it; nowrap keeps a full name on one line. */
/* The gutter self-sizes to its widest weekday label, which the zh locale makes the widest. */
.lc-heat-wd { flex: none; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 9px; color: var(--dsw-alias-label-secondary); text-align: center; white-space: nowrap; }
.lc-heat-cols { display: flex; gap: 3px; }
/* relative anchors the label; absolute keeps its width off the 20px column
(a wider in-flow label would widen just the labeled columns and skew the
grid). top:0 rides the column's own 18px padding band, clear of the cells.
Labels only sit at month opens, ≥4 columns apart, so the nowrap overflow
past a 20px column never meets the next label. */
/* Absolute so a label's width stays off the 20px column and cannot skew the grid. */
.lc-heat-col { position: relative; display: flex; flex-direction: column; gap: 3px; }
.lc-heat-mon { position: absolute; top: 0; left: 0; font-size: 9px; line-height: 18px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
.lc-heat-cell { width: 20px; height: 20px; box-sizing: border-box; border: 0; border-radius: 20%; padding: 0; background: var(--dsw-alias-bg-layer-2); cursor: default; }
@@ -211,81 +100,45 @@ button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--col
.lc-heat-2 { background: color-mix(in srgb, var(--color-blue-500) 50%, transparent); }
.lc-heat-3 { background: color-mix(in srgb, var(--color-blue-500) 72%, transparent); }
.lc-heat-4 { background: color-mix(in srgb, var(--color-blue-500) 92%, transparent); }
/* The pinned day (the grid filter's source) keeps a solid ring in both themes. */
/* The pinned day: the grid filter's source. */
.lc-heat-on { box-shadow: 0 0 0 1.5px var(--color-blue-500); }
/* GitHub's Less→More key: right-aligned under the grid; the swatches reuse
the grid's own depth classes, so the key can never drift from the cells it
explains. They sit at HALF the grid's 20px, though — at cell size the key
reads as a second row of cells instead of a caption for them. The labels'
2px margins keep them off the swatches without loosening the scale's own
2px gap. Labels share the month label's secondary type. */
/* Swatches half the grid's cell size: at cell size the key reads as a second row of cells. */
.lc-heat-legend { display: flex; align-items: center; justify-content: flex-end; gap: 2px; padding-top: 6px; }
.lc-heat-legend .lc-heat-cell { width: 10px; height: 10px; }
.lc-heat-key { font-size: 9px; color: var(--dsw-alias-label-secondary); margin: 0 2px; }
/* ---- the skill card (the activity column's second card): one row per
scoped skill — a name-hashed color dot, the name, the load tally, the last
load's relative time, and a proportional hairline bar along the row's
bottom edge (share of the heaviest row's tally, so no ordering can push a
bar past the card edge). Rows are the column's drill-down: a click pins
the session list to the skill's loaders, and the pinned row wraps with its
detail into one unit carrying the ring. The name ellipsizes (its full
text rides the row's hover tip); the two figures hold a fixed right column
so the tally and the time never crush it. ---- */
/* Each bar is a share of the heaviest row's tally, so no ordering can push it past the card edge. */
.lc-ov-skills { display: flex; flex-direction: column; gap: 2px; }
/* The scope's totals sit under the title, pulled into the title's bottom
margin so the pair reads as one head; the name filter rides the same row,
folded to the right and wrapping below the totals under width pressure.
The sort toggle stays up in the title row's right corner. */
/* Pulled into the title's bottom margin so totals and title read as one head. */
.lc-ov-skills-sub { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: -4px 0 8px; }
.lc-ov-skills-sub-n { margin-left: 0; }
/* The filter keeps the session-list search's look; it shrinks before the
totals and, once wrapped, fills its own line so the input stays usable
on the narrowest pane. */
.lc-ov-skills-search { flex: 0 1 170px; margin-left: auto; }
.lc-ov-skill { position: relative; display: flex; align-items: baseline; gap: 7px; min-width: 0; width: 100%; border: 0; border-radius: 6px; padding: 2px 6px 4px; background: transparent; font: inherit; font-size: 12px; line-height: 1.7; color: inherit; text-align: left; cursor: pointer; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-ov-skill:hover { background: var(--dsw-alias-bg-layer-2); }
/* The pinned row and its detail read as one unit: the fill and the single
ring live on the wrapper (the ring follows the rounded corners), the parts
inside go quiet — no seam, no double outline. */
/* The pinned row and its detail read as one unit: the fill and the single ring live on the wrapper. */
.lc-ov-skill-unit { background: var(--dsw-alias-bg-layer-2); border-radius: 8px; box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-blue-500) 45%, transparent); }
.lc-ov-skill-unit .lc-ov-skill-detail { margin-top: 0; padding: 4px 6px 8px; background: transparent; }
.lc-ov-skill-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; align-self: center; }
.lc-ov-skill-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; color: var(--dsw-alias-label-primary); }
.lc-ov-skill-count { flex: none; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary); }
.lc-ov-skill-last { flex: none; min-width: 56px; text-align: right; font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
/* The share hairline: absolute off the row's bottom padding, so it never
moves the text baseline; the color-mix keeps it a wash under both themes.
The JS width subtracts the row's horizontal insets (6px + 6px), so 100%
stops at the content edge instead of poking through the pinned unit's ring. */
/* Absolute off the row's bottom padding, so the hairline never moves the text baseline; the JS width subtracts the row's 6px insets. */
.lc-ov-skill-bar { position: absolute; left: 6px; bottom: 1px; height: 2px; border-radius: 1px; opacity: 0.45; pointer-events: none; }
.lc-ov-skill-more { padding-top: 4px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
/* The skill hover card (a structured `.lc-tip` bubble — the page's plain
pre-line chrome cannot hold a hierarchy) and the pinned skill's detail
block under the rows share the head (dot + name; the card adds the origin
chip) and the clamped description; the card adds the stats/action footer,
the block the fact grid. The detail registers wrap an over-long name in
full — the list row's ellipsis is the only truncation. */
.lc-skilltip { position: fixed; z-index: 300; max-width: min(300px, 60vw); padding: 10px 12px; border-radius: 10px; white-space: normal; line-height: 1.5; }
.lc-skilld-head { display: flex; align-items: center; gap: 7px; }
.lc-skilld-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary); }
.lc-skilld-chip { flex: none; font-size: 10px; line-height: 1.5; color: var(--dsw-alias-label-secondary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 0 5px; white-space: nowrap; }
.lc-skilld-desc { margin: 6px 0 0; font-size: 11.5px; line-height: 1.55; color: var(--dsw-alias-label-secondary); overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.lc-skilld-path { margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; color: var(--dsw-alias-label-tertiary); }
/* The footer's two halves fold under width pressure (the bubble is portaled
to <body>, outside the lc-ov query container — flex-wrap self-adapts, no
container query reaches it): the stats hold one unbroken line, the hint
drops to its own right-aligned line. */
/* The bubble is portaled to <body>, outside the lc-ov container, so it wraps with flex instead of a container query. */
.lc-skilld-foot { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; margin-top: 8px; padding-top: 7px; border-top: 1px solid var(--dsw-alias-border-l2); font-size: 11px; color: var(--dsw-alias-label-secondary); }
.lc-skilld-foot > span:first-child { white-space: nowrap; }
.lc-skilld-hint { margin-left: auto; flex: none; color: var(--color-blue-500); white-space: nowrap; }
/* The pinned skill's detail: attached right under its row (a quiet inset
box), or detached at the card's bottom when the pinned row is out of the
visible scope. */
/* Sits under its pinned row, or detached at the card's bottom when that row is out of scope. */
.lc-ov-skill-detail { margin: 4px 0 2px; padding: 8px 10px 9px; background: var(--dsw-alias-bg-layer-2); border-radius: 8px; }
.lc-ov-skill-detail > :first-child { margin-top: 0; }
/* The facts: a label column against a value column — every fact named,
nothing floating. Values ellipsis; the path value is the copy button. */
/* Label column against value column; the path value is the copy button. */
.lc-skilld-grid { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 12px; margin-top: 7px; font-size: 11px; line-height: 1.6; }
.lc-skilld-k { color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
.lc-skilld-v { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary); }
@@ -294,18 +147,14 @@ button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--col
.lc-skilld-pathv svg { flex: none; color: var(--dsw-alias-label-tertiary); transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-skilld-pathv:hover .lc-skilld-pathv-text, .lc-skilld-pathv:hover svg { color: var(--dsw-alias-label-primary); }
/* ---- the session list head: title, count, day chip, search, sort ---- */
.lc-ov-list-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.lc-ov-list-title { font-weight: 600; white-space: nowrap; }
.lc-ov-list-count { color: var(--dsw-alias-label-secondary); font-size: 11px; font-variant-numeric: tabular-nums; background: var(--dsw-alias-bg-layer-2); border-radius: 8px; padding: 1px 7px; }
/* The day/skill pin chips truncate an over-long skill name instead of
bursting the list head. */
.lc-ov-day-chip { border: 1px solid color-mix(in srgb, var(--color-blue-500) 45%, transparent); color: var(--color-blue-500); background: color-mix(in srgb, var(--color-blue-500) 10%, transparent); border-radius: 999px; font-size: 11px; line-height: 1.4; padding: 1px 10px; cursor: pointer; font-family: inherit; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.lc-ov-search { flex: 1 1 150px; min-width: 110px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 6px; padding: 4px 10px; font: inherit; font-size: 12px; color: var(--dsw-alias-label-primary); outline: none; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-ov-search:focus { border-color: var(--dsw-alias-label-secondary); }
.lc-ov-search::placeholder { color: var(--dsw-alias-label-secondary); }
/* ---- the group filter chips (workspace groups + the ungrouped bucket) ---- */
.lc-ov-groups { display: flex; gap: 6px; flex-wrap: wrap; margin: -2px 0 10px; }
.lc-ov-chip { display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 999px; font: inherit; font-size: 11px; line-height: 1.5; padding: 2px 10px; cursor: pointer; max-width: 220px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-ov-chip:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-secondary); }
@@ -313,48 +162,37 @@ button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--col
.lc-ov-chip-n { font-variant-numeric: tabular-nums; opacity: 0.65; font-size: 10px; }
.lc-ov-chip-on .lc-ov-chip-n { opacity: 0.85; }
/* ---- the session card grid (3 columns at the panel's full width; auto-fill folds 3→2→1 under width pressure) ---- */
/* auto-fill folds the 3-column grid down as the pane narrows. */
.lc-ov-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; }
/* The grid track sizes every row to its tallest card, so a wrapped title stretches its whole row uniformly. */
/* The grid sizes every row to its tallest card. */
.lc-ov-session { display: flex; flex-direction: column; gap: 6px; text-align: left; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; padding: 10px 12px; cursor: pointer; font: inherit; color: inherit; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-ov-session:hover { border-color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-2); }
.lc-ov-session-current { border-color: color-mix(in srgb, var(--color-blue-500) 65%, transparent); }
.lc-ov-session-head { display: flex; align-items: flex-start; gap: 6px; min-width: 0; }
.lc-ov-running { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--color-green-500); }
/* The title wraps (2-line clamp keeps one long title from ballooning its row); the full text stays on the tooltip. */
/* The 2-line clamp keeps one long title from ballooning its row; the full text stays on the tooltip. */
.lc-ov-session-title { flex: 1 1 auto; min-width: 0; font-weight: 600; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; }
.lc-ov-current { flex: none; font-size: 10px; line-height: 1.5; color: var(--color-blue-500); border: 1px solid color-mix(in srgb, var(--color-blue-500) 45%, transparent); border-radius: 4px; padding: 0 4px; }
.lc-ov-session-times { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 1px; }
.lc-ov-session-time { color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; }
.lc-ov-session-created { color: var(--dsw-alias-label-secondary); font-size: 10px; line-height: 1.4; white-space: nowrap; font-variant-numeric: tabular-nums; }
/* The breadcrumb row: workspace group / project pill (the full path stays on the row's tooltip). */
.lc-ov-session-crumb { display: flex; align-items: center; gap: 5px; min-width: 0; font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; overflow: hidden; }
.lc-ov-crumb-group { flex: none; max-width: 55%; overflow: hidden; text-overflow: ellipsis; }
.lc-ov-crumb-sep { flex: none; opacity: 0.55; }
/* The project pill's fill mixes the label gray instead of layer-2: layer-2 is
the card's own white in the light theme (the heat grid's lesson), which
would leave the pill invisible on the card. */
/* Mixes the label gray instead of layer-2: layer-2 is the card's own white in the light theme, which would leave the pill invisible. */
.lc-ov-crumb-project { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 10px; line-height: 1.6; color: var(--dsw-alias-label-primary); background: color-mix(in srgb, var(--dsw-alias-label-secondary) 16%, transparent); border-radius: 999px; padding: 0 8px; }
.lc-ov-session-body { display: flex; gap: 14px; align-items: center; min-width: 0; }
.lc-ov-mini-stats { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
/* Definition-list rows: label and value pull to opposite card edges, so the
figures read as a right-hand column mirroring the header's right-aligned
times instead of clustering beside the donut. */
/* Label and value pull to opposite card edges, mirroring the header's right-aligned times. */
.lc-ov-mini-stat { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 11px; }
.lc-ov-mini-label { flex: none; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
.lc-ov-mini-value { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.lc-ov-session-empty { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1.5; padding: 6px 0; }
/* The last-user-message footer: a hairline-ruled caption anchoring the card's
tail — the label names the line, the message text follows in the primary
label color (the fold already bounds it; the full text stays on the tip).
The auto top margin pins it to the bottom edge when a taller sibling
stretches the row; the rule (not a bubble or a quote glyph) keeps it quiet
next to the donut row and survives both themes via the border token. */
/* `margin-top: auto` pins the caption to the bottom edge when a taller sibling stretches the row. */
.lc-ov-session-preview { display: flex; align-items: baseline; gap: 6px; margin-top: auto; padding-top: 6px; border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l2) 60%, transparent); min-width: 0; }
.lc-ov-session-preview-label { flex: none; color: var(--dsw-alias-label-secondary); font-size: 10px; }
.lc-ov-session-preview-text { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); font-size: 11px; line-height: 1.5; }
/* ---- the grid pager: one row under the card grid, only when it spans pages ---- */
.lc-ov-pager { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 12px; }
.lc-ov-pager-btn { flex: none; width: 26px; height: 22px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-primary); border-radius: 6px; font: inherit; line-height: 1; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-ov-pager-btn:hover:enabled { border-color: var(--dsw-alias-label-secondary); }
+5 -22
View File
@@ -1,49 +1,33 @@
/* The trend chart detail panel: step brief, metric rows and delta bars (requestDetail.tsx). */
.lc-detail { margin-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 12px; }
/* One wrapping row: title + event/tag chips + right-pinned tabular time, then uniform neutral metric pills —
same pill size/tabular figures, so a wrapped line still reads as a grid instead of scattered inline spans. */
.lc-detail-head { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; margin-bottom: 10px; color: var(--dsw-alias-label-secondary); }
.lc-detail-head b { font-size: 13px; color: var(--dsw-alias-label-primary); }
.lc-detail-time { margin-left: auto; font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.lc-detail-marker { color: var(--dsw-alias-state-warn-primary); font-size: 11px; background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); border-radius: 6px; padding: 1px 7px; }
.lc-detail-tag { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
.lc-detail-metric { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; padding: 2px 8px; border-radius: 6px; color: var(--dsw-alias-label-primary); background: color-mix(in srgb, var(--dsw-alias-label-secondary) 14%, transparent); }
/* Delta sign semantics mirror the harness diff blocks: growth on the success token, shrinkage on the error token. */
/* Sign semantics mirror the harness diff blocks. */
.lc-detail-metric-up { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); }
.lc-detail-metric-down { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 15%, transparent); }
/* auto-fit: two columns when the card is wide (unchanged), one below ~424px — a fixed 1fr 1fr cannot shrink under each row's min-content (~196px) and would spill. */
/* auto-fit instead of a fixed pair: a row's min-content is ~196px, below which two columns would spill. */
.lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 4px 24px; }
/* Step brief: the bar's semantic identity under the detail head — one FIXED-WIDTH tag + one line per row, so content starts at
one shared left edge. Every line speaks the Context browser's row anatomy (fact tag + preview text), so a brief line reads
exactly like the browser element its click reveals; every clickable target (whole row or input chip) uses the standard
interactive hover token + host duration/ease token transition. */
/* Fixed three-row lane (min-height = 3 × 24px rows + 2 × 2px gaps): the container exists even when no row resolves, so
hovering around the trend chart never changes the detail panel's height. Rows appear INSIDE the lane; blanks degrade. */
/* A fixed three-row lane: the container exists even when no row resolves, so hovering the trend chart never changes the detail panel's height. */
.lc-brief { display: flex; flex-direction: column; gap: 2px; margin: -4px 0 10px; min-height: 76px; }
/* Input chips wrap onto a second line instead of forcing the row past the card edge (each chip's internal tag is flex-none). */
.lc-brief-row { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 3px 6px; margin-left: -6px; margin-right: -6px; border-radius: 6px; border: 0; background: transparent; font-family: inherit; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-primary); text-align: left; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/* The link class marks a hoverable ROW (buttons and the chip-carrying inputs row alike); the pointer cursor belongs only to the
actual click targets — button rows and individual chips. */
/* The link class marks a hoverable row; the pointer cursor belongs only to real click targets. */
button.lc-brief-row-link { cursor: pointer; }
.lc-brief-tag { flex: none; box-sizing: border-box; min-width: 56px; text-align: center; font-size: 11px; padding: 0 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); position: relative; }
/* The tag's explanation bubble — the shared `.lc-tip` chrome: instant styled reveal on tag hover (a `group-hover/tip` variant), never intercepts the pointer. */
.lc-brief-tip { bottom: calc(100% + 6px); left: 0; max-width: 240px; white-space: normal; text-align: left; line-height: 1.5; font-weight: 400; }
.lc-brief-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The empty-state In row's placeholder: same anatomy as the preview text, muted. */
.lc-brief-empty { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); }
/* Standalone fact chip on the opener/reply rows (call breadcrumb, image count) — the row kind tag's look, content-sized. */
.lc-brief-fact { flex: none; box-sizing: border-box; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; padding: 0 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); }
/* Input chips: the name tag is flex-none so it always survives truncation — only the preview text ellipsizes. */
.lc-brief-chip { display: inline-flex; align-items: center; gap: 5px; max-width: 260px; min-width: 0; overflow: hidden; font-size: 12px; padding: 0 7px; border-radius: 5px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-primary); transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/* The reply row's single chip grows to the row's width, so a long reply text reads as far as the card allows. */
.lc-brief-chip-grow { flex: 1; max-width: none; }
.lc-brief-chip-tag { flex: none; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; padding: 0 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); }
.lc-brief-chip-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-brief-chip-link { cursor: pointer; }
.lc-brief-more { flex: none; font-size: 11px; color: var(--dsw-alias-label-secondary); }
.lc-detail-row { display: flex; align-items: center; gap: 8px; border-radius: 4px; /* Padding + matching negative margin: the highlight paints past the text while the layout stays put. */ padding: 1px 4px; margin: -1px -4px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/* Category hover-link: the shared hover lights the matching detail row — same pairing as the legend chips and the
browser rows (tinted background, bold label, ring on the color dot). */
/* Category hover-link: the shared hover lights the matching detail row. */
.lc-detail-row i { transition: box-shadow var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-detail-row-on { background: var(--dsw-alias-interactive-bg-hover); }
.lc-detail-row-on i { box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); }
@@ -51,7 +35,6 @@ button.lc-brief-row-link { cursor: pointer; }
.lc-detail-label { min-width: 70px; white-space: nowrap; color: var(--dsw-alias-label-secondary); }
.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--color-neutral-500) 18%, transparent); overflow: hidden; display: block; position: relative; }
.lc-bar-fill { display: block; height: 100%; border-radius: 3px; }
/* Delta rows: a hairline zero at the middle, growth fills right of it, shrinkage left. */
.lc-bar-zero { position: absolute; left: 50%; top: 0; bottom: 0; border-left: 1px solid color-mix(in srgb, var(--color-neutral-500) 45%, transparent); }
.lc-bar-fill-up, .lc-bar-fill-down { position: absolute; top: 0; height: 100%; display: block; }
.lc-bar-fill-up { left: 50%; border-radius: 0 3px 3px 0; }
+1 -2
View File
@@ -1,5 +1,4 @@
/* The preference card on the Plugins page (settingsCard.tsx): the page draws
the section chrome, so the rows render flat inside their own block. */
/* The Plugins page draws the section chrome; these rows render flat inside its block. */
.lc-settings-prefs { padding: 4px 0; }
.lc-settings-row { display: flex; align-items: center; gap: 8px; padding: 8px 0; }
.lc-settings-label { flex: 1; min-width: 0; font-size: 14px; color: var(--dsw-alias-label-primary); }
+4 -19
View File
@@ -1,34 +1,19 @@
/* The composition stacked bar with occupied-window frame, reserve band, legend chips and hover tip (stackedBar.tsx). */
.lc-stacked-wrap { position: relative; width: 100%; }
.lc-stacked { display: flex; width: 100%; border-radius: 5px; overflow: hidden; background: color-mix(in srgb, var(--color-neutral-500) 18%, transparent); position: relative; }
/*
Hover reference frame around the OCCUPIED region of the composition bar: a solid box from the left edge to the used/window boundary so the legend/tooltip 'share of used' percentages visibly map to the boxed part; pointer-events: none keeps hover on the segments/free; the free track echoes it dashed so the two read as one system.
*/
/* Boxes the used/window boundary the legend's "share of used" percentages refer to; `pointer-events: none` keeps hover on the segments. */
.lc-occupied-box { position: absolute; top: 0; bottom: 0; left: 0; border: 2px solid var(--dsw-alias-label-tertiary); border-radius: 5px; box-sizing: border-box; pointer-events: none; opacity: 0; box-shadow: 0 0 0 1px var(--dsw-alias-bg-layer-2); transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-occupied-box-on { opacity: 1; }
/* The chart's hover tip rides the shared `.lc-tip` chrome — narrower padding, centered on the bar, under the legend's z-order. */
.lc-bar-tip { bottom: calc(100% + 6px); transform: translateX(-50%); z-index: 5; white-space: nowrap; padding: 3px 8px; }
.lc-bar-tip-on { opacity: 1; }
.lc-stacked > div { height: 100%; }
/* Pickable segments (the browser's DNA bands open their accordion row on click). */
.lc-stacked-seg-pick { cursor: pointer; }
.lc-stacked-seg { transition: filter var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-stacked-free { box-sizing: border-box; transition: box-shadow var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/*
Entrance grow: the occupied segments and the free track scaleX-open from their own left edge (transform,
not width, so the layout never thrashes), staggered in layout order so the strip builds left to right;
the 100% keyframe is omitted — CSS lands on the elements' natural transform. `backwards` keeps each piece
collapsed until its turn; runs once on mount, so live hover/width updates never replay it. The component
caps `--lc-i`, so a long DNA band list still settles in about a second. The reserve band stays
unanimated — as an overlay its growth would smear the stripes, and it reads as chrome, not data.
*/
/* Entrance grow: transform, not width, so the layout never thrashes; runs once on mount, so live hover and width updates never replay it. The reserve band stays unanimated — as an overlay its growth would smear the stripes. */
.lc-stacked-seg, .lc-stacked-free { transform-origin: 0 50%; }
/* The grow-in sweep rides the `animate-lc-stacked-in` utility (theme token: 0.4s sweep, staggered by the
element's --lc-i slot, `motion-reduce:animate-none` for the reduced-motion guard). */
/* The sweep rides `animate-lc-stacked-in`, staggered by each element's `--lc-i` and disabled under `motion-reduce`. */
.lc-stacked-seg-on { filter: brightness(1.18); }
/*
Free track mirrors the occupied frame as a 2px DASHED box — border, not the old inset shadow, so the dash pattern renders (box-shadow can't dash); the base box-sizing keeps the width from shifting. Reserve band: rightmost (1−ratio) of the window, striped 'headroom' above the track/segments, owning the pointer so its own hover explanation shows.
*/
/* Dashed border, not box-shadow: a shadow cannot dash. The reserve band owns the pointer so its own hover explanation shows. */
.lc-reserve { position: absolute; top: 0; bottom: 0; z-index: 1; pointer-events: auto; cursor: help; background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--dsw-alias-state-warn-primary) 24%, transparent) 0 5px, transparent 5px 10px); }
.lc-stacked-free-on { border: 2px dashed var(--dsw-alias-label-secondary); border-radius: 3px; }
.lc-stacked-dim .lc-stacked-seg { opacity: 0.35; }
+22 -124
View File
@@ -1,88 +1,42 @@
/*
* The head band's stat cards — the context/plugin row and the token/timing
* donut row: the context card's count grid with the cost cell's styled tip,
* the composition overview's big number, and the cards' shared anatomy
* — section heading, donut, slice list, plugin-info rows.
*/
.lc-stat { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 6px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.lc-stat-label { color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lc-stat-value { color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; }
.lc-stat-sub { color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/*
Stats cell tooltip (cost cell): styled DOM bubble because native `title` can't hold the multi-row price breakdown; a '?' signals the affordance and hovering anywhere on the cell reveals the bubble below (the cell is the tip's position frame). Chrome rides the shared `.lc-tip`; the reveal and the badge's brighten ride `group-hover/tip` variants (`group/tip` on the cell).
*/
/* A DOM bubble, not `title`: native tooltips cannot hold the multi-row price breakdown. Hovering anywhere on the cell reveals it. */
.lc-stat-tipped { position: relative; }
.lc-stat-q { display: inline-block; margin-left: 4px; width: 11px; height: 11px; line-height: 11px; border-radius: 50%; text-align: center; font-size: 9px; font-weight: 700; font-style: normal; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); cursor: help; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-stat-tip { top: calc(100% + 6px); left: 0; max-width: 280px; line-height: 1.5; font-weight: 400; }
.lc-stat-tip-prices { display: block; margin-top: 5px; border-top: 1px dashed var(--dsw-alias-border-l1); padding-top: 5px; }
.lc-stat-tip-head { display: block; font-weight: 600; }
/* The donut cards' right-corner rate figure (statsTiming.tsx's decode TPS,
statsTokens.tsx's session cache hit): plain text pinned to the title row's
right end, regular weight against the 600 title — no pill chrome; the
native `title` tooltip explains it on hover. `margin-left: auto` parks it
at the row's end; the title row's own wrap carries it below the title
under pressure. */
/* Plain text pinned to the title row's right end, regular weight against the 600 title; the native `title` explains it. */
.lc-card-rate { flex: none; margin-left: auto; font-weight: 400; font-size: 12px; line-height: 18px; font-variant-numeric: tabular-nums; cursor: default; }
.lc-stat-tip-row { display: block; margin-top: 2px; }
/* One billed model's block: the four bucket rates as label/price rows, the
listing line beneath naming the registry face the rates resolved from. */
.lc-stat-tip-band { display: flex; justify-content: space-between; gap: 2rem; }
.lc-stat-tip-band i { font-style: normal; }
.lc-stat-tip-by { display: block; margin-top: 3px; color: var(--dsw-alias-label-secondary); }
.lc-card-sub { font-weight: 400; color: var(--dsw-alias-label-secondary); font-size: 12px; margin-left: auto; }
/* The occupancy reading row: the one-decimal percent (currentComposition.tsx)
is wider than the integer it replaced, so the row wraps — the percent block
drops to its own right-aligned line below the width floor instead of
spilling past the card edge. */
/* The one-decimal percent is wider than the integer it replaced; the row wraps so the percent block drops to its own line instead of spilling past the card edge. */
.lc-overview-num { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.lc-overview-num > b { font-size: 20px; }
.lc-overview-num span { color: var(--dsw-alias-label-secondary); }
.lc-overview-pct { margin-left: auto; font-size: 11px; }
.lc-overview-pct b { font-size: 20px; color: var(--dsw-alias-label-primary); margin-right: 4px; }
/*
---- Stats cards' shared anatomy: a small section heading, the donut+list
row, and the token card's cost line (the '.lc-tip' bubble pattern). ----
*/
/* The ring hugs the left edge; the legend fills the rest of the card with its quantity and share columns
right-aligned. The donut cards stretch the donut+legend row over their full height (`flex: 1` below), so
the head row's extra space sits around the chart instead of pooling under it; the row's own flex layout
and its 320px/240px folds ride utilities on the elements (statsTokens.tsx / statsTiming.tsx /
sliceList.tsx / donut.tsx). */
.lc-col-donut { display: flex; flex-direction: column; }
.lc-col-donut .lc-donut-row { flex: 1; }
.lc-donut { position: relative; flex: none; }
.lc-donut svg { display: block; }
.lc-donut-track { stroke: color-mix(in srgb, var(--color-neutral-500) 18%, transparent); }
/* The legend row ↔ segment hover link: hovering either side dims the ring's
other slices to 0.35 (the stacked bars' idiom) while the active one stays
solid; the center figures stay at rest. */
/* Legend-row ↔ segment hover link: hovering either side dims the other slices to 0.35 while the active one stays solid. */
.lc-donut-seg { transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/* The entrance sweep rides the `animate-lc-donut-in` utility (theme token: 0.45s sweep, staggered by the
element's --lc-i slot, `motion-reduce:animate-none` for the reduced-motion guard). */
/* The entrance sweep rides `animate-lc-donut-in`. */
.lc-donut-dim .lc-donut-seg { opacity: 0.35; }
.lc-donut-dim .lc-donut-seg-on { opacity: 1; }
.lc-donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; pointer-events: none; text-align: center; padding: 0 7px; box-sizing: border-box; }
.lc-donut-center b { line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.lc-donut-center span { color: var(--dsw-alias-label-secondary); white-space: nowrap; }
/*
The slice rows — the donut's legend with numbers, no bars, two tiers per
row: the primary line pairs the color dot + 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. The name takes the whole primary line and only ellipsizes as
a last resort (the full text stays on the row's `title`); a zero-figure row
dims — the ring already carries the share.
The rows flow into as many ≥150px columns as the card offers (the
composition legend's own auto-fill idiom, floored at the container itself
so a narrower legend never clips its share column): one in a narrow pane,
two once the legend reaches 308px (the Insights page's ~440px stats cards
ride this), more on ultrawide or the page's folded single-column row — so
the classic size reads in the same paired order as the composition card
below.
*/
/* Two tiers per row: the primary line pairs dot + name with the share, the secondary drops the absolute quantity under the name. The name ellipsizes only as a last resort (full text on the row's `title`); a zero-figure row dims because the ring already carries the share. auto-fill holds the share column readable down to the container width. */
.lc-sl { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr)); gap: 4px 8px; min-width: 0; overflow: hidden; }
/* The pill tint spreads 6px past the text column (negative margins) so the
hovered row reads as one lit surface without shifting the columns. */
/* The pill tint spreads 6px past the text column (negative margins) without shifting the columns. */
.lc-sl-row { display: flex; flex-direction: column; gap: 1px; font-size: 12px; min-width: 0; border-radius: 6px; padding: 1px 6px; margin: 0 -6px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-sl-row-on { background: var(--dsw-alias-interactive-bg-hover); }
.lc-sl-row-dim { opacity: 0.55; }
@@ -92,122 +46,69 @@
.lc-sl-pct { flex: none; min-width: 30px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.lc-sl-sub { margin-left: 14px; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1.3; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lc-pi-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
/* The settings-entry row is a button wearing the link-row chrome — reset the button chrome the shared row style doesn't cover. */
/* A button wearing the link-row chrome. */
.lc-pi-row-btn { appearance: none; border: 0; background: none; font: inherit; color: inherit; text-align: left; padding: 0; cursor: pointer; width: 100%; }
.lc-pi-row { display: flex; flex-direction: row; justify-content: space-between; align-items: baseline; gap: 12px; min-width: 0; text-decoration: none; color: inherit; }
.lc-pi-hint { text-decoration: none; }
/*
The update notice takes its own line under the version instead of trailing it.
Sharing the line left the value box overflowing by 29-44px at the card's 240px
floor (measured, English labels), and the notice sat last in that box, so the
ellipsis always ate it first — ~15px of a 52px chip was all that survived, at
every version string tried. A block-level chip drops below instead, leaving the
version its own ellipsis on the first line.
*/
/* A block-level chip drops below the version instead of trailing it: sharing the line overflowed the value box at the card's 240px floor, and the ellipsis ate the notice first. */
.lc-pi-update { display: block; color: var(--dsw-alias-state-warn-primary); font-size: 11px; margin-top: 1px; white-space: nowrap; }
.lc-pi-label { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The leading site mark rides inline so the row's baseline alignment keeps the label text on the value's baseline. */
.lc-pi-labelicon { display: inline-block; vertical-align: -2px; margin-right: 4px; }
/*
min-width: 0 lets the value shrink inside the flex row and truncate with
an ellipsis; without it a narrow card pushes the value over the label.
*/
/* min-width: 0 lets the value shrink and ellipsize inside the flex row; without it a narrow card pushes it over the label. */
.lc-pi-value { min-width: 0; color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
/*
---- Context stats flow (statsContext.tsx): the session as a flow diagram.
The two source nodes (left) feed the session node, which drains into the two
effect nodes (right); measured bezier ribbons connect them — the component
measures the node boxes and flips the geometry at the same width the
container query below folds the three zones into a top→bottom stack. ----
*/
.lc-flow { position: relative; display: flex; align-items: stretch; }
.lc-flow-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
/* Two layers per link: a wide, near-transparent ribbon carries the Sankey
mass, and a dashed hairline of the same hue travels along it — the agent
network's flow pulse (`animate-lc-agent-flow`; the element sets
`motion-reduce:animate-none`) — so the direction of travel reads without
arrowheads. The link color rides the `stroke` attribute per element. */
/* Two layers per link: a wide near-transparent ribbon carries the mass, and a dashed hairline travels along it so the direction reads without arrowheads. */
.lc-flow-ribbon { fill: none; stroke-width: 14px; stroke-opacity: 0.13; stroke-linecap: round; }
.lc-flow-dash { fill: none; stroke-width: 1.5px; stroke-opacity: 0.55; stroke-linecap: round; stroke-dasharray: 2 7; }
/* The three zones sit above the connector overlay; the mid zone's side
margins are the horizontal gutters the ribbons travel through. */
/* The zones sit above the connector overlay; the mid zone's side margins are the ribbons' gutters. */
.lc-flow-col { position: relative; z-index: 1; display: flex; flex-direction: column; justify-content: center; gap: 8px; flex: 1 1 0; min-width: 0; }
.lc-flow-mid { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; margin: 0 30px; }
.lc-flow-node { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 10px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* A corner card's header: the title left, the bold total right (tabular so a
ticking count never jiggles the row). */
/* Tabular figures so a ticking count never jiggles the row. */
.lc-flow-head { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
/* The clickable cost header: the same row as an anchor opening the models.dev
listing in a new tab — reset the link chrome so it reads as the text it
visually is; the label's brighten is the only affordance. */
/* Link chrome reset so the clickable cost header reads as the text it visually is. */
a.lc-flow-head { color: inherit; text-decoration: none; cursor: pointer; }
a.lc-flow-head:hover .lc-flow-label { color: var(--dsw-alias-label-primary); }
.lc-flow-label { color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lc-flow-total { margin-left: auto; flex: none; color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
/* The pills: a wrapping breadcrumb of rounded chips under the header, each a
muted label with a bold tally. A zero pill dims (the card's total already
reads); a tinted pill (the events card's kind colors) keeps its tally on
the tint's own hue. */
/* A zero pill dims; a tinted pill keeps its tally on the tint's own hue. */
.lc-flow-pills { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.lc-flow-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.lc-flow-pill-label { min-width: 0; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
.lc-flow-pill b { font-weight: 700; color: var(--dsw-alias-label-primary); }
.lc-flow-pill-tint b { color: inherit; }
.lc-flow-pill-dim { opacity: 0.45; }
/* The Agent Team ledger's rows: the agent scope's name left, its
tokens/cost pair pinned right — one row per scope under the family header. */
.lc-flow-rows { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
.lc-flow-row { display: flex; align-items: baseline; gap: 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); min-width: 0; }
.lc-flow-row-label { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lc-flow-pair { margin-left: auto; flex: none; display: inline-flex; align-items: baseline; gap: 5px; font-variant-numeric: tabular-nums; }
.lc-flow-pair b { font-weight: 700; color: var(--dsw-alias-label-primary); }
.lc-flow-pair i { font-style: normal; opacity: 0.55; }
/* The Agent Network team card doubles as a jump to its namesake at the tab's
foot: pointer plus the stat cell's anchor-hover edge (statsContext.tsx —
the price link inside keeps its own behavior and never scrolls). */
/* Doubles as a jump to the Agent Network card at the tab's foot; the price link inside keeps its own behavior. */
.lc-flow-team { cursor: pointer; }
.lc-flow-team:hover { border-color: var(--dsw-alias-label-secondary); }
/* The session node: the diagram's pivot — raised surface, brand-tinted edge
and halo, two figure rows centered under the label (turns/steps above
skill loads/answers), one even vertical rhythm throughout. */
.lc-flow-self { background: var(--dsw-alias-bg-layer-1); border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 40%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary) 8%, transparent); padding: 8px 14px; align-items: center; gap: 6px; }
.lc-flow-self-stats { display: flex; align-self: stretch; gap: 16px; }
/* Each figure cell takes half its row and centers within — the two rows share
one column pair no matter how wide a label runs (技能加载 vs 轮次). */
/* Each figure cell takes half its row and centers within, so both rows share one column pair however wide a label runs in either locale. */
.lc-flow-kv { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; }
/* The second-row figures are jumps into the Context Browser: pointer plus a
soft hover wash and label brightening (the team card's affordance). */
/* Second-row figures jump into the Context Browser. */
.lc-flow-kv-btn { cursor: pointer; border-radius: 6px; }
.lc-flow-kv-btn:hover { background: var(--dsw-alias-bg-layer-2); }
.lc-flow-kv-btn:hover i { color: var(--dsw-alias-label-primary); }
.lc-flow-kv-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
.lc-flow-kv b { font-size: 17px; font-weight: 700; line-height: 1.2; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
.lc-flow-kv i { font-style: normal; font-size: 11px; color: var(--dsw-alias-label-secondary); }
/* Right-column tips open leftward so the wide bubble never spills past the card edge. */
.lc-flow .lc-flow-col-r .lc-stat-tip { left: auto; right: 0; }
/* The narrow fold — paired with the component's FLOW_FOLD_PX (the container's
content box is exactly the flow root's width): the zones stack sources →
session → effects and the ribbons run top to bottom; the side nodes pair up
on one row and the session node centers between the two ribbon gutters. */
/* The fold width is mirrored as the component's FLOW_FOLD_PX. */
@container lc-card (max-width: 559px) {
.lc-flow { flex-direction: column; }
.lc-flow-col { flex-direction: row; align-items: stretch; }
.lc-flow-col > .lc-flow-node { flex: 1 1 0; }
.lc-flow-mid { margin: 26px 0; }
}
/*
The donut row's 320px gap fold and 240px stack still ride `@max-[…]/lc-card`
utilities — statsTokens.tsx / statsTiming.tsx / sliceList.tsx / donut.tsx.
*/
/*
---- Timing card's session-time strip (timingStrip.tsx): the fold's painted
spans packed gapless in occurrence order — each band's width is its share of
the cumulative active time, idle time takes no track. The bands sit
absolutely positioned at their cumulative percent offsets; the neutral track
only reads through sub-pixel rounding seams and during the grow-in. Hover
chrome mirrors the stacked bar: dim rest, lit kind.
The grow-in rides `.lc-tstrip-fill` — ONE scaled wrapper around the bands — so a long session's thousands
of bands cost a single composited transform instead of one animation (and one layer) each.
/** The bands sit absolutely at their cumulative percent offsets; the neutral track reads only through sub-pixel seams and during the grow-in.
* The grow-in rides ONE scaled wrapper around the bands, so a long session's thousands of bands cost a single composited transform instead of one animation and layer each.
*/
.lc-tstrip { margin-top: 10px; }
.lc-tstrip-wrap { position: relative; }
@@ -217,10 +118,7 @@ a.lc-flow-head:hover .lc-flow-label { color: var(--dsw-alias-label-primary); }
.lc-tstrip-seg-on { filter: brightness(1.18); }
.lc-tstrip-dim .lc-tstrip-seg { opacity: 0.35; }
.lc-tstrip-dim .lc-tstrip-seg-on { opacity: 1; }
/*
The axis: tick labels centered under hairline marks at the fixed quartiles,
the ends pinned outward — 0 hugs the left edge, the final active time the
right — so width pressure squeezes the interior labels, never the endpoints. */
/* The ends pin outward — first hugs the left edge, last the right — so width pressure squeezes the interior labels instead of the endpoints. */
.lc-tstrip-axis { position: relative; height: 13px; margin-top: 5px; font-size: 10px; line-height: 13px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-tstrip-tick { position: absolute; transform: translateX(-50%); white-space: nowrap; }
.lc-tstrip-tick::before { content: ''; position: absolute; left: 50%; top: -4px; width: 1px; height: 3px; background: var(--dsw-alias-border-l1); }
+18 -54
View File
@@ -1,54 +1,26 @@
/*
* Tailwind entry: compiles (tsdown.config.ts, @tailwindcss/node) the utilities
* carried by the plugin's JSX class lists — box layout, flex/grid, spacing,
* sizing, positioning, overflow, the @container responsive variants, and the
* pure `:hover`/`:focus` pseudo-state styling (hover/focus/group-hover
* variants; Tailwind media-guards hover behind `(hover: hover)`), and the
* animations (`animate-*` utilities over the theme tokens below, with
* `motion-reduce:animate-none` where the sheets had reduced-motion guards).
* The sibling sheets keep everything the variants cannot own here: the
* harness's `--dsw-*` color/typography chrome, the JS-state class systems
* (the lit/dim/selected markers, several of which double as test anchors and
* DOM query hooks), their contextual overrides, and the element transitions —
* those follow the harness motion tokens (`--ds-transition-duration`/
* `--ds-ease-in-out`, host-overridable) over exact per-element property
* lists, where Tailwind's preset durations/easings would freeze the values
* and its property bundles would transition more than the design intends.
* Tailwind entry: tsdown.config.ts compiles the utilities carried by the plugin's JSX class lists.
* The sibling sheets keep what the variants cannot own here: the harness's `--dsw-*` chrome, the
* JS-state class systems (several of which double as test anchors), their contextual overrides, and
* the element transitions, which follow the harness motion tokens over exact per-element property
* lists — Tailwind's preset durations would freeze those values.
*
* Deliberate deviations from a stock Tailwind setup, all for cohabitation
* with the harness host page:
* - No preflight and no theme import: a reset would re-skin host chrome. The
* palette below is a hand-picked SUBSET declared here (not the default
* theme import) so the plugin's chart/accent colors are the standard
* Tailwind tokens, shared by the sheets, the TS data layer and utilities.
* - `--spacing: 4px` instead of 0.25rem: the plugin's spacing values are px
* (4/6/7/14…), and anchoring the scale to px keeps every utility at its
* designed size regardless of the host's root font size.
* - Utilities compile UNLAYERED (plain `@tailwind utilities`, no @layer):
* these sheets are injected as unlayered <style> tags today, and a layered
* utility would lose to ANY unlayered host rule — the inverse of the
* cascade the hand-written sheets rely on. The sheet imports FIRST in
* client/index.ts, so the sibling sheets still win same-specificity ties.
* - Generic utility class names are safe: dsh styles itself through CSS
* modules (hashed names) and `--ds-*`/`--dsw-*` tokens, so no host rule
* collides with `flex`/`gap-2`/… (the flat `lc-*` namespace stays for the
* plugin's own chrome classes and as test anchors).
* Deliberate deviations from a stock Tailwind setup, all for cohabitation with the harness host page:
* - No preflight and no theme import: a reset would re-skin host chrome.
* - `--spacing: 4px` instead of 0.25rem, so utilities keep their designed px sizes regardless of the
* host's root font size.
* - Utilities compile UNLAYERED: a layered utility would lose to ANY unlayered host rule. The sheet
* imports FIRST in client/index.ts, so the sibling sheets still win same-specificity ties.
* - dsh styles itself through CSS modules and `--ds-*`/`--dsw-*` tokens, so generic names like
* `flex`/`gap-2` cannot collide; the flat `lc-*` namespace is the plugin's own chrome.
*
* The tailwindcss / @tailwindcss/node / @tailwindcss/oxide devDependencies
* are pinned to one exact version (as is lightningcss): the config process
* loads this compiler alongside lightningcss's native binding, and two
* copies of any native set in that realm break the napi calls.
* tailwindcss / @tailwindcss/node / @tailwindcss/oxide are pinned to one exact version (as is
* lightningcss): the config process loads this compiler alongside lightningcss's native binding, and
* two copies of any native set in that realm break the napi calls.
*/
@theme static {
--spacing: 4px;
/*
* The plugin's accent palette as standard Tailwind color tokens, values
* lifted verbatim from this pinned tailwindcss's theme.css. `static` is
* load-bearing: the sibling sheets and the TS color fields consume these
* via plain var()/inline styles — never a compiled utility — and Tailwind's
* used-only emission would never see them. dsh itself defines no
* `--color-*` variables, so the canonical names stay collision-free.
*/
/* `static` is load-bearing: the sibling sheets and the TS color fields consume these via plain var(), which Tailwind's used-only emission would otherwise drop. dsh defines no `--color-*`, so the canonical names stay collision-free. */
--color-red-500: oklch(63.7% 0.237 25.331);
--color-orange-500: oklch(70.5% 0.213 47.604);
--color-amber-500: oklch(76.9% 0.188 70.08);
@@ -61,14 +33,7 @@
--color-pink-500: oklch(65.6% 0.241 354.308);
--color-slate-400: oklch(70.4% 0.04 256.788);
--color-neutral-500: oklch(55.6% 0 none);
/*
* The plugin's entrance/state animations as theme tokens — keyframes live
* here and emit only when their animate-* utility is used. Entrance delays
* ride the element-scoped --lc-i stagger slot inside the shorthand; the
* donut sweep omits its 100% keyframe on purpose (CSS lands on the
* element's own stroke-dasharray) and runs once on mount, so hover-driven
* re-renders never restart it.
*/
/* Keyframes emit only when their animate-* utility is used. The donut sweep omits its 100% keyframe so CSS lands on the element's own stroke-dasharray, and runs once on mount. */
--animate-lc-donut-in: lc-donut-in 0.45s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--lc-i, 0) * 50ms) backwards;
--animate-lc-stacked-in: lc-stacked-in 0.4s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--lc-i, 0) * 40ms) backwards;
--animate-lc-bar-in: lc-bar-in 0.35s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--lc-i, 0) * 15ms) backwards;
@@ -81,7 +46,6 @@
@keyframes lc-agent-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
}
/* The client sources — the only place utility class lists appear. */
@source "../";
@tailwind utilities;
+19 -97
View File
@@ -1,107 +1,60 @@
/* The trend chart: axes, grid lines, bars, category hover-link and the turn strip (trendChart.tsx). */
.lc-chartrow { display: flex; gap: 6px; align-items: stretch; }
.lc-axis { position: relative; width: 40px; height: 150px; padding-top: 18px; box-sizing: border-box; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.lc-axis span { position: absolute; right: 0; line-height: 1; }
/* The duration overlay's right-hand axis: same fixed marks as the left, anchored to the LEFT edge of its
column instead (labels stream rightward away from the plot), and kept narrow — duration labels
(fmtDuration: "850ms" / "3.2s" / "1m5s") stay short. */
/* Anchored to the column's LEFT edge so the duration labels stream rightward away from the plot. */
.lc-axis-r { width: 34px; flex: none; }
.lc-axis-r span { left: 0; right: auto; }
.lc-axis-top { top: 13px; }
/* Quarter-mark labels (chart top 18 + ¼ / ¾ of the 112px bar area, minus half the 11px label box);
tops are mirrored as Q3_TOP/Q1_TOP in trendChart.tsx for the delta mode's overlap check. */
/* Mirrored as Q3_TOP/Q1_TOP in trendChart.tsx for the delta mode's overlap check. */
.lc-axis-q3 { top: 41px; }
.lc-axis-mid { top: 69px; }
.lc-axis-q1 { top: 97px; }
.lc-axis-bot { top: 125px; }
/* Positioned wrapper owning the chart viewport: the scroller holds ONLY the scrolling content, while the hover
tip sits beside it anchored here — absolutely-positioned children of a scroller would contribute to its
scrollable overflow (a wide tooltip once flapped the horizontal scrollbar per hover) AND translate away with
the content on scroll. */
/* Holds the scroller AND the hover tip: absolutely-positioned children of the scroller itself would add to its scrollable overflow and translate away with the content on scroll. */
.lc-chart-wrap { position: relative; flex: 1; min-width: 0; }
/* No `scrollbar-width`/`scrollbar-color` here: either would make Chromium/Safari drop every
::-webkit-scrollbar* rule (see dsh ui-theme scrollbar.css), falling back to the OS overlay
bar that auto-hides — dsh's global 8px skin below stays visible instead.
`overscroll-behavior-x: contain` stops a swipe past either edge from chaining into the browser's history
navigation (back at the left edge, forward at the right) — the same guard dsh's own horizontal scrollers
carry (WebKit ignores it for history, so overscroll.ts cancels the wheel there too). */
/* No `scrollbar-width`/`scrollbar-color`: either makes Chromium/Safari drop every `::-webkit-scrollbar*` rule, including dsh's global 8px skin.
`overscroll-behavior-x: contain` stops a swipe past either edge from chaining into browser history; WebKit ignores it for history, so overscroll.ts cancels the wheel there too. */
.lc-chart-scroll { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-gutter: stable; padding-bottom: var(--dsh-scrollbar-width, 8px); }
/* Horizontal padding reserves the hover outline's ink (1px offset + 1px line) beyond the edge bars — the
scroller would otherwise clip the dashed frame's outer line at either scroll end. The turn strip mirrors
the same padding to keep its blocks aligned with the bar columns. */
/* The 2px padding reserves the hover outline's ink beyond the edge bars, which the scroller would otherwise clip. */
.lc-chart { position: relative; display: flex; align-items: flex-end; gap: 2px; height: 130px; padding: 18px 2px 0; box-sizing: border-box; width: max-content; min-width: 100%; }
.lc-grid { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--dsw-alias-border-l2); pointer-events: none; }
.lc-grid-top { top: 18px; }
/* Quarter-mark guides (chart top 18 + ¼ / ¾ of the 112px bar area); their axis labels sit 5px above each line. */
.lc-grid-q3 { top: 46px; }
.lc-grid-mid { top: 74px; }
.lc-grid-q1 { top: 102px; }
/* The SOLID zero baseline in BOTH modes — inline-positioned off the up-arm in delta, the chart floor
(top 130px, right under the bars) under the '0' label in total; painted after the dashed guides. */
/* The solid zero baseline in both modes: inline-positioned off the up-arm in delta, the chart floor in total. Painted after the dashed guides. */
.lc-grid-zero { top: 130px; border-top-style: solid; border-top-color: color-mix(in srgb, var(--color-neutral-500) 50%, transparent); }
.lc-bar { position: relative; width: 14px; flex: none; height: 100%; display: flex; align-items: flex-end; cursor: pointer; border-radius: 2px; outline: 1px dashed transparent; outline-offset: 1px; transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), outline-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-chart-dim .lc-bar { opacity: 0.35; }
.lc-chart-dim .lc-bar-in-turn { opacity: 1; }
.lc-chart-dim .lc-turn { opacity: 0.35; }
.lc-chart-dim .lc-turn-on { opacity: 1; }
/* Renders OUTSIDE the scroller (see .lc-chart-wrap); syncTip owns the whole transform: translate to the bar's
visible slice. translateX(-50%) is composed there too, so the tip stays width-agnostic without layout reads.
The card floats ABOVE the plot (bottom-anchored, 4px clear of the chart top) so it never covers the bars or
the ✂ markers; rows stack vertically (identity / total) and the whole card is capped at the wrapper (= the
card's chart viewport) and wrapped, so a long row can never hang past the card into neighbouring panes or
off-screen. */
/* Renders outside the scroller (see `.lc-chart-wrap`); syncTip owns the whole transform. Floats above the plot so it never covers the bars or the ✂ markers, and wraps within the wrapper so a long row cannot hang past the card. */
.lc-chart-tip { position: absolute; bottom: calc(100% + 4px); left: 0; z-index: 5; display: flex; flex-direction: column; gap: 2px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 3px 8px; font-size: 12px; color: var(--dsw-alias-label-primary); box-shadow: 0 2px 8px rgba(0,0,0,0.18); pointer-events: none; }
/* The metric row reads secondary to the identity row. */
.lc-chart-tip span + span { color: var(--dsw-alias-label-secondary); }
/*
Hovering previews a bar with a thin dashed frame; a CLICKED (locked)
bar must always show the strong solid frame, so the selected rule is
declared LAST (equal specificity) — otherwise the dashed hover frame
would keep overriding it while the pointer is still on the just-clicked
bar and the lock would only become visible after the pointer leaves.
*/
/* The selected rule is declared LAST at equal specificity: otherwise the dashed hover frame keeps overriding it while the pointer rests on the just-clicked bar. */
.lc-bar-hovered { outline-color: var(--dsw-alias-brand-primary); }
.lc-bar-selected { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 1px; }
.lc-bar-in-turn { background: color-mix(in srgb, var(--color-neutral-500) 14%, transparent); }
.lc-bar-stack { display: flex; flex-direction: column-reverse; width: 100%; }
.lc-bar-stack > div { width: 100%; }
/* DNA mode's bar interior (trendChart.tsx DnaBar): ONE gradient div paints the whole per-item
fingerprint in read order — the px height rides the same total/maxTotal scale as the segmented
stack, so the total-mode axis stays honest. */
/* ONE gradient div paints the whole per-item fingerprint in read order, on the same total/maxTotal scale as the segmented stack. */
.lc-bar-dna { width: 100%; border-radius: 2px; }
/*
Entrance rise: each bar's stack scaleY-opens from the baseline (total mode: the chart floor; delta arms:
the zero line — the down-arm's origin is its top edge), staggered left to right inside the component's
RISE_CAP window (a log longer than the window rises its newest columns only; `--lc-i` is counted from the
window's edge). The sweep rides the `animate-lc-bar-in` utility (theme token, `motion-reduce:animate-none`
for the reduced-motion guard).
*/
/* Entrance rise: the stacks scaleY-open from the baseline (delta arms open from the zero line, so the down-arm's origin is its top edge), staggered left to right inside the component's RISE_CAP window. */
.lc-bar-stack, .lc-bar-up, .lc-bar-down, .lc-bar-dna { transform-origin: 50% 100%; }
.lc-bar-down { transform-origin: 50% 0; }
/* Rounded ends on each bar's outer corners only (the idiom radius, .lc-bar's 2px): the topmost segment
carries the two top corners, the bottommost the two bottom, and an only segment all four — inner
boundaries stay square so stacked segments never show background gaps between colors. */
/* Rounds only each bar's outer corners; inner boundaries stay square so stacked segments never show background gaps. */
.lc-bar-stack > div:first-child { border-radius: 0 0 2px 2px; }
.lc-bar-stack > div:last-child { border-radius: 2px 2px 0 0; }
.lc-bar-stack > div:only-child { border-radius: 2px; }
/* Delta mode: diverging stacks anchored at the zero line — growth piles up from it, shrinkage hangs down.
Only each arm's OUTER end rounds (an arm's last segment, top corners on the up-arm / bottom on the
down-arm; :last-child also covers a single-segment arm) — the zero-line joins stay square and crisp. */
/* Delta mode: diverging stacks anchored at the zero line, growth up and shrinkage down; only each arm's outer end rounds, so the zero-line joins stay crisp. */
.lc-bar-up, .lc-bar-down { position: absolute; left: 0; width: 100%; display: flex; }
.lc-bar-up { flex-direction: column-reverse; }
.lc-bar-down { flex-direction: column; }
.lc-bar-up > div, .lc-bar-down > div { width: 100%; }
.lc-bar-up > div:last-child { border-radius: 2px 2px 0 0; }
.lc-bar-down > div:last-child { border-radius: 0 0 2px 2px; }
/*
Category hover-link (overview/browser → trend card): the chart container carries the hovered
category key (`data-catdim`), so CSS alone lights that key's segment in EVERY bar and recedes
the rest — the memoized bars never re-render on a cross-card hover. Same idiom as the stacked
bars' dim pairing. OPACITY ONLY: a `filter` on the lit segments forced per-segment layerization
on every toggle (the Layerize storm of issue #116). The re-light list below must track the
Category vocabulary — a bucket missing here dims EVERY bar with nothing lit (the #66 skill
regression).
*/
/* The container carries the hovered category key in `data-catdim`, so CSS alone lights that key in every bar and the memoized bars never re-render on a cross-card hover. Opacity only: a `filter` forced per-segment layerization on every toggle (issue #116). The re-light list must track the Category vocabulary — a bucket missing here dims every bar with nothing lit (issue #66). */
.lc-cat-seg { transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-chart[data-catdim] .lc-cat-seg { opacity: 0.35; }
.lc-chart[data-catdim='system'] .lc-cat-seg[data-cat='system'],
@@ -111,26 +64,11 @@
.lc-chart[data-catdim='skill'] .lc-cat-seg[data-cat='skill'],
.lc-chart[data-catdim='assistant'] .lc-cat-seg[data-cat='assistant'],
.lc-chart[data-catdim='tool'] .lc-cat-seg[data-cat='tool'] { opacity: 1; }
/*
DNA mode's cross-bar lifetime highlight (trendChart.tsx DnaHighlights): a flex row mirroring the bar
grid — same 18px top band, 2px gap and 14px columns, so no per-bar x math — painting one translucent
brand slice over every bar that still holds the hovered item. Pointer-transparent; z-index 1 keeps it
above the bars but below the ✂ markers and step flags (z-index 2), which a crossing slice must not hide.
*/
/* A flex row mirroring the bar grid, so no per-bar x math; z-index 1 keeps it above the bars but below the ✂ markers and step flags (z-index 2). */
.lc-dna-hl { position: absolute; top: 18px; right: 2px; bottom: 0; left: 2px; z-index: 1; display: flex; gap: 2px; align-items: stretch; pointer-events: none; }
.lc-dna-cell { position: relative; width: 14px; flex: none; }
.lc-dna-slice { position: absolute; left: 0; width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-brand-primary); border-radius: 1px; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, transparent); }
/*
The duration overlay (trendChart.tsx durationCurve): one svg absolutely positioned over the bar area (the 18px
top band and 2px side padding mirrored from .lc-chart), sized in content px so it scrolls with the bars.
Pointer-transparent; z-index 1 with DOM order after .lc-dna-hl keeps the curve above the bars and the
highlight slices, below the ✂ markers and step flags (z-index 2). `overflow: visible` lets an adaptive-mode
spike off the visible window poke above the plot exactly like an off-window bar's stack does.
The curve is a SECONDARY overlay, so it stays in the quiet label-tertiary gray rather than the brand token
(near-black/near-white made it the loudest element and collided with the hover outline and DNA slices);
each segment's slightly wider `.lc-dur-halo` underlay knocks out the card bg behind the line so it lifts
clear of the bar colors instead of fighting them.
*/
/* One svg over the bar area, sized in content px so it scrolls with the bars; `overflow: visible` lets an adaptive-mode spike poke above the plot. Label-tertiary, not brand: near-black/near-white made the curve the loudest element and collided with the hover outline. */
.lc-duration { position: absolute; top: 18px; left: 2px; z-index: 1; overflow: visible; pointer-events: none; }
.lc-duration polyline, .lc-duration circle { fill: none; stroke: var(--dsw-alias-label-tertiary); stroke-width: 1.25; stroke-linejoin: round; stroke-linecap: round; }
.lc-duration circle { fill: var(--dsw-alias-label-tertiary); }
@@ -138,29 +76,13 @@
.lc-duration circle.lc-dur-halo { stroke: none; fill: var(--dsw-alias-bg-layer-1); }
.lc-chart-dim .lc-duration { opacity: 0.35; }
.lc-bar-marker { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); z-index: 2; font-size: 11px; color: var(--dsw-alias-state-warn-primary); }
/*
Step flags (step granularity only): every 5th bar plants a flag at its RIGHT edge — a 1px pole rising from
the baseline into the chart's 18px top padding band, the cumulative step number boxed at the pole's top,
extending LEFT (a left-streaming banner over its own bar; the opaque background covers the pole's upper
end, so the pair reads as one flag). Decorative and pointer-transparent, and outside the animated stacks
so the entrance rise leaves it standing. The banner never contributes to scrollable overflow: rightward it
ends AT the pole, and the first flag lands 4 columns in (pole at 81px), wider than any label's reach
leftward. Renders BEFORE the ✂ marker so the event glyph paints on top on a same-bar collision.
*/
/* Step flags (step granularity only): every 5th bar plants a 1px pole into the chart's top padding band with the cumulative step number boxed at its top, extending LEFT so it never adds scrollable overflow. Decorative and pointer-transparent, and outside the animated stacks so the entrance rise leaves it standing. */
.lc-step-flag { position: absolute; right: -1px; top: -17px; bottom: 0; z-index: 2; width: 1px; background: color-mix(in srgb, var(--color-neutral-500) 45%, transparent); pointer-events: none; }
.lc-step-flag-label { position: absolute; top: 0; right: 0; box-sizing: border-box; padding: 1px 3px; border: 1px solid color-mix(in srgb, var(--color-neutral-500) 45%, transparent); border-radius: 3px; background: var(--dsw-alias-bg-layer-1); font-size: 9px; line-height: 1; font-weight: 600; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
/*
Turn strip: NEUTRAL zebra fills (inline, see TURN_FILLS) — never the category palette, or the strip reads as a detached bottom segment of the bars.
*/
/* Same sizing as .lc-chart: as an in-flow child of the scroller the strip would otherwise cap at the
viewport width (clientWidth) and overflow:hidden would clip every block past the first screen — the
strip must span the full scrollable column grid instead. */
/* NEUTRAL zebra fills (inline, see TURN_FILLS) — never the category palette, which would read as a detached bottom segment of the bars. */
.lc-turns { display: flex; gap: 2px; overflow: hidden; margin-top: 4px; padding: 0 2px; box-sizing: border-box; width: max-content; min-width: 100%; }
.lc-turn { flex: none; box-sizing: border-box; text-align: center; font-size: 10px; line-height: 14px; font-weight: 600; color: var(--dsw-alias-label-secondary); border-radius: 3px; height: 14px; white-space: nowrap; cursor: pointer; transition: filter var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-turn-on { filter: brightness(1.35); color: var(--dsw-alias-label-primary); }
/* Labels render at natural width, centered over their block — a 2-digit "12" over a 14px turn bar overflows the
block instead of ellipsizing away. The render pass sets one shrunk font size on .lc-turns when the tightest
adjacent label pair would collide at this 10px base (mirrored as LABEL_FONT in trendChart.tsx);
updateTurnLabels' thinning chain stays only as the measured last resort past the 6px floor. */
/* Labels render at natural width, centered over their block, so a 2-digit "12" over a 14px turn overflows instead of ellipsizing away. trendChart.tsx sets one shrunk font size when the tightest adjacent pair would collide at the 10px base (mirrored as LABEL_FONT). */
.lc-turn-label { display: inline-block; }
+54 -153
View File
@@ -1,38 +1,17 @@
/**
* The timeline source behind the Context tab and the /context modal —
* reconciles the two `contextTimeline` wire generations into the single
* value the cards have always rendered.
* The timeline source behind the Context tab and the /context modal. It
* reconciles the two `contextTimeline` wire generations, marked by `detailRev`:
*
* Generations (the marker is `detailRev` on the delivered value):
* - INLINE (older hosts, channel-less deployments, the baseline-gate
* fallback): the wire value carries the collections in place. It passes
* through untouched — no fetch ever happens.
* - SPLIT (current host with the detail route live): the wire value is the
* slim head (~1KB — every session.list row, control baseline, and push
* frame carries it whole). The collections arrive from the plugin's
* `/api/dsh-context/detail` fetch route (host/detail.ts): one targeted read
* when the tab/modal first opens, then a debounced refetch whenever the
* pushed `detailRev` outruns the served detail. Closed tabs fetch nothing.
* - INLINE (older hosts, channel-less deployments, the gate fallback): the
* collections ride the value in place.
* - SPLIT (current host, detail route live): a slim head small enough for every
* session.list row and push frame, with the collections read from
* `/api/dsh-context/detail` and refetched once the pushed revision outruns
* the served detail.
*
* The no-stale-content guarantees: the store is per session and shared by
* the tab and the modal; a refetch is single-flight with a trailing edge
* (a rev bumped mid-flight re-reads after settle); responses race-safe by
* generation and revision (a host refold invalidates reads started before
* its revisions restarted, and latest wins within a generation); a
* transport failure keeps the last good
* detail and backs off; an absent answer (the session left the live set)
* stops the trailing until the head moves again. With no detail at all,
* failure surfaces as a retryable note on the detail cards instead of an
* empty chart.
*
* The cold start: a session can sit with NO pushed `contextTimeline` value
* at all — the enable recomposition raced its open baseline (and the
* projection store clears a key its baseline omits), or its checkpoint row
* is missing or version-stale on a cold-observed session. Waiting for a
* push that never comes would stall the view on "loading" forever, so the
* source opens the detail channel on its own (rev 0): the route folds the
* durable log on demand and serves the slim head beside the collections,
* and the next pushed head takes the channel back per its own revision.
* One store per session, single-flight refetches, race-safe by generation and
* revision; a failed read keeps the last good detail and backs off. A session
* with no pushed value opens the channel itself (rev 0).
*/
import { useEffect, useMemo, useSyncExternalStore } from 'react'
@@ -40,25 +19,18 @@ import type { ContextTimeline, ContextTimelineDetail } from '../shared/types'
import type { SessionStandardProps } from './services'
import { asRecord, collectionsOf, projectionOf, timelineOf } from './services'
// The detail route of host/detail.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 detail route of host/detail.ts, re-declared here: the client bundle
// inlines every import and the host module must never reach it.
const DETAIL_ROUTE = '/api/dsh-context/detail'
/**
* Narrow the detail endpoint's payload to a render-safe value (the same
* boundary rigor as `timelineOf`): collections re-proved per item, scalars
* zeroed, a missing/NaN revision rejects the whole payload (the caller then
* shows the retryable failure note instead of half-merged data).
*/
/** Narrow the detail endpoint's payload; a missing or NaN revision rejects the
* whole payload so the caller shows its retryable note, never half-merged data. */
export function detailOf(value: unknown): ContextTimelineDetail | null {
const data = asRecord(value)
if (data === null) return null
if (typeof data.rev !== 'number' || !Number.isFinite(data.rev) || data.rev < 0) return null
// The slim head rides the payload for the Agent network's cold-node ring
// fetch; a missing or malformed head only degrades that composition (the
// detail cards never read it), so it drops out instead of rejecting the
// payload the cards consume.
// fetch; a malformed head drops out rather than rejecting the whole payload.
const head = timelineOf(data.head)
return {
rev: data.rev,
@@ -67,13 +39,7 @@ export function detailOf(value: unknown): ContextTimelineDetail | null {
}
}
/**
* The detail reader over the plugin's own `/api` fetch route (host/detail.ts).
* Same-origin POST through the harness's authenticated fence; the returned
* thunk resolves the session's current detail, `null` when the session is not
* live anymore, and rejects on transport failure or a malformed payload (the
* store turns the two into the retryable state).
*/
/** The detail reader over the plugin's own `/api` route (host/detail.ts); failures and malformed payloads reject. */
export function makeDetailFetcher(
sessionId: string,
): (() => Promise<ContextTimelineDetail | null>) | undefined {
@@ -94,47 +60,35 @@ export function makeDetailFetcher(
}
}
/** The store's observable snapshot, rebuilt on every transition (identity-gated for useSyncExternalStore). */
/** The store's snapshot, rebuilt on every transition and identity-gated for `useSyncExternalStore`. */
export interface DetailSnap {
/** The newest accepted detail (kept while a refetch is in flight — the cards never flicker back to loading). */
/** The newest accepted detail, kept while a refetch is in flight so the cards never flicker back to loading. */
detail: ContextTimelineDetail | null
/** The last read settled without data (transport failure or absence) and there is no detail to show. */
failed: boolean
/** A read is scheduled or in flight. */
pending: boolean
}
const EMPTY_SNAP: DetailSnap = { detail: null, failed: false, pending: false }
/** The fetch debounce base; each consecutive failure doubles the wait, capped at 3 doublings. */
const DETAIL_DEBOUNCE_MS = 300
/**
* One session's detail ledger. Exported for tests (a zero debounce makes the
* machine synchronous-ish); the app reaches it through `detailStoreOf`.
*/
/** One session's detail ledger; exported for tests, reached by the app through `detailStoreOf`. */
export class DetailStore {
private detail: ContextTimelineDetail | null = null
/** The revision of `detail` (latest-wins cursor); -1 before the first landing. */
private acceptedRev = -1
/** The newest revision the head has asked for. */
private wantedRev = -1
/** The head rev the IN-FLIGHT (or scheduled) read targets — the refold exception's acceptance key. */
/** The head rev the in-flight (or scheduled) read targets — the refold exception's acceptance key. */
private targetRev = -1
/** The head rev seen last — a DECREASE means the host refolded (revisions restart). */
/** The head rev seen last; a DECREASE means the host refolded and revisions restarted. */
private lastHeadRev = -1
private generation = 0
private failed = false
private inFlight = false
private timer: ReturnType<typeof setTimeout> | null = null
private failures = 0
/**
* No view holds this store (the last subscriber left). A parked store keeps
* its cached payload so a re-open renders instantly, but arms no read: the
* map is page-lifetime, so a store whose view is gone would otherwise keep
* trailing a broken detail route (or a moved rev) forever, with nobody
* reading the answer.
*/
/** No view holds this store. It keeps its cached payload so a re-open renders
* instantly but arms no read: the map is page-lifetime, so a view-less store would otherwise trail a dead route forever. */
private parked = false
private readonly listeners = new Set<() => void>()
private snap: DetailSnap = EMPTY_SNAP
@@ -148,12 +102,8 @@ export class DetailStore {
this.listeners.add(fn)
if (this.parked) {
this.parked = false
// Resume: with nothing cached (a failure or an absence), the head the
// view then requests may equal the cursor the failed read already left
// behind — it would be swallowed as "wanted". Clear the ledger so the
// mount's own request(rev) reads again instead of sticking on the note,
// and drop the old backoff so the fresh attempt waits out the base
// window rather than a parked failure's doubled one.
// Resume with nothing cached: clear the ledger and the old backoff, so the
// mount's own request reads again instead of sticking on the note.
if (this.detail === null) {
this.resetLedger()
this.failures = 0
@@ -167,12 +117,8 @@ export class DetailStore {
readonly getSnapshot = (): DetailSnap => this.snap
/**
* The head's current revision arrived: schedule the trailing-edge read
* when it outruns the served detail. A rev DECREASE means the host
* refolded the session (a discarded checkpoint, a restart): revisions are
* no longer comparable, so the ledger resets and refetches.
*/
/** The head's revision arrived: schedule the trailing-edge read when it
* outruns the served detail. A DECREASE means the host refolded, so revisions restart and the ledger resets. */
request(rev: number): void {
if (rev < this.lastHeadRev) this.resetLedger()
this.lastHeadRev = rev
@@ -181,7 +127,6 @@ export class DetailStore {
this.schedule()
}
/** Re-arm after a failure (the cards' retry note): immediate, backoff reset. */
readonly retry = (): void => {
this.failures = 0
if (this.detail !== null) return
@@ -192,37 +137,28 @@ export class DetailStore {
if (!this.inFlight) void this.fire()
}
/** Drop the served payload and its cursors — a host refold, or a resume with nothing cached. */
private resetLedger(): void {
this.generation++
this.acceptedRev = -1
this.wantedRev = -1
this.detail = null
this.failed = false
// Drop the snapshot's own payload reference too: it is the second place a
// released detail could stay reachable from.
// Drop the snapshot's own payload reference too: it is the second place a released detail could stay reachable from.
this.snap = EMPTY_SNAP
}
/** Whether a view holds this store (only an idle store may be demoted). */
get active(): boolean {
return this.listeners.size > 0
}
/** Whether a fetched payload is still cached (what a demotion releases). */
get hasPayload(): boolean {
return this.detail !== null
}
/**
* Demote the store: drop its cached payload and any pending work. Called on
* an idle (parked) store once the page cache exceeds its bound — the store
* itself stays the one instance its session's consumers get, so a later
* open simply reads again through the same ledger.
/** Demote the store: drop its cached payload and pending work, called on an idle store once the page cache exceeds its bound.
*/
release(): void {
// A parked store's window is already cleared; clear again so the demotion
// contract holds on its own (this method is public to the store map).
// Clear again on its own, so the demotion contract holds here too.
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
@@ -231,7 +167,6 @@ export class DetailStore {
this.failures = 0
}
/** The last viewer left: clear the pending read (the cached payload stays). */
private park(): void {
this.parked = true
if (this.timer !== null) {
@@ -242,7 +177,7 @@ export class DetailStore {
}
private schedule(): void {
// Parked: nobody reads the answer, so never arm one.
// Nobody reads a parked store's answer, so never arm one.
if (this.parked || this.timer !== null) return
this.timer = setTimeout(() => {
this.timer = null
@@ -252,13 +187,11 @@ export class DetailStore {
}
private async fire(): Promise<void> {
// A trailing timer fired while an earlier read is still in flight — that
// read's settle re-arms when the wanted rev still outruns the served one.
// A trailing timer fired while an earlier read is still in flight; that
// read's settle re-arms if the wanted rev still outruns the served one.
if (this.inFlight) return
if (this.fetcher === undefined) {
// No session id to read for: the typed failure arms the cards' note.
// (The slim head is only served when the host route went live, so this
// is the exotic path.)
this.failed = true
this.emit()
return
@@ -271,7 +204,6 @@ export class DetailStore {
const d = await this.fetcher()
if (generation !== this.generation) return
if (d !== null) {
// Compare revisions only within the generation that started this read.
if (d.rev >= this.acceptedRev || d.rev === this.targetRev) {
this.detail = d
this.acceptedRev = d.rev
@@ -280,8 +212,7 @@ export class DetailStore {
this.failed = false
} else {
// Absent (the session left the live set): keep the last detail, stop
// the trailing until the head moves again (a disposed session's rev
// never does), and arm the note only when nothing is showable.
// the trailing until the head moves again, and arm the note only when nothing is showable.
this.wantedRev = this.acceptedRev
this.failed = this.detail === null
this.failures++
@@ -293,7 +224,6 @@ export class DetailStore {
}
} finally {
this.inFlight = false
// The head moved or refolded while the read settled: trail once more.
if (this.wantedRev > this.acceptedRev) this.schedule()
this.emit()
}
@@ -308,21 +238,14 @@ export class DetailStore {
}
}
/**
* How many sessions keep their fetched detail payload cached for an instant
* re-open. A large session's payload measured ~194KB of JSON (~300KB of live
* heap) at the default retention bounds, and this map is page-lifetime — so
* without a bound the page's heap grows with every session ever opened. The
* newest few stay cached (the tab, the sidebar panel, and the /context modal
* all read through one store per session); older idle ones are demoted and
* read again when they are opened.
/** How many sessions keep their fetched detail payload cached. A large payload
* measured ~194KB of JSON (~300KB of live heap) and this map is page-lifetime, so older idle stores are demoted and read again.
*/
const DETAIL_STORES_MAX = 8
/** Page-lifetime per-session stores (the tab and the modal share one). */
const stores = new Map<string, DetailStore>()
/** How many mapped stores still hold a fetched payload. */
function payloadHolders(): number {
let held = 0
for (const store of stores.values()) if (store.hasPayload) held++
@@ -332,14 +255,12 @@ function payloadHolders(): number {
export function detailStoreOf(sessionId: string): DetailStore {
const known = stores.get(sessionId)
const store = known ?? new DetailStore(makeDetailFetcher(sessionId))
// Recency: re-inserting moves the entry to the map's newest end, so the
// demotion below always targets the least recently opened session — never
// the store being opened here.
// Re-inserting moves the entry to the map's newest end, so the demotion
// below always targets the least recently opened session.
if (known !== undefined) stores.delete(sessionId)
stores.set(sessionId, store)
// Bound the retained payloads. A store without a payload is about to fetch
// one (its view just mounted, or a demoted one was opened again), so it
// counts as one more; an active store is on screen and is never demoted.
// A store without a payload is about to fetch one, so it counts as one more;
// an active store is on screen and is never demoted.
let excess = payloadHolders() - DETAIL_STORES_MAX + (store.hasPayload ? 0 : 1)
for (const candidate of stores.values()) {
if (excess <= 0) break
@@ -350,7 +271,6 @@ export function detailStoreOf(sessionId: string): DetailStore {
return store
}
/** Test isolation: drop every cached store. */
export function resetTimelineDetailStores(): void {
stores.clear()
}
@@ -358,11 +278,7 @@ export function resetTimelineDetailStores(): void {
export type DetailState = 'legacy' | 'loading' | 'ready' | 'failed'
export interface TimelineSource {
/**
* The value the cards render — the inline generation's value untouched, or
* the slim head merged with the fetched detail collections (empty while
* the first read is in flight; `detailState` names that).
*/
/** The value the cards render: the inline generation's value, or the slim head merged with the fetched detail collections. */
data: ContextTimeline | null
detailState: DetailState
retryDetail: () => void
@@ -371,24 +287,17 @@ export interface TimelineSource {
const noopSubscribe = (): (() => void) => () => {}
const noopRetry = (): void => {}
/**
* The view's one read of the timeline (see the module header for the
* generation rules). Hook-order safe: every hook runs unconditionally, the
* branches below only shape the returned record.
*/
/** The view's one read of the timeline. Hook-order safe: every hook runs
* unconditionally, and the branches below only shape the returned record. */
export function useTimelineSource(props: SessionStandardProps): TimelineSource {
const head = projectionOf(props, 'contextTimeline', timelineOf)
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
// The split marker: the slim head carries the detail revision; anything
// else (the inline value, the gate fallback, a corrupt payload) is the
// inline generation — `headRev` null means no detail channel work at all.
// The split marker: the slim head carries the detail revision; `headRev` null
// (an inline value, the gate fallback, a corrupt payload) means no channel.
const headRev = head !== null && typeof head.detailRev === 'number' ? head.detailRev : null
const slim = headRev !== null
// The cold start arms whenever no value has been pushed: the channel is
// opened by the source itself (rev 0) instead of waiting on a push that
// may never come (the enable recomposition raced the baseline, a cleared
// or stale checkpoint row). The store is per session, so the cold read's
// result carries straight into the pushed generation's later refetches.
// The cold start arms whenever no value has been pushed: the source opens the
// channel itself (rev 0) rather than waiting on a push that may never come.
const cold = head === null
const store = useMemo(
() => (slim || cold ? detailStoreOf(sessionId) : null),
@@ -399,33 +308,25 @@ export function useTimelineSource(props: SessionStandardProps): TimelineSource {
store !== null ? store.getSnapshot : () => EMPTY_SNAP,
)
useEffect(() => {
// The pushed head's revision drives the trailing refetch; the cold start
// requests rev 0 — acceptance is rev-safe, and a head that arrives later
// trails per its own revision.
// The pushed head's revision drives the trailing refetch; the cold start requests rev 0.
if (store !== null) store.request(headRev ?? 0)
}, [store, headRev])
return useMemo<TimelineSource>(() => {
const detail = snap.detail
// A head-with-detail can only exist through a live channel store — the
// fallback covers TS's narrowing loss, never a real state.
const retry = store !== null ? store.retry : noopRetry
// The inline generation passes through untouched — no store exists, the
// collections never ride the channel.
if (head !== null && !slim) return { data: head, detailState: 'legacy', retryDetail: noopRetry }
// The render head: the pushed value, or — while nothing has been pushed —
// the detail read's own slim head.
// The render head: the pushed value, or the detail read's own slim head.
const base = head !== null ? head : detail !== null ? detail.head ?? null : null
if (base === null) {
// Nothing renderable: the cold read still pending, or settled without
// a usable value (transport failure, absence, a headless payload) —
// the retryable failure, never a spinner that never resolves.
// Nothing renderable yet — a cold read still pending, or one settled
// without a usable value: the retryable failure, never a dead spinner.
const failed = store !== null && (snap.failed || detail !== null)
return { data: null, detailState: failed ? 'failed' : 'loading', retryDetail: retry }
}
if (detail === null) {
// The head's own counters render while the first read is in flight (or
// once it settled without data — the failure note rides beside).
// The head's own counters render while the first read is in flight, or
// once it settled without data (the failure note rides beside).
return { data: base, detailState: snap.failed ? 'failed' : 'loading', retryDetail: retry }
}
const data: ContextTimeline = {
+10 -35
View File
@@ -1,40 +1,26 @@
/**
* Chat → Context jump relay. The assistant-message action records the clicked
* reply's request seq here and opens the Context view — the right Sidebar's
* tab when this harness serves one, else the conversation tab; the view
* consumes the one-shot focus once its projection data is in. Module-level
* per-session map — the same life pattern as the modal open-state stores.
*/
/** Chat → Context jump relay: the assistant-message action records the clicked reply's request seq and opens
* the Context view — the right Sidebar's tab when served, else the conversation tab — which consumes the
* one-shot focus once its projection data is in. */
import { asRecord, type ClientCtx } from './services'
import { SIDEBAR_CONTEXT_KIND } from './sidebar'
const pendingFocus = new Map<string, number>()
/** The mounted Context views re-pinning on later records (repeat jumps). */
const focusListeners = new Set<() => void>()
/**
* Record the Context step (request seq) to reveal for `sessionId` — replaces
* any unconsumed request and wakes the mounted views.
*/
export function requestContextFocus(sessionId: string, seq: number): void {
pendingFocus.set(sessionId, seq)
for (const listener of focusListeners) listener()
}
/**
* Wake on every record while subscribed: a Context view that is already
* mounted (the sidebar landing keeps it mounted across jumps) re-pins when a
* record lands — the listener takes its OWN session's entry, so other
* sessions' records leave their entries pending for their views.
*/
/** Wake every subscribed view on each record; a listener takes its OWN session's entry, so other sessions'
* records stay pending for their views. */
export function subscribeContextFocus(listener: () => void): () => void {
focusListeners.add(listener)
return () => { focusListeners.delete(listener) }
}
/** Take the pending focus request, if any — one-shot, the map entry is consumed. */
export function takeContextFocus(sessionId: string): number | null {
const seq = pendingFocus.get(sessionId)
if (seq === undefined) return null
@@ -42,14 +28,9 @@ export function takeContextFocus(sessionId: string): number | null {
return seq
}
/**
* Open the Context tab on the right Sidebar over `ctx.sidebarRight.openTab`
* (the column expands in the same step). OPTIONAL seam, re-proved at call
* time: a harness without the service (a stripped deployment, a below-baseline
* gated host), a placement that registered no tab type, no mounted session
* surface, or a hostile face all report false so the caller keeps its
* conversation-tab fallback.
*/
/** Open the Context tab over `ctx.sidebarRight.openTab` (the column expands in the same step). OPTIONAL seam,
* re-proved at call time: a stripped service, no registered tab type, or a hostile face all report false so
* the caller keeps its conversation-tab fallback. */
export function openContextSidebar(ctx: ClientCtx): boolean {
try {
const face = asRecord(ctx.get('sidebarRight'))
@@ -61,14 +42,8 @@ export function openContextSidebar(ctx: ClientCtx): boolean {
}
}
/**
* Activate the Context tab by clicking its own tab-bar button (the semantic
* `button[role="tab"]` chrome the conversation shell renders for every view).
* The harness hands `openView` only to the ACTIVE view entry, so a nested
* chat action cannot call it — this rides the same button a user click would.
* Already-active is a no-op that still reports success; no matching tab (any
* dsh layout without the tab bar) reports failure and nothing happens.
*/
/** Activate the Context tab by clicking its own `button[role="tab"]` chrome: the harness hands `openView` only
* to the ACTIVE view entry, so a nested chat action cannot call it; a missing tab reports failure. */
export function activateContextTab(label: string): boolean {
const tabs = document.querySelectorAll<HTMLButtonElement>('button[role="tab"]')
for (const tab of tabs) {
-2
View File
@@ -1,5 +1,3 @@
/** Shared dependency bag for component factories, built once per plugin apply (t, formatters, catLabel, event-text helpers). */
import type { ContextEventRecord } from '../shared/types'
import { fmt, fmtDuration, fmtShare, fmtTime } from './format'
import { makeEventText } from './components/events'