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:
1 parent
8205075877
commit
cb93e4c74c
15 files changed
+227
-221
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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[] }
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user