diff --git a/src/client/agentTree.ts b/src/client/agentTree.ts index 7e1f3b1..c6085c0 100644 --- a/src/client/agentTree.ts +++ b/src/client/agentTree.ts @@ -20,7 +20,7 @@ import type { PartsPart } from './categories' import { headlineOf, type Headline } from './headline' -import { contextBreakdownOf, contextPressureOf, numOf, timelineOf, tokenUsageOf } from './services' +import { asRecord, contextBreakdownOf, contextPressureOf, numOf, timelineOf, tokenUsageOf } from './services' /** * The outward `ctx.sessions` client face, minimally re-typed for the card: @@ -122,11 +122,6 @@ const LEVEL_H = 154 const CELL_H = AGENT_NODE_R + 64 const PAD_Y = 56 -function asRecord(value: unknown): Record | null { - if (value === null || value === undefined || typeof value !== 'object') return null - return value as Record -} - /** 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) @@ -564,8 +559,13 @@ export function sessionsFaceOf(ctx: { get(name: string): unknown }): SessionsFac return rec } -/** Compact duration: `42s`, `3m05s`, `1h07m` (shared by both locales). */ -export function fmtDuration(ms: number): string { +/** + * Compact duration: `42s`, `3m05s`, `1h07m` (shared by both locales). + * Deliberately distinct from format.ts's locale-aware `fmtDuration` (the + * timing card's `12.3s` / `4分0秒`): the inspector's caption column needs + * whole-second, fixed-width, locale-free text. + */ +export function fmtDurationCompact(ms: number): string { if (!Number.isFinite(ms) || ms < 0) return '—' const s = Math.round(ms / 1000) if (s < 60) return `${s}s` diff --git a/src/client/categories.ts b/src/client/categories.ts index 593b710..24a77ac 100644 --- a/src/client/categories.ts +++ b/src/client/categories.ts @@ -33,9 +33,6 @@ export const CATS: { key: Category | 'system' | 'tools'; color: string }[] = [ const MESSAGE_CATS: readonly (Category | 'system' | 'tools')[] = ['user', 'inject', 'assistant', 'tool'] -/** Category key → chart color, built once from CATS. */ -export const CAT_COLORS: Record = Object.fromEntries(CATS.map(c => [c.key, c.color])) - export function partsOf(breakdown: Snapshot['current'] | RequestRecord): PartsPart[] { return CATS.map((c) => { return { key: c.key, color: c.color, value: breakdown[c.key] || 0 } diff --git a/src/client/components/agentGraph.tsx b/src/client/components/agentGraph.tsx index 63a0e55..42bd4aa 100644 --- a/src/client/components/agentGraph.tsx +++ b/src/client/components/agentGraph.tsx @@ -20,7 +20,7 @@ import { AGENT_NODE_R, AGENT_RING_R, agentForestOf, - fmtDuration, + fmtDurationCompact, layoutForest, openAgentSession, ringSegments, @@ -297,7 +297,7 @@ function Inspector(props: { node: AgentNode; t: ViewKit['t']; fmt: ViewKit['fmt' } if (node.requests > 0) bits.push(t('agents.requests', { n: node.requests })) if (node.billed !== null && node.billed > 0) bits.push(t('agents.billed', { n: fmt(node.billed) })) - if (node.durationMs !== null) bits.push(fmtDuration(node.durationMs)) + if (node.durationMs !== null) bits.push(fmtDurationCompact(node.durationMs)) return (
{node.label} diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 4cf29ea..6f2d067 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -1,5 +1,5 @@ import type * as ReactNS from 'react' -import { UNKNOWN_TOOL_SOURCE, type Category, type ContextHeaders, type ContextTimeline, type HeaderEpochContent, type HeaderTool, type RequestRecord, type SurfaceNode } from '../../shared/types' +import { UNKNOWN_TOOL_SOURCE, type Category, type ContextHeaders, type ContextTimeline, type HeaderTool, type RequestRecord, type SurfaceNode } from '../../shared/types' import { assemble } from '../assemble' import type { Assembled } from '../assemble' import { CATS, partsOf } from '../categories' @@ -8,6 +8,7 @@ import type { ContentFetcher, ConversationNodeLike, HeaderFetcher } from '../ser import type { ViewKit } from '../viewkit' import { blockSummaryOf, callSummaryOf, parseCallArgs } from '../callSummary' import { makeNodeText } from './nodes' +import { fetchMissNote, useFetchOnMiss } from './fetchOnMiss' import { imageRefOf, makeImageCard } from './images' import type { ImageKit } from './images' import { makeRichText } from './richText' @@ -19,10 +20,9 @@ export interface ContextBrowserProps { data: ContextTimeline headers: ContextHeaders | null /** - * The conversation-window nodes, already resolved by the caller from - * whichever seat the harness provides (`useChat` on 0.1.2+, the session - * snapshot before it) — the browser itself stays seat-free so the hook - * order lives in exactly one place. + * 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. */ convNodes?: readonly ConversationNodeLike[] /** @@ -580,47 +580,32 @@ export function makeContextBrowser( // 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 [fetched, setFetched] = React.useState>(() => new Map()) - const bySeq = React.useMemo(() => { + const convBySeq = React.useMemo(() => { const m = new Map() for (const n of convNodes ?? []) m.set(n.seq, n) - for (const [seq, n] of fetched) if (!m.has(seq)) m.set(seq, n) return m - }, [convNodes, fetched]) + }, [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 state machine: one targeted history read per expanded row - // whose seq the join missed. `failed` arms the retry button; `absent` - // means the page came back without the seq — it is not in the durable log. - const missingSeq = openSeq !== null && !bySeq.has(openSeq) ? openSeq : 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 [missState, setMissState] = React.useState<'idle' | 'loading' | 'absent' | 'failed'>('idle') - const [retry, setRetry] = React.useState(0) - React.useEffect(() => { - if (missingSeq === null || fetchContent === undefined) return - let live = true - setMissState('loading') - fetchContent(missingSeq).then((node) => { - if (!live) return - if (node === null) { - setMissState('absent') - return - } - setFetched((prev) => { - const next = new Map(prev) - next.set(missingSeq, node) - return next - }) - setMissState('idle') - }, (error: unknown) => { - console.warn('dsh-context: targeted history read failed', error) - if (live) setMissState('failed') - }) - return () => { live = false } - }, [missingSeq, fetchContent, retry]) + const miss = useFetchOnMiss( + openSeq !== null && !convBySeq.has(openSeq) ? openSeq : null, + fetchContent, + 'dsh-context: targeted history read failed', + ) + const bySeq = React.useMemo(() => { + if (miss.values.size === 0) return convBySeq + const m = new Map(convBySeq) + 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. const pinSeq = props.pinSeq @@ -647,21 +632,8 @@ export function makeContextBrowser( focusScrollRef.current = false rootRef.current?.querySelector('.lc-br-elem-on')?.scrollIntoView({ block: 'nearest' }) }) - // The note an un-joined open row shows, per fetch state: legacy static hint - // (no fetcher), in-flight loading, log-absent, or a failed read with retry. - const missNote: ReactNS.ReactNode = fetchContent === undefined - ? t('browser.noContent') - : missState === 'loading' - ? t('browser.loading') - : missState === 'absent' - ? t('browser.notInLog') - : missState === 'failed' - ? ( - - ) - : t('browser.noContent') + // The note an un-joined open row shows, per fetch state (fetchOnMiss.tsx). + const missNote = fetchMissNote(t, fetchContent, miss.state, miss.retry, 'browser.noContent') const requests = data.requests const hoverReq = props.previewSeq !== null && props.previewSeq !== undefined @@ -682,53 +654,18 @@ export function makeContextBrowser( // Header epoch CONTENT (system prompt text, tool descriptions/schemas): // the projection carries metadata only, so the selected step's epoch is // fetched on demand — one seq-anchored history read when its system or - // tools section first opens. Content caches per epoch (history is - // immutable); the fetch states mirror the row-miss machine above. - const [headerContent, setHeaderContent] = React.useState>(() => new Map()) - const [headerState, setHeaderState] = React.useState<'idle' | 'loading' | 'absent' | 'failed'>('idle') - const [headerRetry, setHeaderRetry] = React.useState(0) + // tools section first opens. The same fetch-on-miss machine as the row + // read above (content caches per epoch — history is immutable). const fetchHeader = props.fetchHeader const headerSeq = view.header !== null ? view.header.seq : null - const headerNeeded = view.header !== null - && headerSeq !== null - && !headerContent.has(headerSeq) - && (openCat === 'system' || openCat === 'tools') - React.useEffect(() => { - if (!headerNeeded || fetchHeader === undefined) return - let live = true - setHeaderState('loading') - fetchHeader(headerSeq).then((content) => { - if (!live) return - if (content === null) { - setHeaderState('absent') - return - } - setHeaderContent((prev) => { - const next = new Map(prev) - next.set(headerSeq, content) - return next - }) - setHeaderState('idle') - }, (error: unknown) => { - console.warn('dsh-context: header content fetch failed', error) - if (live) setHeaderState('failed') - }) - return () => { live = false } - }, [headerNeeded, headerSeq, fetchHeader, headerRetry]) - // The note a not-yet-loaded epoch section shows, per fetch state: legacy - // metadata-only hint (no fetcher), in-flight loading, log-absent, or a - // failed read with retry. - const headerNote: ReactNS.ReactNode = fetchHeader === undefined - ? t('browser.headerMetaOnly') - : headerState === 'loading' || headerState === 'idle' - ? t('browser.loading') - : headerState === 'absent' - ? t('browser.notInLog') - : ( - - ) + const epoch = useFetchOnMiss( + headerSeq !== null && (openCat === 'system' || openCat === 'tools') ? headerSeq : null, + fetchHeader, + '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) diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx index 1ae4ce1..3b656fa 100644 --- a/src/client/components/contextModal.tsx +++ b/src/client/components/contextModal.tsx @@ -9,12 +9,13 @@ import { measureDock } from '../dockMeasure' import { headlineOf } from '../headline' import { modalStoreOf, takePendingConsume } from '../modalStore' import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services' -import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, timelineOf } from '../services' +import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, projectionOf, timelineOf } from '../services' import { makeContentFetcher, makeHeaderFetcher } from '../historyPage' import type { ViewKit } from '../viewkit' import { makeContextBrowser } from './browser' import { makeCurrentComposition } from './currentComposition' import { makeErrorBoundary } from './errorBoundary' +import { useEscapeClose } from './escapeClose' import { makeLegend, makeStackedBar } from './stackedBar' import { React, h } from '../react' @@ -35,21 +36,13 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM function ContextModalBody(props: ContextModalProps): ReactNS.ReactElement | null { const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '' const open = typeof props.useContextModal === 'function' ? props.useContextModal(s => s) : false - const data = typeof props.useProjection === 'function' - ? timelineOf(props.useProjection('contextTimeline')) - : null - const pressure = typeof props.useProjection === 'function' - ? contextPressureOf(props.useProjection('contextPressure')) - : null - const breakdown = typeof props.useProjection === 'function' - ? contextBreakdownOf(props.useProjection('contextBreakdown')) - : null - const headers = typeof props.useProjection === 'function' - ? headersOf(props.useProjection('contextHeaders')) - : null - // Conversation-window join for the browser (both seats are hooks — read - // unconditionally here, before the closed early return, so the hook order - // stays stable across open/close). + const data = projectionOf(props, 'contextTimeline', timelineOf) + 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). const convNodes = conversationNodesOf(props) const [hoverCat, setHoverCat] = React.useState(null) // Dock the mask beside the shell sidebar: 0 until the frame measure lands @@ -81,21 +74,8 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM if (scope !== undefined) scope.bail(scope, 'slash/input-consume-token', { guard }) }, [ctx, sessionId]) - React.useEffect(() => { - if (!open) return undefined - const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null - const onKey = (ev: KeyboardEvent) => { - if (ev.key !== 'Escape') return - ev.preventDefault() - ev.stopPropagation() - close() - } - window.addEventListener('keydown', onKey, true) - return () => { - window.removeEventListener('keydown', onKey, true) - if (previous !== null && document.contains(previous)) previous.focus() - } - }, [open, close]) + // Capture-phase Escape close + focus restore (the shared overlay contract). + useEscapeClose(open, close) // Dock the mask to the main column: measure the sidebar track once before // first paint, then follow the frame's inline template while open (sidebar diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index bba3c4c..7cb068a 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -8,7 +8,7 @@ import type { ContextEventRecord, RequestRecord, SurfaceNode } from '../../share import { briefNodes, briefOf } from '../brief' import { headlineOf } from '../headline' import type { SessionStandardProps } from '../services' -import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, numOf, timelineOf, tokenUsageOf, unsupportedOf } from '../services' +import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, numOf, projectionOf, timelineOf, tokenUsageOf, unsupportedOf } from '../services' import type { ClientCtx, ConversationNodeLike } from '../services' import { makeContentFetcher, makeHeaderFetcher } from '../historyPage' import { canOpenPathsOf, openPathVia, workspaceOf } from '../services' @@ -42,13 +42,11 @@ const viewScroll = new Map() const EVENT_KINDS = ['inject', 'compaction', 'prune', 'model', 'mode'] as const -export type ContextViewProps = SessionStandardProps - export function makeContextView( ctx: ClientCtx, kit: ViewKit, settings: ContextSettings, -): (props: ContextViewProps) => ReactNS.ReactElement { +): (props: SessionStandardProps) => ReactNS.ReactElement { const { t } = kit const StackedBar = makeStackedBar(kit) const Legend = makeLegend(kit) @@ -69,31 +67,21 @@ export function makeContextView( // 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): ReactNS.ReactElement { + function ContextViewBody(props: SessionStandardProps): ReactNS.ReactElement { const sessionId = props.sessionId - const data = typeof props.useProjection === 'function' - ? timelineOf(props.useProjection('contextTimeline')) - : null + const data = projectionOf(props, 'contextTimeline', timelineOf) // 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. - const pressure = typeof props.useProjection === 'function' - ? contextPressureOf(props.useProjection('contextPressure')) - : null + 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 stats board's cache-hit cell reuses it verbatim; absent → the cell drops to a dash instead of estimating. - const usage = typeof props.useProjection === 'function' - ? tokenUsageOf(props.useProjection('tokenUsage')) - : null + 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. - const breakdown = typeof props.useProjection === 'function' - ? contextBreakdownOf(props.useProjection('contextBreakdown')) - : null + 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. - const headers = typeof props.useProjection === 'function' - ? headersOf(props.useProjection('contextHeaders')) - : null + const headers = projectionOf(props, 'contextHeaders', headersOf) const [selectedSeq, setSelectedSeq] = React.useState(null) const [hoveredSeq, setHoveredSeq] = React.useState(null) const [hoverTurn, setHoverTurn] = React.useState(null) @@ -215,10 +203,9 @@ export function makeContextView( // 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 = React.useMemo(() => (data ? briefNodes(data) : []), [data]) - // The conversation-window join, from whichever seat the harness provides - // (`useChat` on 0.1.2+, the session snapshot before it). Both seats are - // real hooks — invoked unconditionally per render (stable order), only - // the result picked; undefined join = render without it, never an error. + // 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 = React.useMemo(() => { const m = new Map() @@ -480,7 +467,7 @@ export function makeContextView( ) } - return function ContextView(props: ContextViewProps): ReactNS.ReactElement { + return function ContextView(props: SessionStandardProps): ReactNS.ReactElement { return h(ErrorBoundary, null, h(ContextViewBody, props)) } } diff --git a/src/client/components/escapeClose.ts b/src/client/components/escapeClose.ts new file mode 100644 index 0000000..ba12d19 --- /dev/null +++ b/src/client/components/escapeClose.ts @@ -0,0 +1,28 @@ +/** + * 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). + */ + +import { React } from '../react' + +/** Close on Escape while `active`; restores the pre-open focus on cleanup. */ +export function useEscapeClose(active: boolean, onClose: () => void): void { + React.useEffect(() => { + if (!active) return undefined + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null + const onKey = (ev: KeyboardEvent): void => { + if (ev.key !== 'Escape') return + ev.preventDefault() + ev.stopPropagation() + onClose() + } + window.addEventListener('keydown', onKey, true) + return () => { + window.removeEventListener('keydown', onKey, true) + if (previous !== null && document.contains(previous)) previous.focus() + } + }, [active, onClose]) +} diff --git a/src/client/components/events.tsx b/src/client/components/events.tsx index d96f879..075496c 100644 --- a/src/client/components/events.tsx +++ b/src/client/components/events.tsx @@ -11,7 +11,7 @@ import type { ViewKit } from '../viewkit' import { React } from '../react' -export const EVENT_ICONS: Record = { compaction: '✂', prune: '✂', inject: '+', model: '⇄', mode: '⇄' } +const EVENT_ICONS: Record = { compaction: '✂', prune: '✂', inject: '+', model: '⇄', mode: '⇄' } export interface EventListProps { events: ContextEventRecord[] } diff --git a/src/client/components/fetchOnMiss.tsx b/src/client/components/fetchOnMiss.tsx new file mode 100644 index 0000000..dd2640c --- /dev/null +++ b/src/client/components/fetchOnMiss.tsx @@ -0,0 +1,88 @@ +/** + * 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. + */ + +import type * as ReactNS from 'react' +import type { Translate } from '../i18n' + +import { React } from '../react' + +export type FetchMissState = 'idle' | 'loading' | 'absent' | 'failed' + +export interface FetchOnMiss { + /** Resolved values by key (immutable history — a landed value never refetches). */ + values: Map + state: FetchMissState + /** Re-arm the read after a failure (the retry button). */ + retry: () => void +} + +/** + * Run one fetch for `key` when it is set and not yet cached. `key` null (no + * target open) or `fetcher` undefined (older host without the history face) + * leaves the machine idle; the caller renders the matching note either way. + */ +export function useFetchOnMiss( + key: number | null, + fetcher: ((key: number) => Promise) | undefined, + warn: string, +): FetchOnMiss { + const [values, setValues] = React.useState>(() => new Map()) + const [state, setState] = React.useState('idle') + const [attempt, setAttempt] = React.useState(0) + React.useEffect(() => { + if (key === null || fetcher === undefined || values.has(key)) return + let live = true + setState('loading') + fetcher(key).then((value) => { + if (!live) return + if (value === null) { + setState('absent') + return + } + setValues((prev) => { + const next = new Map(prev) + next.set(key, value) + return next + }) + setState('idle') + }, (error: unknown) => { + console.warn(warn, error) + if (live) setState('failed') + }) + return () => { live = false } + }, [key, fetcher, attempt, values]) + 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. + */ +export function fetchMissNote( + t: Translate, + fetcher: unknown, + state: FetchMissState, + onRetry: () => void, + emptyKey: 'browser.noContent' | 'browser.headerMetaOnly', +): ReactNS.ReactNode { + if (fetcher === undefined) return t(emptyKey) + if (state === 'absent') return t('browser.notInLog') + if (state === 'failed') { + return ( + + ) + } + return t('browser.loading') +} diff --git a/src/client/components/fileCard.tsx b/src/client/components/fileCard.tsx index c766237..d9b0bff 100644 --- a/src/client/components/fileCard.tsx +++ b/src/client/components/fileCard.tsx @@ -20,8 +20,6 @@ import type { ViewKit } from '../viewkit' import { React } from '../react' export type FileFilter = 'all' | FileOpKind | 'image' -/** Row order: most operations first (default), most-recently-touched first, or path ascending. */ -export type FileSort = DefaultFileSort export interface FileCardProps { activity: FileActivity @@ -62,7 +60,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C const { activity } = props const [filter, setFilter] = React.useState('all') // Mount-time default from the plugin settings card; in-card toggling stays mount-local and never writes back. - const [sort, setSort] = React.useState(() => settings.defaultFileSort()) + const [sort, setSort] = React.useState(() => settings.defaultFileSort()) const [query, setQuery] = React.useState('') const [openPath, setOpenPath] = React.useState(null) diff --git a/src/client/components/upgradeGate.tsx b/src/client/components/upgradeGate.tsx index 7376f21..7b71940 100644 --- a/src/client/components/upgradeGate.tsx +++ b/src/client/components/upgradeGate.tsx @@ -12,6 +12,7 @@ import type * as ReactNS from 'react' import type { ViewKit } from '../viewkit' +import { useEscapeClose } from './escapeClose' import { React } from '../react' @@ -39,19 +40,9 @@ export function makeUpgradeGate(kit: ViewKit): (props: UpgradeGateProps) => Reac setClosedFor(sessionId) }, [sessionId]) - // Same Escape contract as the /context modal: capture-phase, so the - // composer's own key handling never swallows it first. - React.useEffect(() => { - if (closed) return undefined - const onKey = (ev: KeyboardEvent) => { - if (ev.key !== 'Escape') return - ev.preventDefault() - ev.stopPropagation() - close() - } - window.addEventListener('keydown', onKey, true) - return () => { window.removeEventListener('keydown', onKey, true) } - }, [closed, close]) + // Same overlay contract as the /context modal (escapeClose.ts): + // capture-phase Escape, and focus returns to the pre-open element. + useEscapeClose(!closed, close) if (closed) return null diff --git a/src/client/settings.ts b/src/client/settings.ts index 1db8d82..1b90aa3 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -12,13 +12,11 @@ * members keeps the dependency graph honest. */ -export type DefaultGranularity = 'step' | 'turn' -export type DefaultTrendMode = 'total' | 'delta' -/** File Activity row order: most operations first, most-recently-touched first, or path ascending. */ -export type DefaultFileSort = 'count' | 'latest' | 'path' +import type { DefaultFileSort, DefaultGranularity, DefaultTrendMode, SettingsField } from '../shared/types' -/** The section fields the card edits, as the Host schema names them. */ -export type SettingsField = 'defaultGranularity' | 'defaultTrendMode' | 'defaultFileSort' +// The preference vocabulary is declared once in shared/types.ts; re-exported +// here so client-side consumers keep their canonical import path. +export type { DefaultFileSort, DefaultGranularity, DefaultTrendMode, SettingsField } from '../shared/types' /** The bound settings scope (ctx.settingsScope.bind result), as consumed. */ export interface SettingsScopeLike { diff --git a/src/host/settings.ts b/src/host/settings.ts index 3c54456..8847351 100644 --- a/src/host/settings.ts +++ b/src/host/settings.ts @@ -16,22 +16,14 @@ import type { Context } from '@deepseek-ai/cordis' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import z from '@deepseek-ai/schemastery' +import type { PluginSettings } from '../shared/types' /** The namespace is the join key between the Host registration and the browser card. */ export const SETTINGS_NAMESPACE = 'dsh-context' -export type DefaultGranularity = 'step' | 'turn' - -export type DefaultTrendMode = 'total' | 'delta' - -/** File Activity row order: most operations first, most-recently-touched first, or path ascending. */ -export type DefaultFileSort = 'count' | 'latest' | 'path' - -export interface PluginSettings { - defaultGranularity: DefaultGranularity - defaultTrendMode: DefaultTrendMode - defaultFileSort: DefaultFileSort -} +// The preference vocabulary is declared once in shared/types.ts; re-exported +// here so host-side consumers keep their canonical import path. +export type { DefaultFileSort, DefaultGranularity, DefaultTrendMode, PluginSettings } from '../shared/types' /** Section schema: also the wire envelope the browser scope validates against. */ export const SettingsSchema: z = z.object({ diff --git a/src/shared/types.ts b/src/shared/types.ts index b2be953..0ac558d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -44,6 +44,29 @@ declare module '@deepseek-ai/dsh-session-projection/types' { export type Category = 'user' | 'inject' | 'assistant' | 'tool' +/** + * The per-user display-preference vocabulary of the `dsh-context` settings + * namespace — the ONE declaration both halves share: the Host registers the + * namespace schema against it (host/settings.ts), the Client binds the scope + * and edits fields by name (client/settings.ts). Type-only, so both bundles + * erase it. + */ +export type DefaultGranularity = 'step' | 'turn' + +export type DefaultTrendMode = 'total' | 'delta' + +/** File Activity row order: most operations first, most-recently-touched first, or path ascending. */ +export type DefaultFileSort = 'count' | 'latest' | 'path' + +export interface PluginSettings { + defaultGranularity: DefaultGranularity + defaultTrendMode: DefaultTrendMode + defaultFileSort: DefaultFileSort +} + +/** The section fields the settings card edits, as the Host schema names them. */ +export type SettingsField = keyof PluginSettings + export interface Snapshot { ok: boolean /** @@ -69,19 +92,6 @@ export interface Snapshot { tool: number total: number } - /** - * Provider-anchored occupancy of the NEXT request. LEGACY since 0.11: the - * Host no longer folds this — the Client reads the official token-meter - * `contextPressure` projection key (`useProjection('contextPressure')`) - * instead. Kept optional for wire compatibility with older clients. - */ - occupancy?: { - pressureTokens?: number - surfaceTokens: number - sampledSurfaceTokens?: number - projectedTokens?: number - contextWindow?: number - } /** * Image blocks live in the CURRENT context (user uploads plus tool-result * images, nested blocks included) — the sum over the live surface nodes' diff --git a/tests/client/agentTree.spec.ts b/tests/client/agentTree.spec.ts index 58faa0a..ab57bd7 100644 --- a/tests/client/agentTree.spec.ts +++ b/tests/client/agentTree.spec.ts @@ -17,7 +17,7 @@ import { agentStatsOf, familyHue, ringSegments, - fmtDuration, + fmtDurationCompact, layoutForest, openAgentSession, sessionsFaceOf, @@ -584,12 +584,12 @@ describe('sessionsFaceOf', () => { }) }) -describe('fmtDuration', () => { +describe('fmtDurationCompact', () => { test('compacts milliseconds', () => { - assert.equal(fmtDuration(NaN), '—') - assert.equal(fmtDuration(-5), '—') - assert.equal(fmtDuration(42000), '42s') - assert.equal(fmtDuration(185000), '3m05s') - assert.equal(fmtDuration(4020000), '1h07m') + assert.equal(fmtDurationCompact(NaN), '—') + assert.equal(fmtDurationCompact(-5), '—') + assert.equal(fmtDurationCompact(42000), '42s') + assert.equal(fmtDurationCompact(185000), '3m05s') + assert.equal(fmtDurationCompact(4020000), '1h07m') }) })