refactor: share the settings vocabulary and dedupe the client fetch/overlay patterns

- The settings preference vocabulary (DefaultGranularity/DefaultTrendMode/
  DefaultFileSort/PluginSettings/SettingsField) was declared in both
  host/settings.ts and client/settings.ts — declared once in
  shared/types.ts now, re-exported at both ends.
- The Context browser's two fetch-on-miss state machines (row content,
  header epoch) were line-identical and had already drifted (the row
  read's idle note said 'no content') — one useFetchOnMiss hook plus one
  fetchMissNote helper (components/fetchOnMiss.tsx).
- The /context modal and the upgrade gate duplicated the capture-phase
  Escape handling; useEscapeClose (components/escapeClose.ts) is the one
  overlay contract, and the gate gains the modal's focus restore.
- The five/four identical useProjection reads in contextView/contextModal
  collapse into services.ts's projectionOf; agentTree drops its private
  asRecord copy for the exported one.
- agentTree's compact duration formatter is renamed fmtDurationCompact —
  same name as format.ts's locale-aware fmtDuration with different
  behavior was an import trap.
- Dead surface removed: Snapshot.occupancy (unfolded since 0.11 and
  undeliverable under the strict wire schema), CAT_COLORS,
  HistoryEventLike, and three exports nothing consumed (EVENT_ICONS,
  ContextViewProps, the FileSort alias).
- Comment drift fixed: the conversation join has a single useChat seat
  since the matrix narrowed to 0.1.2-rc.1+ (three sites), and
  services.ts's header no longer claims the module is type-only.
This commit is contained in:
Bowen Liang committed 2026-09-05 22:19:01 +08:00
1 parent 8205075877
commit cb93e4c74c
15 files changed
+227 -221

No files matched your search

+8 -8
View File
@@ -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<string, unknown> | null {
if (value === null || value === undefined || typeof value !== 'object') return null
return value as Record<string, unknown>
}
/** 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`
-3
View File
@@ -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<string, string> = 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 }
+2 -2
View File
@@ -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 (
<div className="lc-agents-inspector">
<b className="lc-agents-inspector-name">{node.label}</b>
+34 -97
View File
@@ -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<Map<number, ConversationNodeLike>>(() => new Map())
const bySeq = React.useMemo(() => {
const convBySeq = React.useMemo(() => {
const m = new Map<number, ConversationNodeLike>()
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'
? (
<button type="button" className="lc-br-retry" onClick={() => { setRetry(r => r + 1) }}>
{t('browser.loadFailed')}
</button>
)
: 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<Map<number, HeaderEpochContent>>(() => 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')
: (
<button type="button" className="lc-br-retry" onClick={() => { setHeaderRetry(r => r + 1) }}>
{t('browser.loadFailed')}
</button>
)
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)
+11 -31
View File
@@ -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<string | null>(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
+12 -25
View File
@@ -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<string, number>()
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<number | null>(null)
const [hoveredSeq, setHoveredSeq] = React.useState<number | null>(null)
const [hoverTurn, setHoverTurn] = React.useState<number | null>(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<number, ConversationNodeLike>()
@@ -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))
}
}
+28
View File
@@ -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])
}
+1 -1
View File
@@ -11,7 +11,7 @@ import type { ViewKit } from '../viewkit'
import { React } from '../react'
export const EVENT_ICONS: Record<string, string> = { compaction: '✂', prune: '✂', inject: '+', model: '⇄', mode: '⇄' }
const EVENT_ICONS: Record<string, string> = { compaction: '✂', prune: '✂', inject: '+', model: '⇄', mode: '⇄' }
export interface EventListProps { events: ContextEventRecord[] }
+88
View File
@@ -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<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.
*/
export function useFetchOnMiss<T>(
key: number | null,
fetcher: ((key: number) => Promise<T | null>) | undefined,
warn: string,
): FetchOnMiss<T> {
const [values, setValues] = React.useState<Map<number, T>>(() => new Map())
const [state, setState] = React.useState<FetchMissState>('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 (
<button type="button" className="lc-br-retry" onClick={onRetry}>
{t('browser.loadFailed')}
</button>
)
}
return t('browser.loading')
}
+1 -3
View File
@@ -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<FileFilter>('all')
// Mount-time default from the plugin settings card; in-card toggling stays mount-local and never writes back.
const [sort, setSort] = React.useState<FileSort>(() => settings.defaultFileSort())
const [sort, setSort] = React.useState<DefaultFileSort>(() => settings.defaultFileSort())
const [query, setQuery] = React.useState('')
const [openPath, setOpenPath] = React.useState<string | null>(null)
+4 -13
View File
@@ -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
+4 -6
View File
@@ -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 {
+4 -12
View File
@@ -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<PluginSettings> = z.object({
+23 -13
View File
@@ -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'
+7 -7
View File
@@ -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')
})
})