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:
1 parent
3fe1b7cf77
commit
7cb0a9fa46
89 files changed
+1212
-4659
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)] : []),
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '' : ''
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
Vendored
+2
-11
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 }),
|
||||
)))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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,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
@@ -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; }
|
||||
@@ -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); }
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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); }
|
||||
|
||||
@@ -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,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)); }
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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,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); }
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user