refactor(client): import React members directly, dropping the central react module

Every client file reached hooks and element types through the central
require seam (src/client/react.ts) and spelled them React.useState /
ReactNS.ReactElement. Import the members directly instead — named value
imports for hooks, inline type-only imports for element types — and let
the bundler own the single require('react'), which is exactly what the
platform module table answers.

This needs JSX off the classic runtime (it required a React identifier
in every tsx scope): tsconfig moves to jsx: react-jsx, so the bundle
sources jsx from 'react/jsx-runtime', already seeded by every supported
harness (verified against the dsh-v0.1.2-rc.1 platform.ts). The bundle
smoke's fake module table gains the specifier to stay a faithful shell
stand-in. h() survives as a local createElement alias at the slot
render-prop seams and the lightbox portal imports createPortal from
react-dom directly.

Verified: the built client.js requires react, react/jsx-runtime, and
react-dom exactly once each; the compat lane (bundle smoke + the
real-baseline matrix) passes against the fresh artifacts; the full gate
stays green with per-file coverage at 100%.
This commit is contained in:
Bowen Liang committed 2026-09-07 08:59:03 +08:00
1 parent e7e19dbff5
commit 4d144147a0
60 files changed
+298 -403

No files matched your search

+17 -19
View File
@@ -11,7 +11,7 @@
* session. A harness without the outward sessions service hides the card.
*/
import type * as ReactNS from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactElement } from 'react'
import { CATS } from '../categories'
import type { ClientCtx } from '../services'
import type { ViewKit } from '../viewkit'
@@ -27,8 +27,6 @@ import {
sessionsFaceOf,
} from '../agentTree'
import { React } from '../react'
export interface AgentGraphProps {
sessionId?: string
/** Live stats of the current session from the tab's own projections. */
@@ -49,35 +47,35 @@ export function ringColorOf(pct: number | null): string {
export function makeAgentGraph(
ctx: ClientCtx,
kit: ViewKit,
): (props: AgentGraphProps) => ReactNS.ReactElement | null {
): (props: AgentGraphProps) => ReactElement | null {
const { t, fmt, catLabel } = kit
function AgentGraph(props: AgentGraphProps): ReactNS.ReactElement | null {
function AgentGraph(props: AgentGraphProps): ReactElement | null {
// Resolved lazily at mount (not at apply): the outward sessions service
// belongs to the client runtime's composition, and a deployment without
// it simply keeps the card hidden.
const face = React.useMemo(() => sessionsFaceOf(ctx), [])
const subscribe = React.useCallback((fn: () => void) => {
const face = useMemo(() => sessionsFaceOf(ctx), [])
const subscribe = useCallback((fn: () => void) => {
if (face === null) return () => {}
/* v8 ignore next 2 -- sessionsFaceOf returns a face only after proving list.subscribe. */
if (face.list === undefined) return () => {}
return face.list.subscribe(fn)
}, [face])
const getSnapshot = React.useCallback(() => {
const getSnapshot = useCallback(() => {
if (face === null) return null
/* v8 ignore next 2 -- sessionsFaceOf proves list before returning the face. */
if (face.list === undefined) return null
return face.list.getSnapshot()
}, [face])
const snapshot = React.useSyncExternalStore(subscribe, getSnapshot)
const snapshot = useSyncExternalStore(subscribe, getSnapshot)
const sessionId = props.sessionId
const [hoverId, setHoverId] = React.useState<string | null>(null)
const [hoverId, setHoverId] = useState<string | null>(null)
// The layout is fully responsive: re-run it whenever the stage's visible
// width changes (sidebar toggles, window resizes, split views).
const stageRef = React.useRef<HTMLDivElement | null>(null)
const [stageWidth, setStageWidth] = React.useState(0)
React.useEffect(() => {
const stageRef = useRef<HTMLDivElement | null>(null)
const [stageWidth, setStageWidth] = useState(0)
useEffect(() => {
/* v8 ignore start -- jsdom has neither ResizeObserver nor layout; tests exercise the natural-pitch fallback (stageWidth 0). */
const el = stageRef.current
if (el === null || typeof ResizeObserver !== 'function') return
@@ -91,13 +89,13 @@ export function makeAgentGraph(
// Discover the current session's direct-child catalog once per session:
// catalog-derived children join the list rows (and gain navigation
// addresses). Fire-and-forget — the card renders from list rows alone.
React.useEffect(() => {
useEffect(() => {
if (face === null || typeof sessionId !== 'string' || sessionId === '') return
if (typeof face.refreshSubagents !== 'function') return
face.refreshSubagents(sessionId).catch(() => {})
}, [face, sessionId])
const built = React.useMemo(() => {
const built = useMemo(() => {
const forest = agentForestOf(snapshot, sessionId, props.self)
return forest !== null ? { forest, layout: layoutForest(forest, stageWidth) } : null
}, [snapshot, sessionId, props.self, stageWidth])
@@ -117,7 +115,7 @@ export function makeAgentGraph(
if (id === current.id) return
openAgentSession(face, id)
}
const keyOpen = (id: string) => (ev: ReactNS.KeyboardEvent) => {
const keyOpen = (id: string) => (ev: KeyboardEvent) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return
ev.preventDefault()
open(id)
@@ -225,12 +223,12 @@ interface NodeViewProps {
hovered: boolean
onHover: (id: string | null) => void
onOpen: (id: string) => void
onKeyOpen: (ev: ReactNS.KeyboardEvent) => void
onKeyOpen: (ev: KeyboardEvent) => void
t: ViewKit['t']
fmt: ViewKit['fmt']
}
function AgentNodeView(props: NodeViewProps): ReactNS.ReactElement {
function AgentNodeView(props: NodeViewProps): ReactElement {
const { node, x, y, captionW } = props
const pct = node.head !== null ? node.head.pct : null
const ring = 2 * Math.PI * AGENT_RING_R
@@ -286,7 +284,7 @@ function AgentNodeView(props: NodeViewProps): ReactNS.ReactElement {
}
/** The detail strip mirroring the hovered (or current) node: identity, occupancy, activity, and the open hint. */
function Inspector(props: { node: AgentNode; t: ViewKit['t']; fmt: ViewKit['fmt'] }): ReactNS.ReactElement {
function Inspector(props: { node: AgentNode; t: ViewKit['t']; fmt: ViewKit['fmt'] }): ReactElement {
const { node, t, fmt } = props
const bits: string[] = []
if (node.head !== null) {
+46 -47
View File
@@ -1,9 +1,8 @@
import type * as ReactNS from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type ReactElement, type ReactNode } from 'react'
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'
import { React } from '../react'
import type { ContentFetcher, ConversationNodeLike, HeaderFetcher } from '../services'
import type { ViewKit } from '../viewkit'
import { blockSummaryOf, callSummaryOf, parseCallArgs } from '../callSummary'
@@ -133,7 +132,7 @@ function ParamRow(props: {
name: string
schema: ParamSchema
required: boolean
}): ReactNS.ReactElement {
}): ReactElement {
const typeLabel = typeOf(props.schema)
const desc = props.schema.description
return (
@@ -158,10 +157,10 @@ function Section(props: {
label: string
labelClass?: string
count?: number
actions?: ReactNS.ReactNode
meta?: ReactNS.ReactNode
children: ReactNS.ReactNode
}): ReactNS.ReactElement {
actions?: ReactNode
meta?: ReactNode
children: ReactNode
}): ReactElement {
const right = props.actions !== undefined || props.meta !== undefined
return (
<div className="lc-ts-card">
@@ -186,10 +185,10 @@ function TextSection(props: {
text: string
rich: RichKit
lines: (n: number) => string
}): ReactNS.ReactElement {
}): ReactElement {
const { rich } = props
const [mode, setMode] = rich.useRichMode()
const lineCount = React.useMemo(() => lineCountOf(props.text), [props.text])
const lineCount = useMemo(() => lineCountOf(props.text), [props.text])
return (
<Section
label={props.label}
@@ -201,7 +200,7 @@ function TextSection(props: {
)
}
function RawSection(props: { label: string; text: string }): ReactNS.ReactElement {
function RawSection(props: { label: string; text: string }): ReactElement {
return (
<Section label={props.label}>
<pre className="lc-ts-desc-body lc-br-dim">{props.text}</pre>
@@ -232,15 +231,15 @@ function RowToolbar(props: {
placeholder: string
tip?: string
onChange: (v: string) => void
children?: ReactNS.ReactNode
}): ReactNS.ReactElement {
children?: ReactNode
}): ReactElement {
return (
<div className="lc-br-toolctl">
<input
className="lc-br-tool-search"
value={props.value}
placeholder={props.placeholder}
onChange={(ev: ReactNS.ChangeEvent<HTMLInputElement>) => { props.onChange(ev.target.value) }}
onChange={(ev: ChangeEvent<HTMLInputElement>) => { props.onChange(ev.target.value) }}
/>
{props.children !== undefined
? <span className="lc-gran" role="group" title={props.tip}>{props.children}</span>
@@ -267,11 +266,11 @@ function ToolSchema(props: {
show: string
hide: string
}
}): ReactNS.ReactElement {
}): ReactElement {
const { rich } = props
const [jsonOpen, setJsonOpen] = React.useState(false)
const params = React.useMemo(() => paramsOf(props.schema), [props.schema])
const rows = React.useMemo<{ name: string; schema: ParamSchema; required: boolean }[]>(() => {
const [jsonOpen, setJsonOpen] = useState(false)
const params = useMemo(() => paramsOf(props.schema), [props.schema])
const rows = useMemo<{ name: string; schema: ParamSchema; required: boolean }[]>(() => {
if (params === null) return []
const props = (params as { properties?: unknown }).properties
if (props === null || typeof props !== 'object') return []
@@ -290,7 +289,7 @@ function ToolSchema(props: {
// Pretty-printed only while the row's JSON is open: a tools section lists
// dozens of schemas, and eager stringification of every collapsed row
// dominated the section's render cost.
const schemaJson = React.useMemo(
const schemaJson = useMemo(
() => jsonOpen ? JSON.stringify(props.schema, null, 2) : '',
[props.schema, jsonOpen],
)
@@ -327,7 +326,7 @@ interface DetailLabels {
images: string
other: string
lines: (n: number) => string
callState: (err: boolean, exit: number | null) => ReactNS.ReactNode
callState: (err: boolean, exit: number | null) => ReactNode
}
/**
@@ -342,9 +341,9 @@ function BlocksBody(props: {
rich: RichKit
img: ImageKit
labels: DetailLabels
}): ReactNS.ReactElement {
}): ReactElement {
const { rich, img, labels } = props
const out: ReactNS.ReactNode[] = []
const out: ReactNode[] = []
let images: ImageRefLike[] = []
const flushImages = (): void => {
if (images.length === 0) return
@@ -456,9 +455,9 @@ function ToolCallCard(props: {
name: string
argsRaw: unknown
arrow?: string
status?: ReactNS.ReactNode
}): ReactNS.ReactElement {
const args = React.useMemo(() => parseCallArgs(props.argsRaw), [props.argsRaw])
status?: ReactNode
}): ReactElement {
const args = useMemo(() => parseCallArgs(props.argsRaw), [props.argsRaw])
return (
<Section
label={(props.arrow ?? '→') + ' ' + props.name}
@@ -474,7 +473,7 @@ function ToolCallCard(props: {
)
}
function CallArgRow(props: { name: string; value: unknown }): ReactNS.ReactElement {
function CallArgRow(props: { name: string; value: unknown }): ReactElement {
const v = props.value
/* v8 ignore next 2 -- the only caller maps Object.keys of a JSON.parse'd
object, which never holds undefined values; defensive. */
@@ -492,11 +491,11 @@ function CallArgRow(props: { name: string; value: unknown }): ReactNS.ReactEleme
function NodeContent(props: {
node: SurfaceNode
conv: ConversationNodeLike | undefined
hint: ReactNS.ReactNode
hint: ReactNode
rich: RichKit
img: ImageKit
labels: DetailLabels
}): ReactNS.ReactElement {
}): ReactElement {
const { node, conv, rich, img, labels } = props
if (conv === undefined) {
// The join missed (node outside the loaded window): the 80-char preview
@@ -562,8 +561,8 @@ function lastOfTurn(requests: RequestRecord[], turn: number): RequestRecord | nu
export function makeContextBrowser(
kit: ViewKit,
StackedBar: (props: StackedBarProps) => ReactNS.ReactElement,
): (props: ContextBrowserProps) => ReactNS.ReactElement {
StackedBar: (props: StackedBarProps) => ReactElement,
): (props: ContextBrowserProps) => ReactElement {
const { t, fmt, fmtTime, catLabel } = kit
const DetailNote = makeDetailNote(kit)
const nodeText = makeNodeText(kit)
@@ -573,24 +572,24 @@ export function makeContextBrowser(
// descriptions, system text, and message bodies stay in sync.
const lineLabel = (n: number): string => t(n === 1 ? 'block.line' : 'block.lines', { n })
return function ContextBrowser(props: ContextBrowserProps): ReactNS.ReactElement {
return function ContextBrowser(props: ContextBrowserProps): ReactElement {
const { data, headers } = props
// 'live' = the current surface (the NEXT request's context); number = a retained step's seq.
const [sel, setSel] = React.useState<'live' | number>('live')
const [openCat, setOpenCat] = React.useState<string | null>(null)
const [openElem, setOpenElem] = React.useState<string | null>(null)
const [sel, setSel] = useState<'live' | number>('live')
const [openCat, setOpenCat] = useState<string | null>(null)
const [openElem, setOpenElem] = useState<string | null>(null)
// The open category's row-filter text plus the tools' row order. The
// filter is a lens on the OPEN category: opening a different one resets
// it, while step picks (setOpenCat(null) below) keep it so the same lens
// compares epochs.
const [rowQuery, setRowQuery] = React.useState('')
const [toolSort, setToolSort] = React.useState<'size' | 'name'>('size')
const [rowQuery, setRowQuery] = useState('')
const [toolSort, setToolSort] = useState<'size' | 'name'>('size')
// Full message content: the conversation-window join first (zero cost),
// plus nodes fetched on demand for seqs outside the window (node arrays
// are stable references per snapshot; the map memoizes over them).
const convNodes = props.convNodes
const convBySeq = React.useMemo(() => {
const convBySeq = useMemo(() => {
const m = new Map<number, ConversationNodeLike>()
for (const n of convNodes ?? []) m.set(n.seq, n)
return m
@@ -610,7 +609,7 @@ export function makeContextBrowser(
fetchContent,
'dsh-context: targeted history read failed',
)
const bySeq = React.useMemo(() => {
const bySeq = 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)
@@ -619,17 +618,17 @@ export function makeContextBrowser(
// 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
React.useEffect(() => {
useEffect(() => {
setSel(pinSeq === null || pinSeq === undefined ? 'live' : pinSeq)
setOpenCat(null)
setOpenElem(null)
}, [pinSeq])
// Step-brief reveal: select the owning step, open the node's category + element (the pagination effect above already pulls older
// history for a missing join), then arm a one-shot scroll consumed by the layout effect once the row renders.
const rootRef = React.useRef<HTMLDivElement | null>(null)
const focusScrollRef = React.useRef(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const focusScrollRef = useRef(false)
const nodeFocus = props.nodeFocus
React.useEffect(() => {
useEffect(() => {
if (nodeFocus === null || nodeFocus === undefined) return
setSel(nodeFocus.step)
setOpenCat(nodeFocus.cat)
@@ -637,7 +636,7 @@ export function makeContextBrowser(
focusScrollRef.current = true
if (props.onNodeFocusHandled !== undefined) props.onNodeFocusHandled()
}, [nodeFocus, props.onNodeFocusHandled])
React.useLayoutEffect(() => {
useLayoutEffect(() => {
if (!focusScrollRef.current) return
focusScrollRef.current = false
rootRef.current?.querySelector('.lc-br-elem-on')?.scrollIntoView({ block: 'nearest' })
@@ -735,9 +734,9 @@ export function makeContextBrowser(
* result scans while collapsed.
*/
const elemRow = (
key: string, tag: ReactNS.ReactNode | null, preview: string,
tokens: number, time: number | undefined, body: ReactNS.ReactNode,
err = false, trailing: ReactNS.ReactNode = null,
key: string, tag: ReactNode | null, preview: string,
tokens: number, time: number | undefined, body: ReactNode,
err = false, trailing: ReactNode = null,
) => {
const open = openElem === key
return (
@@ -756,7 +755,7 @@ export function makeContextBrowser(
)
}
const catBody = (c: string): ReactNS.ReactNode => {
const catBody = (c: string): ReactNode => {
if (c === 'system') {
if (view.header === null) return <div className="lc-br-note">{t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}</div>
const content = headerContent.get(view.header.seq)
@@ -824,7 +823,7 @@ export function makeContextBrowser(
}
// The open row's body: the fetched content's description, parameter
// table, and raw JSON — or the epoch's fetch-state note.
const toolBody = (tool: HeaderTool): ReactNS.ReactNode => {
const toolBody = (tool: HeaderTool): ReactNode => {
if (content === undefined) return <div className="lc-br-note">{headerNote}</div>
const row = contentByName.get(tool.name)
return row === undefined
+4 -5
View File
@@ -9,11 +9,10 @@
* is not a plain string renders nothing at all.
*/
import { type ReactElement } from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type * as ReactNS from 'react'
import type { ConversationNodeLike, UseChatLike } from '../services'
import { conversationNodesOf } from '../services'
import { React } from '../react'
import { activateContextTab, requestContextFocus } from '../viewFocus'
import type { ViewKit } from '../viewkit'
@@ -41,7 +40,7 @@ export function seqOfMessageId(nodes: readonly ConversationNodeLike[] | undefine
}
/** The jump glyph: the plugin's mini stacked composition bars, same 16px outline family as the shipped row icons. */
function JumpIcon(): ReactNS.ReactElement {
function JumpIcon(): ReactElement {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2" y="3" width="12" height="2" rx="1" fill="currentColor" />
@@ -51,9 +50,9 @@ function JumpIcon(): ReactNS.ReactElement {
)
}
export function makeContextJumpButton(kit: ViewKit): (props: ContextJumpProps) => ReactNS.ReactElement | null {
export function makeContextJumpButton(kit: ViewKit): (props: ContextJumpProps) => ReactElement | null {
const { t } = kit
return function ContextJump(props: ContextJumpProps): ReactNS.ReactElement | null {
return function ContextJump(props: ContextJumpProps): ReactElement | null {
const messageId = props.messageId
// Interruption-frozen partials address no durable message — the owner
// already withholds them, and anything else non-string is ignored.
+11 -13
View File
@@ -4,7 +4,7 @@
* through the per-session modal store, so the trigger flips it and no message ever enters session history.
*/
import type * as ReactNS from 'react'
import { createElement as h, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import { measureDock } from '../dockMeasure'
import { headlineOf } from '../headline'
import { modalStoreOf, takePendingConsume } from '../modalStore'
@@ -19,14 +19,12 @@ import { makeErrorBoundary } from './errorBoundary'
import { useEscapeClose } from './escapeClose'
import { makeLegend, makeStackedBar } from './stackedBar'
import { React, h } from '../react'
export interface ContextModalProps extends SessionStandardProps {
/** Bound selector hook over the per-session open flag (hooks compartment). */
useContextModal?: (sel: (open: boolean) => boolean) => boolean
}
export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextModalProps) => ReactNS.ReactElement | null {
export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextModalProps) => ReactElement | null {
const { t } = kit
const StackedBar = makeStackedBar(kit)
const Legend = makeLegend(kit)
@@ -34,7 +32,7 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
const ContextBrowser = makeContextBrowser(kit, StackedBar)
const ErrorBoundary = makeErrorBoundary(t)
function ContextModalBody(props: ContextModalProps): ReactNS.ReactElement | null {
function ContextModalBody(props: ContextModalProps): ReactElement | null {
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
const open = typeof props.useContextModal === 'function' ? props.useContextModal(s => s) : false
// The timeline source (timelineSource.ts) — shares the tab's per-session
@@ -48,23 +46,23 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
// 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)
const [hoverCat, setHoverCat] = useState<string | null>(null)
// Dock the mask beside the shell sidebar: 0 until the frame measure lands
// (the layout effect below resolves it before first paint).
const [dockLeft, setDockLeft] = React.useState(0)
const backdropRef = React.useRef<HTMLDivElement | null>(null)
const [dockLeft, setDockLeft] = useState(0)
const backdropRef = useRef<HTMLDivElement | null>(null)
// Same targeted content fetch the Context tab wires (one seq-anchored history read per expanded row), plus the on-demand header
// epoch content read for the browser's system/tools sections.
const fetchContent = React.useMemo(
const fetchContent = useMemo(
() => (sessionId !== '' ? makeContentFetcher(sessionId) : undefined),
[sessionId],
)
const fetchHeader = React.useMemo(
const fetchHeader = useMemo(
() => (sessionId !== '' ? makeHeaderFetcher(sessionId) : undefined),
[sessionId],
)
const close = React.useCallback(() => {
const close = useCallback(() => {
if (sessionId === '') return
modalStoreOf(sessionId).set(false)
// Consume the `/context` token now (it stayed in the composer while the modal was open) via the scoped input event — a stale guard
@@ -85,7 +83,7 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
// first paint, then follow the frame's inline template while open (sidebar
// drags, collapse toggles and narrow-viewport re-solves all rewrite it).
// An unresolved frame keeps the full-viewport mask.
React.useLayoutEffect(() => {
useLayoutEffect(() => {
if (!open) return undefined
const dock = measureDock(backdropRef.current)
setDockLeft(dock.left)
@@ -138,7 +136,7 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
)
}
return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null {
return function ContextModal(props: ContextModalProps): ReactElement | null {
return h(ErrorBoundary, null, h(ContextModalBody, props))
}
}
+37 -38
View File
@@ -5,7 +5,7 @@
* host's detail endpoint only while the tab (or modal) is open.
*/
import type * as ReactNS from 'react'
import { createElement as h, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import type { ContextEventRecord, RequestRecord, SurfaceNode } from '../../shared/types'
import { briefNodes, briefOf } from '../brief'
import { headlineOf } from '../headline'
@@ -35,7 +35,6 @@ import { makeStatsTokens } from './statsTokens'
import { makeLegend, makeStackedBar } from './stackedBar'
import { aggregateByTurn, attachMarkers, jumpTargetOf, makeTrendChart } from './trendChart'
import { React, h } from '../react'
import { takeContextFocus } from '../viewFocus'
import { makeErrorBoundary } from './errorBoundary'
@@ -50,7 +49,7 @@ export function makeContextView(
ctx: ClientCtx,
kit: ViewKit,
settings: ContextSettings,
): (props: SessionStandardProps) => ReactNS.ReactElement {
): (props: SessionStandardProps) => ReactElement {
const { t } = kit
const StackedBar = makeStackedBar(kit)
const Legend = makeLegend(kit)
@@ -72,7 +71,7 @@ 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: SessionStandardProps): ReactNS.ReactElement {
function ContextViewBody(props: SessionStandardProps): ReactElement {
const sessionId = props.sessionId
// The timeline source (timelineSource.ts): the pushed value on the inline
// generation, or the slim head merged with the on-demand detail on the
@@ -92,21 +91,21 @@ export function makeContextView(
// `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 = 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)
const [selectedSeq, setSelectedSeq] = useState<number | null>(null)
const [hoveredSeq, setHoveredSeq] = useState<number | null>(null)
const [hoverTurn, setHoverTurn] = useState<number | null>(null)
// Mount-time default from the plugin settings card; in-chart toggling stays mount-local and never writes back.
const [granularity, setGranularity] = React.useState<'step' | 'turn'>(() => settings.defaultGranularity())
const [granularity, setGranularity] = useState<'step' | 'turn'>(() => settings.defaultGranularity())
// 'total' plots each request's cumulative composition, 'delta' its incremental change vs the previous one;
// like granularity, the default is read at mount and in-chart toggling never writes back.
const [trendMode, setTrendMode] = React.useState<'total' | 'delta'>(() => settings.defaultTrendMode())
const [trendMode, setTrendMode] = useState<'total' | 'delta'>(() => settings.defaultTrendMode())
// Strip-clicked turn: chart switches to turn granularity and scroll-centers that turn's bar, then clears via onFocusTurnHandled.
const [focusTurn, setFocusTurn] = React.useState<number | null>(null)
const [focusTurn, setFocusTurn] = useState<number | null>(null)
// Chat → Context jump: the assistant-action relay's one-shot request, held until the projection data is in, then resolved into a
// turn-level pin below (mirrors the strip-click's consume-once focus flow).
const [jumpSeq, setJumpSeq] = React.useState<number | null>(null)
const [hoverCat, setHoverCat] = React.useState<string | null>(null)
const [pickedKinds, setPickedKinds] = React.useState<string[]>([...EVENT_KINDS])
const [jumpSeq, setJumpSeq] = useState<number | null>(null)
const [hoverCat, setHoverCat] = useState<string | null>(null)
const [pickedKinds, setPickedKinds] = useState<string[]>([...EVENT_KINDS])
const toggleKind = (k: string) => {
setPickedKinds((p) => {
if (p.length === EVENT_KINDS.length) return [k]
@@ -115,37 +114,37 @@ export function makeContextView(
})
}
// Step-brief → browser reveal bridge: one-shot focus request consumed by the Context browser.
const [nodeFocus, setNodeFocus] = React.useState<{ step: number | 'live'; seq: number; cat: SurfaceNode['cat'] } | null>(null)
const clearNodeFocus = React.useCallback(() => { setNodeFocus(null) }, [])
const [nodeFocus, setNodeFocus] = useState<{ step: number | 'live'; seq: number; cat: SurfaceNode['cat'] } | null>(null)
const clearNodeFocus = useCallback(() => { setNodeFocus(null) }, [])
// Session-authorized durable-image loader for the browser's attachment cards, resolved through the harness `uiConversation` service
// (`imageUrl`); absent service/session degrades the cards to metadata-only, never an error.
const loadImage = React.useMemo(
const loadImage = useMemo(
() => imageLoaderOf(ctx, typeof sessionId === 'string' ? sessionId : undefined),
[ctx, sessionId],
)
// Targeted full-content fetch for browser nodes outside the conversation window (one seq-anchored history read per expanded row);
// absent face/session degrades to the static hint — never an error.
const fetchContent = React.useMemo(
const fetchContent = useMemo(
() => (typeof sessionId === 'string' && sessionId !== '' ? makeContentFetcher(sessionId) : undefined),
[sessionId],
)
// On-demand header epoch content (system prompt text, tool schemas) for the Context browser — one seq-anchored history read per
// opened epoch; the `contextHeaders` projection carries metadata only. Same degradation shape as fetchContent.
const fetchHeader = React.useMemo(
const fetchHeader = useMemo(
() => (typeof sessionId === 'string' && sessionId !== '' ? makeHeaderFetcher(sessionId) : undefined),
[sessionId],
)
const rootRef = React.useRef<HTMLDivElement | null>(null)
const scrollerRef = React.useRef<HTMLElement | null>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
const scrollerRef = useRef<HTMLElement | null>(null)
// The session whose position was already applied this mount — re-applying on re-renders would yank the reader's scroll.
const restoredRef = React.useRef<string | null>(null)
const restoredRef = useRef<string | null>(null)
// Restore the saved position (or the top on first visit) in a layout effect, so the chat's bottom-anchored position never flashes in
// first.
React.useLayoutEffect(() => {
useLayoutEffect(() => {
if (typeof sessionId !== 'string' || sessionId === '' || data === null) return
if (restoredRef.current === sessionId) return
restoredRef.current = sessionId
@@ -161,7 +160,7 @@ export function makeContextView(
// Save the position on unmount/session change — a layout-effect cleanup, so it fires before the incoming view's own layout effects
// re-scroll the shared container.
React.useLayoutEffect(() => {
useLayoutEffect(() => {
return () => {
if (typeof sessionId !== 'string' || sessionId === '') return
const scroller = scrollerRef.current
@@ -182,14 +181,14 @@ export function makeContextView(
const shownEvents = pickedKinds.length === EVENT_KINDS.length ? events : events.filter(e => pickedKinds.includes(e.kind))
// Per-step bars, or one per turn (each turn's LAST step's record); memoized so hover-driven re-renders keep bar props identity-stable —
// the chart's memoized bars then skip reconciliation (turn-mode aggregation allocates).
const displayRequests = React.useMemo(
const displayRequests = useMemo(
() => (granularity === 'turn' ? aggregateByTurn(requests) : requests),
[requests, granularity],
)
const markers = React.useMemo(() => attachMarkers(displayRequests, events), [displayRequests, events])
const markers = useMemo(() => attachMarkers(displayRequests, events), [displayRequests, events])
// Chat → Context jump, leg 1: pick up the assistant-action relay's request for this session (once per mount).
React.useEffect(() => {
useEffect(() => {
if (typeof sessionId !== 'string' || sessionId === '') return
const seq = takeContextFocus(sessionId)
if (seq !== null) setJumpSeq(seq)
@@ -201,7 +200,7 @@ export function makeContextView(
// The split generation waits for the detail read first: the relayed seq resolves against the served request records, so the
// one-shot request must not be consumed while the collections are still pending (it re-fires when they land).
const detailReady = source.detailState === 'ready' || source.detailState === 'legacy'
React.useEffect(() => {
useEffect(() => {
if (jumpSeq === null || data === null || !detailReady) return
setJumpSeq(null)
const target = jumpTargetOf(aggregateByTurn(requests), jumpSeq)
@@ -215,12 +214,12 @@ 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])
const briefList = useMemo(() => (data ? briefNodes(data) : []), [data])
// The conversation-window join, from the `useChat` seat. The seat is a
// real hook — invoked unconditionally per render (stable order);
// undefined join = render without it, never an error.
const convNodes = conversationNodesOf(props)
const bySeq = React.useMemo(() => {
const bySeq = useMemo(() => {
const m = new Map<number, ConversationNodeLike>()
for (const n of convNodes ?? []) m.set(n.seq, n)
return m
@@ -246,17 +245,17 @@ export function makeContextView(
filesBefore = ri + 1 < requests.length ? requests[ri + 1].seq : null
}
// The active bar's semantic identity ("what this step was about"); pure derivation over the served nodes, null when nothing is known.
const brief = React.useMemo(
const brief = useMemo(
() => (activeReq !== null ? briefOf(briefList, displayRequests, activeIdx) : null),
[activeReq, briefList, displayRequests, activeIdx],
)
const convOf = React.useCallback((seq: number): ConversationNodeLike | undefined => bySeq.get(seq), [bySeq])
const convOf = useCallback((seq: number): ConversationNodeLike | undefined => bySeq.get(seq), [bySeq])
// File activity follows the same active bar: the op-log generation reads the fold-derived records (full
// session-log coverage); older hosts keep the conversation-window derivation. Recomputed when the brief
// source, the conversation join, or the upper bound moves — hover/select elsewhere (composition chips,
// kind filters) leaves every input reference untouched, so the walk is skipped on those renders.
const fileActivity = React.useMemo(
const fileActivity = useMemo(
() => {
if (data !== null && data.fileOps !== undefined) {
return activityOfOps(data.fileOps, data.archive, filesBefore)
@@ -272,17 +271,17 @@ export function makeContextView(
// The capability is an RPC answer now (the synchronous host-description fact is gone),
// so it lands in state once the probe settles — inert file names until then; the
// effect's cleanup drops an answer that arrives after the view unmounted.
const [canOpenPaths, setCanOpenPaths] = React.useState(false)
React.useEffect(() => {
const [canOpenPaths, setCanOpenPaths] = useState(false)
useEffect(() => {
let live = true
void canOpenPathsOf(ctx).then((can) => { if (live) setCanOpenPaths(can) })
return () => { live = false }
}, [ctx])
const fileOpener = React.useMemo(
const fileOpener = useMemo(
() => (canOpenPaths ? openPathVia(ctx) : undefined),
[canOpenPaths, ctx],
)
const locateFileOp = React.useCallback((op: FileOp): void => {
const locateFileOp = useCallback((op: FileOp): void => {
// A nested Code-Mode op has no surface row of its own — it reveals on
// its parent run_code result (whose removal stamp the op carries).
const seq = op.parent ?? op.seq
@@ -292,7 +291,7 @@ export function makeContextView(
}, [requests])
// A brief row's reveal target: inputs/opener live in the picked step's OWN assembled surface; the response node (seq === the
// request's) first appears in the NEXT step's surface — or the live surface when the last bar is picked.
const locateNode = React.useCallback((node: SurfaceNode, isResponse: boolean): void => {
const locateNode = useCallback((node: SurfaceNode, isResponse: boolean): void => {
/* v8 ignore next 1 -- locateNode is only wired to brief rows, and
brief !== null guarantees activeReq !== null in the same closure. */
if (activeReq === null) return
@@ -502,7 +501,7 @@ export function makeContextView(
)
}
return function ContextView(props: SessionStandardProps): ReactNS.ReactElement {
return function ContextView(props: SessionStandardProps): ReactElement {
return h(ErrorBoundary, null, h(ContextViewBody, props))
}
}
+5 -7
View File
@@ -1,17 +1,15 @@
import type * as ReactNS from 'react'
import { type ReactElement } from 'react'
import type { PartsPart } from '../categories'
import type { Headline } from '../headline'
import type { ViewKit } from '../viewkit'
import { AUTO_COMPACT_RATIO } from './stackedBar'
import type { StackedBarProps } from './stackedBar'
import { React } from '../react'
type LegendFn = (props: {
parts: PartsPart[]
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
}) => ReactNS.ReactElement
}) => ReactElement
export interface CurrentCompositionProps {
head: Headline
@@ -22,11 +20,11 @@ export interface CurrentCompositionProps {
export function makeCurrentComposition(
kit: ViewKit,
StackedBar: (props: StackedBarProps) => ReactNS.ReactElement,
StackedBar: (props: StackedBarProps) => ReactElement,
Legend: LegendFn,
): (props: CurrentCompositionProps) => ReactNS.ReactElement {
): (props: CurrentCompositionProps) => ReactElement {
const { t, fmt } = kit
return function CurrentComposition(props: CurrentCompositionProps): ReactNS.ReactElement {
return function CurrentComposition(props: CurrentCompositionProps): ReactElement {
const head = props.head
const reserve = head.window != null && head.window > 0
? { ratio: AUTO_COMPACT_RATIO, label: t('overview.compactReserve', { pct: Math.round(AUTO_COMPACT_RATIO * 100) }) }
+3 -5
View File
@@ -8,11 +8,9 @@
* the plain text).
*/
import type * as ReactNS from 'react'
import { type ReactElement } from 'react'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
export function makeDetailNote(kit: ViewKit): (props: {
/** The pending kind: the first read in flight, or settled without data. */
state: 'loading' | 'failed'
@@ -20,13 +18,13 @@ export function makeDetailNote(kit: ViewKit): (props: {
onRetry?: () => void
/** The container's style class: cards use the shared empty well, the browser its inline note strip. */
className?: string
}) => ReactNS.ReactElement {
}) => ReactElement {
const { t } = kit
return function DetailNote(props: {
state: 'loading' | 'failed'
onRetry?: () => void
className?: string
}): ReactNS.ReactElement {
}): ReactElement {
const cls = props.className ?? 'lc-empty'
if (props.state === 'loading') return <div className={cls}>{t('detail.loading')}</div>
return (
+5 -7
View File
@@ -7,11 +7,9 @@
* size, so the label always fits inside the hole the thin stroke leaves.
*/
import type * as ReactNS from 'react'
import { type ReactElement, type ReactNode } from 'react'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
export interface DonutSegment {
key: string
color: string
@@ -21,9 +19,9 @@ export interface DonutSegment {
export interface DonutProps {
segments: DonutSegment[]
/** The big center figure (a duration, a token count, a percentage). */
centerTop: ReactNS.ReactNode
centerTop: ReactNode
/** The small caption under the center figure. */
centerSub?: ReactNS.ReactNode
centerSub?: ReactNode
/** Outer size in px (default 118). */
size?: number
/** The hovered slice key — the legend row ↔ segment hover link. */
@@ -32,9 +30,9 @@ export interface DonutProps {
onHoverKey?: (key: string | null) => void
}
export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactNS.ReactElement {
export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactElement {
void kit
return function Donut(props: DonutProps): ReactNS.ReactElement {
return function Donut(props: DonutProps): ReactElement {
const size = props.size ?? 118
let total = 0
for (const s of props.segments) {
+5 -7
View File
@@ -5,14 +5,12 @@
* subtree's render errors (no hook-based boundary in React 18); Retry resets the boundary and a healthy value resumes.
*/
import type * as ReactNS from 'react'
import { Component, type ComponentType, type ReactNode } from 'react'
import type { Translate } from '../i18n'
import { React } from '../react'
export function makeErrorBoundary(t: Translate): ReactNS.ComponentType<{ children?: ReactNS.ReactNode }> {
return class ErrorBoundary extends React.Component<{ children?: ReactNS.ReactNode }, { error: Error | null }> {
constructor(props: { children?: ReactNS.ReactNode }) {
export function makeErrorBoundary(t: Translate): ComponentType<{ children?: ReactNode }> {
return class ErrorBoundary extends Component<{ children?: ReactNode }, { error: Error | null }> {
constructor(props: { children?: ReactNode }) {
super(props)
this.state = { error: null }
}
@@ -21,7 +19,7 @@ export function makeErrorBoundary(t: Translate): ReactNS.ComponentType<{ childre
return { error: error instanceof Error ? error : new Error(String(error)) }
}
render(): ReactNS.ReactNode {
render(): ReactNode {
const error = this.state.error
if (error === null) return this.props.children
return (
+2 -2
View File
@@ -6,11 +6,11 @@
* that element left the document).
*/
import { React } from '../react'
import { useEffect } from 'react'
/** Close on Escape while `active`; restores the pre-open focus on cleanup. */
export function useEscapeClose(active: boolean, onClose: () => void): void {
React.useEffect(() => {
useEffect(() => {
if (!active) return undefined
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null
const onKey = (ev: KeyboardEvent): void => {
+6 -8
View File
@@ -3,7 +3,7 @@
* compaction/prune keep the ✂ marker — no shared glyph exists for it.
*/
import type * as ReactNS from 'react'
import { useEffect, useLayoutEffect, useRef, type ReactElement } from 'react'
import type { ContextEventRecord } from '../../shared/types'
import { IconBranchOutline16, IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Translate } from '../i18n'
@@ -11,8 +11,6 @@ import type { DetailState } from '../timelineSource'
import type { ViewKit } from '../viewkit'
import { makeDetailNote } from './detailNote'
import { React } from '../react'
const EVENT_ICONS: Record<string, string> = { compaction: '✂', prune: '✂', inject: '+', model: '⇄', mode: '⇄' }
export interface EventListProps {
@@ -79,23 +77,23 @@ function syncTitles(root: HTMLElement): void {
}
}
export function makeEventList(kit: ViewKit): (props: EventListProps) => ReactNS.ReactElement {
export function makeEventList(kit: ViewKit): (props: EventListProps) => ReactElement {
const { t, fmt, fmtTime, eventLabel, eventAt } = kit
const DetailNote = makeDetailNote(kit)
return function EventList(props: EventListProps): ReactNS.ReactElement {
return function EventList(props: EventListProps): ReactElement {
// Hooks stay unconditional (Rules of Hooks): events going empty ->
// non-empty in one mounted instance must not grow the hook count — an
// early return above these hooks is a React #310 class bug (issue #12).
const rootRef = React.useRef<HTMLDivElement | null>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
// Truncation titles ride a resize-only listener plus an events-driven resync: the effect MUST NOT run
// on every render — a plain re-render (hover/select elsewhere in the view) would re-read scrollWidth /
// clientWidth for every label, one forced synchronous layout per row, and rebind the listener.
React.useLayoutEffect(() => {
useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
syncTitles(root)
}, [props.events])
React.useEffect(() => {
useEffect(() => {
const onResize = (): void => {
const root = rootRef.current
if (root !== null) syncTitles(root)
+6 -8
View File
@@ -8,11 +8,9 @@
* history is immutable, so a resolved key never refetches.
*/
import type * as ReactNS from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import type { Translate } from '../i18n'
import { React } from '../react'
export type FetchMissState = 'idle' | 'loading' | 'absent' | 'failed'
export interface FetchOnMiss<T> {
@@ -33,10 +31,10 @@ export function useFetchOnMiss<T>(
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(() => {
const [values, setValues] = useState<Map<number, T>>(() => new Map())
const [state, setState] = useState<FetchMissState>('idle')
const [attempt, setAttempt] = useState(0)
useEffect(() => {
if (key === null || fetcher === undefined || values.has(key)) return
let live = true
setState('loading')
@@ -74,7 +72,7 @@ export function fetchMissNote(
state: FetchMissState,
onRetry: () => void,
emptyKey: 'browser.noContent' | 'browser.headerMetaOnly',
): ReactNS.ReactNode {
): ReactNode {
if (fetcher === undefined) return t(emptyKey)
if (state === 'absent') return t('browser.notInLog')
if (state === 'failed') {
+11 -13
View File
@@ -11,7 +11,7 @@
* target that jumps to (and reveals) the exact tool result in the browser.
*/
import type * as ReactNS from 'react'
import { memo, useState, type ChangeEvent, type ComponentType, type MouseEvent, type ReactElement } from 'react'
import { absPathOf, displayPathOf, glyphOf } from '../fileActivity'
import type { FileActivity, FileEntry, FileOp, FileOpKind } from '../fileActivity'
import type { ContextSettings, DefaultFileSort } from '../settings'
@@ -19,8 +19,6 @@ import type { DetailState } from '../timelineSource'
import type { ViewKit } from '../viewkit'
import { makeDetailNote } from './detailNote'
import { React } from '../react'
export type FileFilter = 'all' | FileOpKind | 'image'
export interface FileCardProps {
@@ -42,7 +40,7 @@ export interface FileCardProps {
onRetry?: () => void
}
export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.ComponentType<FileCardProps> {
export function makeFileCard(kit: ViewKit, settings: ContextSettings): ComponentType<FileCardProps> {
const { t, fmt, fmtTime } = kit
const DetailNote = makeDetailNote(kit)
@@ -55,7 +53,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
}
/** Signed line pair, harness diff semantics: growth on the success token, shrinkage on the error token. */
function DeltaPair(props: { added: number; removed: number }): ReactNS.ReactElement {
function DeltaPair(props: { added: number; removed: number }): ReactElement {
return (
<span className="lc-fa-delta">
{props.added > 0 ? <span className="lc-fa-up">{'+' + fmt(props.added)}</span> : null}
@@ -66,13 +64,13 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
// Memoized at the factory level: the parent's activity/scope/onLocate props are all reference-stable across
// hover/select renders (see contextView), so this card skips reconciliation whenever its own inputs did not move.
return React.memo(function FileCard(props: FileCardProps): ReactNS.ReactElement {
return memo(function FileCard(props: FileCardProps): ReactElement {
const { activity } = props
const [filter, setFilter] = React.useState<FileFilter>('all')
const [filter, setFilter] = 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<DefaultFileSort>(() => settings.defaultFileSort())
const [query, setQuery] = React.useState('')
const [openPath, setOpenPath] = React.useState<string | null>(null)
const [sort, setSort] = useState<DefaultFileSort>(() => settings.defaultFileSort())
const [query, setQuery] = useState('')
const [openPath, setOpenPath] = useState<string | null>(null)
const q = query.trim().toLowerCase()
// Display form of an entry's path: './'-relative inside the workspace, verbatim outside —
@@ -105,7 +103,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
{ key: 'image', files: activity.totals.image.files, ops: activity.totals.image.ops },
]
const opLine = (op: FileOp): ReactNS.ReactElement => (
const opLine = (op: FileOp): ReactElement => (
<>
<span className="lc-fa-op-tool" title={op.tool}>{op.tool}</span>
{/* A read's line footprint: the exact `>>n` window off the result meta, or the ≈ limit estimate. */}
@@ -169,7 +167,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
className="lc-fa-search"
value={query}
placeholder={t('files.search')}
onChange={(ev: ReactNS.ChangeEvent<HTMLInputElement>) => { setQuery(ev.target.value) }}
onChange={(ev: ChangeEvent<HTMLInputElement>) => { setQuery(ev.target.value) }}
/>
</div>
<div className="lc-fa-meta">
@@ -234,7 +232,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
<b
className="lc-fa-file"
title={t('files.open')}
onClick={(ev: ReactNS.MouseEvent) => { ev.stopPropagation(); props.onOpen?.(abs) }}
onClick={(ev: MouseEvent) => { ev.stopPropagation(); props.onOpen?.(abs) }}
>
{base}
</b>
+15 -15
View File
@@ -4,16 +4,16 @@
* handed down as `load`; absent loader or failed load degrades to the metadata row alone — the card never throws.
*/
import type * as ReactNS from 'react'
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
import { createPortal } from 'react-dom'
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { fmtBytes } from '../format'
import { React, ReactDOM } from '../react'
import type { ImageLoader, ImageRefLike } from '../services'
import { estimateImageTokens } from '../../shared/imageTokens'
import type { ViewKit } from '../viewkit'
export interface ImageKit {
Card: (props: { attachment: ImageRefLike; load?: ImageLoader }) => ReactNS.ReactElement
Card: (props: { attachment: ImageRefLike; load?: ImageLoader }) => ReactElement
load?: ImageLoader
}
@@ -58,12 +58,12 @@ function AttachmentLightbox(props: {
alt: string
labels: { dialog: string; close: string }
onClose: () => void
}): ReactNS.ReactElement {
}): ReactElement {
const { src, alt, labels, onClose } = props
const closeRef = React.useRef<HTMLButtonElement | null>(null)
const restoreRef = React.useRef<HTMLElement | null>(null)
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
React.useEffect(() => {
useEffect(() => {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: KeyboardEvent): void => {
@@ -76,7 +76,7 @@ function AttachmentLightbox(props: {
}
}, [onClose])
return ReactDOM.createPortal(
return createPortal(
<div className="lc-att-lightbox" role="dialog" aria-modal="true" aria-label={labels.dialog}>
<div className="lc-att-lightbox-mask" aria-hidden="true" onMouseDown={onClose} />
<img className="lc-att-lightbox-img" src={src} alt={alt} />
@@ -95,15 +95,15 @@ function AttachmentLightbox(props: {
*/
export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
const { t, fmt } = kit
return function ImageCard(props: { attachment: ImageRefLike; load?: ImageLoader }): ReactNS.ReactElement {
return function ImageCard(props: { attachment: ImageRefLike; load?: ImageLoader }): ReactElement {
const { attachment, load } = props
const [src, setSrc] = React.useState<string | null>(null)
const [error, setError] = React.useState(false)
const [attempt, setAttempt] = React.useState(0)
const [preview, setPreview] = React.useState(false)
const closePreview = React.useCallback(() => { setPreview(false) }, [])
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [attempt, setAttempt] = useState(0)
const [preview, setPreview] = useState(false)
const closePreview = useCallback(() => { setPreview(false) }, [])
React.useEffect(() => {
useEffect(() => {
if (load === undefined) return
let live = true
setError(false)
+7 -9
View File
@@ -3,27 +3,25 @@
* meta.ts); one live npm-registry check (latestVersion.ts, 1-hour TTL) appends an `↑ vX.Y.Z` chip when newer.
*/
import type * as ReactNS from 'react'
import { useEffect, useState, type ReactElement, type ReactNode } from 'react'
import { fetchLatestVersion, isNewerVersion } from '../latestVersion'
import { PLUGIN_NAME, PLUGIN_REPO, PLUGIN_REPO_SHORT, PLUGIN_VERSION } from '../meta'
import { openPluginSettings } from '../settingsJump'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
export function makePluginInfo(kit: ViewKit): () => ReactNS.ReactElement {
export function makePluginInfo(kit: ViewKit): () => ReactElement {
const { t } = kit
// `title` carries the untruncated value: at narrow card widths the row's
// ellipsis can cut the repo or name short, and the hover text recovers it.
const row = (label: string, value: ReactNS.ReactNode, href: string, hint: string) => (
const row = (label: string, value: ReactNode, href: string, hint: string) => (
<a className="lc-pi-row" href={href} target="_blank" rel="noreferrer">
<div className="lc-pi-label">{label}</div>
<div className="lc-pi-value" title={hint}>{value}</div>
</a>
)
return function PluginInfo(): ReactNS.ReactElement {
const [latest, setLatest] = React.useState<string | null>(null)
React.useEffect(() => {
return function PluginInfo(): ReactElement {
const [latest, setLatest] = useState<string | null>(null)
useEffect(() => {
if (PLUGIN_VERSION.includes('-dev')) return
let on = true
// Fire-and-forget: fetchLatestVersion never rejects (every failure
@@ -33,7 +31,7 @@ export function makePluginInfo(kit: ViewKit): () => ReactNS.ReactElement {
}, [])
const update = latest !== null && isNewerVersion(latest, PLUGIN_VERSION) ? latest : null
const nameText = PLUGIN_NAME + ' (v' + PLUGIN_VERSION + ')'
const nameValue: ReactNS.ReactNode[] = [nameText]
const nameValue: ReactNode[] = [nameText]
if (update) nameValue.push(<span key="update" className="lc-pi-update">{'↑ v' + update}</span>)
return (
<div className="lc-card">
+9 -11
View File
@@ -1,4 +1,4 @@
import type * as ReactNS from 'react'
import { memo, type ComponentType, type MouseEvent, type ReactElement, type ReactNode } from 'react'
import type { ContextEventRecord, RequestRecord, SurfaceNode } from '../../shared/types'
import { partsOf, CATS } from '../categories'
import { cacheHitPercent } from '../format'
@@ -8,8 +8,6 @@ import type { ConversationNodeLike } from '../services'
import type { StackedBarProps } from './stackedBar'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
export interface RequestDetailProps {
request: RequestRecord | null
/**
@@ -35,8 +33,8 @@ export interface RequestDetailProps {
export function makeRequestDetail(
kit: ViewKit,
StackedBar: (props: StackedBarProps) => ReactNS.ReactElement,
): ReactNS.ComponentType<RequestDetailProps> {
StackedBar: (props: StackedBarProps) => ReactElement,
): ComponentType<RequestDetailProps> {
const { t, fmt, fmtTime, catLabel, eventLabel, eventAt } = kit
/**
@@ -51,8 +49,8 @@ export function makeRequestDetail(
node?: SurfaceNode
isResponse: boolean
onLocate?: (node: SurfaceNode, isResponse: boolean) => void
children: ReactNS.ReactNode
}): ReactNS.ReactElement {
children: ReactNode
}): ReactElement {
const inner = (
<>
<span className="lc-brief-tag">
@@ -143,21 +141,21 @@ export function makeRequestDetail(
brief: StepBrief | null | undefined
convOf?: (seq: number) => ConversationNodeLike | undefined
onLocate?: (node: SurfaceNode, isResponse: boolean) => void
}): ReactNS.ReactElement {
}): ReactElement {
const { opener, inputs, response } = props.brief ?? { inputs: [] }
const convOf = props.convOf ?? (() => undefined)
// The content span's native title: full preview, plus the locate hint when the row is clickable.
const hint = props.onLocate !== undefined ? ' — ' + t('brief.locate') : ''
// Chip click: locate THIS chip's node; stopPropagation keeps it from also firing the row's own locate.
const locateChip = props.onLocate === undefined ? undefined : (n: SurfaceNode) =>
(e?: ReactNS.MouseEvent) => { e?.stopPropagation(); props.onLocate?.(n, false) }
(e?: MouseEvent) => { e?.stopPropagation(); props.onLocate?.(n, false) }
const MAX_CHIPS = 3
/**
* The ONE content unit of the brief — inputs and the reply share the same chip anatomy (error dot, fact tag,
* preview text). Input chips are compact and individually clickable (each locates its own node); the reply is a
* single chip grown to the row's width, left inert because the row button already locates it.
*/
const nodeChip = (n: SurfaceNode, onClick?: (e?: ReactNS.MouseEvent) => void, grow = false): ReactNS.ReactElement => {
const nodeChip = (n: SurfaceNode, onClick?: (e?: MouseEvent) => void, grow = false): ReactElement => {
const { tag, text } = chipParts(n, convOf(n.seq))
return (
<span
@@ -208,7 +206,7 @@ export function makeRequestDetail(
// Memoized at the factory level: the parent hands reference-stable request/prev/marker/brief/convOf/onLocate
// across hover/select renders (see contextView), so the panel skips reconciliation when the active bar did not move.
return React.memo(function RequestDetail(props: RequestDetailProps): ReactNS.ReactElement | null {
return memo(function RequestDetail(props: RequestDetailProps): ReactElement | null {
const req = props.request
if (!req) return null
const isTurn = req.stepCount !== undefined && req.stepCount > 1
+10 -11
View File
@@ -4,9 +4,8 @@
* switch sits at a section head's right edge (RichSwitch; per-card mode via useRichMode).
*/
import type * as ReactNS from 'react'
import { useMemo, useState, type ReactElement } from 'react'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { React } from '../react'
import type { ViewKit } from '../viewkit'
export type RichMode = 'raw' | 'md'
@@ -21,11 +20,11 @@ interface MarkdownChrome {
footnotes: string
}
const Markdown = MarkdownText as (props: { text: string; labels?: MarkdownChrome }) => ReactNS.ReactElement
const Markdown = MarkdownText as (props: { text: string; labels?: MarkdownChrome }) => ReactElement
export interface RichKit {
RichText: (props: { text: string; mode: RichMode }) => ReactNS.ReactElement
RichSwitch: (props: { mode: RichMode; onPick: (mode: RichMode) => void }) => ReactNS.ReactElement
RichText: (props: { text: string; mode: RichMode }) => ReactElement
RichSwitch: (props: { mode: RichMode; onPick: (mode: RichMode) => void }) => ReactElement
useRichMode: () => [RichMode, (mode: RichMode) => void]
}
@@ -36,11 +35,11 @@ export function makeRichText(kit: ViewKit): RichKit {
// Markdown is the default view: the detail cards hold prose (prompts,
// descriptions, messages), which reads better rendered; raw stays one
// click away for exact source inspection.
const [mode, setMode] = React.useState<RichMode>('md')
const [mode, setMode] = useState<RichMode>('md')
return [mode, setMode]
}
function RichSwitch(props: { mode: RichMode; onPick: (mode: RichMode) => void }): ReactNS.ReactElement {
function RichSwitch(props: { mode: RichMode; onPick: (mode: RichMode) => void }): ReactElement {
const seg = (m: RichMode, label: string, tip: string) => (
<button
type="button"
@@ -60,8 +59,8 @@ export function makeRichText(kit: ViewKit): RichKit {
// One block per source line: the number is a counter-fed ::before glued to
// its own line across soft wraps, and pseudo content never reaches the
// clipboard, so selecting the body still copies the exact source text.
function RawText(props: { text: string }): ReactNS.ReactElement {
const lines = React.useMemo(() => {
function RawText(props: { text: string }): ReactElement {
const lines = useMemo(() => {
const parts = props.text.split('\n')
return parts.length > 1 && parts[parts.length - 1] === '' ? parts.slice(0, -1) : parts
}, [props.text])
@@ -74,10 +73,10 @@ export function makeRichText(kit: ViewKit): RichKit {
)
}
function RichText(props: { text: string; mode: RichMode }): ReactNS.ReactElement {
function RichText(props: { text: string; mode: RichMode }): ReactElement {
// Reference-stable per locale: a fresh object identity would discard the
// renderer's cached elements on every render (0.1.2+ faces).
const mdLabels = React.useMemo<MarkdownChrome>(() => ({
const mdLabels = useMemo<MarkdownChrome>(() => ({
code: { copyLabel: t('rich.md.copy'), copiedLabel: t('rich.md.copied') },
footnotes: t('rich.md.footnotes'),
}), [t])
+8 -9
View File
@@ -8,9 +8,8 @@
* fresh expand request (settingsJump.ts), scrolling itself into view.
*/
import type * as ReactNS from 'react'
import { useEffect, useRef, useState, type ReactElement } from 'react'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import { React } from '../react'
import { consumeCardExpand } from '../settingsJump'
import type { SettingsField, SettingsState } from '../settings'
import type { ViewKit } from '../viewkit'
@@ -28,8 +27,8 @@ interface PrefRowProps {
onPick: (id: string) => void
}
function PrefRow(props: PrefRowProps): ReactNS.ReactElement {
const [open, setOpen] = React.useState(false)
function PrefRow(props: PrefRowProps): ReactElement {
const [open, setOpen] = useState(false)
const active = props.options.find(o => o.id === props.value)?.label ?? props.value
return (
<div className="lc-settings-row">
@@ -60,14 +59,14 @@ function PrefRow(props: PrefRowProps): ReactNS.ReactElement {
)
}
export function makeSettingsCard(kit: ViewKit): (props: SettingsCardProps) => ReactNS.ReactElement | null {
export function makeSettingsCard(kit: ViewKit): (props: SettingsCardProps) => ReactElement | null {
const { t } = kit
return function SettingsCard(props: SettingsCardProps): ReactNS.ReactElement | null {
const [open, setOpen] = React.useState(false)
const itemRef = React.useRef<HTMLLIElement | null>(null)
return function SettingsCard(props: SettingsCardProps): ReactElement | null {
const [open, setOpen] = useState(false)
const itemRef = useRef<HTMLLIElement | null>(null)
// "Open in Settings" jump: consume its fresh expand request once on mount
// and land open; every guard stays local so no host quirk can surface.
React.useEffect(() => {
useEffect(() => {
if (!consumeCardExpand()) return
setOpen(true)
try {
+3 -5
View File
@@ -9,11 +9,9 @@
* dumb markup out.
*/
import type * as ReactNS from 'react'
import { type ReactElement } from 'react'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
export interface SliceRow {
key: string
color: string
@@ -32,13 +30,13 @@ export function makeSliceList(kit: ViewKit): (props: {
hoverKey?: string | null
/** Hover relay; absent renders the rows inert. */
onHoverKey?: (key: string | null) => void
}) => ReactNS.ReactElement {
}) => ReactElement {
void kit
return function SliceList(props: {
rows: SliceRow[]
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
}): ReactNS.ReactElement {
}): ReactElement {
return (
<div className="lc-sl" onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}>
{props.rows.map(r => (
+6 -8
View File
@@ -3,12 +3,10 @@
* styled through the shared `--dsw-alias-*` tokens.
*/
import type * as ReactNS from 'react'
import { useState, type ReactElement } from 'react'
import type { PartsPart } from '../categories'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
/**
* Mirror of dsh-compaction-basic's default `thresholdRatio` (0.8): it compacts at step boundaries once `floor(contextWindow × ratio)` is
* reached; DSH does not publish the configured ratio to plugins/clients, so the reserve band mirrors the default — deployments tuning
@@ -34,11 +32,11 @@ export interface StackedBarProps {
reserve?: { ratio: number; label: string }
}
export function makeStackedBar(kit: ViewKit): (props: StackedBarProps) => ReactNS.ReactElement {
export function makeStackedBar(kit: ViewKit): (props: StackedBarProps) => ReactElement {
const { t, fmt, catLabel } = kit
return function StackedBar(props: StackedBarProps): ReactNS.ReactElement {
return function StackedBar(props: StackedBarProps): ReactElement {
// The reserve-hover flag lives here so the single tooltip slot serves both the segments and the band.
const [reserveOn, setReserveOn] = React.useState(false)
const [reserveOn, setReserveOn] = useState(false)
let total = 0
for (const p of props.parts) total += p.value
const scale = props.max !== undefined && props.max > total ? props.max : total
@@ -169,13 +167,13 @@ export function makeLegend(kit: ViewKit): (props: {
parts: PartsPart[]
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
}) => ReactNS.ReactElement {
}) => ReactElement {
const { t, fmt, catLabel } = kit
return function Legend(props: {
parts: PartsPart[]
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
}): ReactNS.ReactElement {
}): ReactElement {
let total = 0
for (const p of props.parts) total += p.raw ?? p.value
return (
+5 -7
View File
@@ -17,14 +17,12 @@
* (`countsOfRecords`). The card itself never touches the collections.
*/
import type * as ReactNS from 'react'
import { type ReactElement, type ReactNode } from 'react'
import type { ContextEventRecord, RequestRecord, SessionCostUsage, TimelineCounts } from '../../shared/types'
import { estimateSessionCost, formatCost, formatPriceRate, sessionPrices } from '../cost'
import type { CostCurrency } from '../cost'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
/**
* The inline generation's counter derivation — the exact tally the card ran
* over the served collections before the split (distinct turn values, record
@@ -54,7 +52,7 @@ export function makeStatsContext(kit: ViewKit): (props: {
images?: number
cost?: SessionCostUsage
locale: string
}) => ReactNS.ReactElement {
}) => ReactElement {
const { t, fmt } = kit
return function StatsContext(props: {
counts: TimelineCounts
@@ -62,11 +60,11 @@ export function makeStatsContext(kit: ViewKit): (props: {
images?: number
cost?: SessionCostUsage
locale: string
}): ReactNS.ReactElement {
}): ReactElement {
const currency: CostCurrency = props.locale === 'zh' ? 'cny' : 'usd'
const cost = estimateSessionCost(props.cost, currency)
const fmtRate = (n: number): string => formatPriceRate(n, currency)
const costTip: ReactNS.ReactNode = [
const costTip: ReactNode = [
t('stats.costTip'),
<span key="prices" className="lc-stat-tip-prices">
<span className="lc-stat-tip-head">{t('stats.costPriceHead')}</span>
@@ -80,7 +78,7 @@ export function makeStatsContext(kit: ViewKit): (props: {
))}
</span>,
]
const cell = (label: string, value: string | number, tip?: ReactNS.ReactNode): ReactNS.ReactElement => (
const cell = (label: string, value: string | number, tip?: ReactNode): ReactElement => (
<div className={'lc-stat' + (tip === undefined ? '' : ' lc-stat-tipped')}>
<span className="lc-stat-label">
{label}
+5 -6
View File
@@ -16,8 +16,7 @@
* page down.
*/
import type * as ReactNS from 'react'
import { React, h } from '../react'
import { createElement as h, useEffect, useRef, type ReactElement } from 'react'
import { activateContextTab } from '../viewFocus'
import type { ViewKit } from '../viewkit'
@@ -116,12 +115,12 @@ export function wireDock(anchor: Element | null, label: string, activate: () =>
}
/** The dock entry component: the invisible anchor plus the wiring around it. */
export function makeStatsJump(kit: ViewKit): () => ReactNS.ReactElement {
export function makeStatsJump(kit: ViewKit): () => ReactElement {
const { t } = kit
return function StatsJump(): ReactNS.ReactElement {
const anchorRef = React.useRef<Element | null>(null)
return function StatsJump(): ReactElement {
const anchorRef = useRef<Element | null>(null)
const label = t('jump.statsLine')
React.useEffect(() => wireDock(anchorRef.current, label, () => { activateContextTab(t('tab')) }), [label])
useEffect(() => wireDock(anchorRef.current, label, () => { activateContextTab(t('tab')) }), [label])
return h('span', { ref: anchorRef, className: ANCHOR_CLASS, hidden: true, 'aria-hidden': 'true' })
}
}
+5 -7
View File
@@ -11,26 +11,24 @@
* into the post-model window while the row numbers stay true.
*/
import type * as ReactNS from 'react'
import { useState, type ReactElement } from 'react'
import type { TimingTotals } from '../../shared/types'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
import { makeSliceList } from './sliceList'
import type { SliceRow } from './sliceList'
import type { DonutProps, DonutSegment } from './donut'
export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => ReactNS.ReactElement): (props: {
export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => ReactElement): (props: {
timing: TimingTotals | null
locale: string
}) => ReactNS.ReactElement {
}) => ReactElement {
const { t, fmt, fmtDuration, fmtShare } = kit
const SliceList = makeSliceList(kit)
return function StatsTiming(props: { timing: TimingTotals | null; locale: string }): ReactNS.ReactElement {
return function StatsTiming(props: { timing: TimingTotals | null; locale: string }): ReactElement {
const lang: 'zh' | 'en' = props.locale === 'zh' ? 'zh' : 'en'
// The legend row ↔ donut segment hover link (shared key, set from either side).
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
const [hoverKey, setHoverKey] = useState<string | null>(null)
const timing = props.timing
const wall = timing !== null && Number.isFinite(timing.wallMs) && timing.wallMs > 0 ? timing.wallMs : 0
let segments: DonutSegment[] = []
+5 -7
View File
@@ -8,26 +8,24 @@
* the empty state keeps all four rows.
*/
import type * as ReactNS from 'react'
import { useState, type ReactElement } from 'react'
import type { TokenUsage } from '../../shared/types'
import { cacheHitPercent as cacheHitPercentOf } from '../format'
import { numOf } from '../services'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
import { makeSliceList } from './sliceList'
import type { SliceRow } from './sliceList'
import type { DonutProps } from './donut'
export function makeStatsTokens(kit: ViewKit, Donut: (props: DonutProps) => ReactNS.ReactElement): (props: {
export function makeStatsTokens(kit: ViewKit, Donut: (props: DonutProps) => ReactElement): (props: {
usage: TokenUsage | null
}) => ReactNS.ReactElement {
}) => ReactElement {
const { t, fmt, fmtShare } = kit
const SliceList = makeSliceList(kit)
return function StatsTokens(props: { usage: TokenUsage | null }): ReactNS.ReactElement {
return function StatsTokens(props: { usage: TokenUsage | null }): ReactElement {
// The legend row ↔ donut segment hover link (shared key, set from either side).
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
const [hoverKey, setHoverKey] = useState<string | null>(null)
const reads = props.usage !== null ? numOf(props.usage.cacheReadTokens) : 0
const writes = props.usage !== null ? numOf(props.usage.cacheWriteTokens) : 0
const uncached = props.usage !== null ? numOf(props.usage.uncachedInputTokens) : 0
+14 -16
View File
@@ -3,13 +3,11 @@
* aggregateByTurn/attachMarkers are shared with ContextView.
*/
import type * as ReactNS from 'react'
import { memo, useLayoutEffect, useMemo, useRef, type ReactElement, type UIEvent } from 'react'
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
import { CATS } from '../categories'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
export interface TrendChartProps {
requests: RequestRecord[]
markers: (ContextEventRecord | undefined)[]
@@ -76,7 +74,7 @@ export function jumpTargetOf(requests: RequestRecord[], seq: number): RequestRec
return requests.length > 0 ? requests[0] : null
}
export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactNS.ReactElement {
export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactElement {
const { t, fmt, eventLabel, eventAt } = kit
const CHART_H = 112
@@ -151,7 +149,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
// Memoized so a hover/selection change re-renders only the bars whose flags flipped — the retained log renders in full (thousands of
// nodes on long sessions); `req`/`marker` keep stable identities because the parent memoizes its aggregation, so the default shallow
// compare suffices.
const ChartBar = React.memo(function ChartBar(props: ChartBarProps): ReactNS.ReactElement {
const ChartBar = memo(function ChartBar(props: ChartBarProps): ReactElement {
const { req, marker } = props
const markerAt = marker !== undefined ? eventAt(marker) : null
// Delta mode: diverging stacks — positive category deltas pile UP from the zero line, negative ones
@@ -205,9 +203,9 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
)
})
return function TrendChart(props: TrendChartProps): ReactNS.ReactElement {
return function TrendChart(props: TrendChartProps): ReactElement {
const delta = props.mode === 'delta'
const requests = React.useMemo(
const requests = useMemo(
() => (delta ? props.requests.map((req, i) => deltaOf(req, i > 0 ? props.requests[i - 1] : null)) : props.requests),
[props.requests, delta],
)
@@ -275,17 +273,17 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
// Default anchor: newest bars at the RIGHT edge; the first layout after mount scrolls unconditionally, a GRANULARITY SWITCH re-anchors
// the same way (step mode must not inherit the turn chart's stale left edge), otherwise stick to the end only while already near it;
// useLayoutEffect avoids a first-paint flash.
const scrollRef = React.useRef<HTMLDivElement | null>(null)
const scrolledOnce = React.useRef(false)
const lastGranRef = React.useRef(props.granularity)
const scrollRef = useRef<HTMLDivElement | null>(null)
const scrolledOnce = useRef(false)
const lastGranRef = useRef(props.granularity)
// The newest bar's seq (or 0 when the log is empty): the layout effect only re-runs when the right edge genuinely
// moves (new bar appended, granularity switched, focus turn set) — hover/select changes keep their scroll position
// so the chart does not flash with every keystroke.
const lastSeqRef = React.useRef(0)
const lastSeqRef = useRef(0)
// The scrollWidth measured during the PREVIOUS effect pass. The "was the reader near the right edge?" check
// has to compare against the width as it was BEFORE the new bar landed — by the time the layout effect runs,
// `el.scrollWidth` is already the new (wider) value, so a near-edge check against it would miss the auto-follow.
const prevScrollWidthRef = React.useRef(0)
const prevScrollWidthRef = useRef(0)
/**
* Keep each turn label centered within its block's VISIBLE slice, then thin colliding labels: a label wider
* than its block overflows it, so consecutive narrow turns (14px bars, 2-digit "T12"s) would smear into each
@@ -327,7 +325,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
label.style.visibility = vis
}
}
React.useLayoutEffect(() => {
useLayoutEffect(() => {
const el = scrollRef.current
/* v8 ignore next 1 -- the scroll div renders unconditionally and React
attaches refs before layout effects run; el is never null here. */
@@ -385,7 +383,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
const hoveredReq = hoveredIdx >= 0 ? requests[hoveredIdx] : null
// Column center (content px) of the currently hovered bar, for syncTip reads outside the render pass.
const tipColRef = React.useRef(0)
const tipColRef = useRef(0)
/**
* Glue the hover tip to its bar's VISIBLE slice. The tip deliberately does NOT live inside the scrolling
@@ -411,7 +409,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
// Position (and re-position after EVERY commit — the tip mounts on hover changes, which touch no other
// effect dependency here) from the committed hovered column before paint.
React.useLayoutEffect(() => {
useLayoutEffect(() => {
/* v8 ignore next 1 -- the scroll div renders unconditionally and React attaches refs before
layout effects run; el is never null here. */
if (scrollRef.current === null) return
@@ -455,7 +453,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
<div
className={'lc-chart-scroll' + (props.activeTurn !== null ? ' lc-chart-dim' : '')}
ref={scrollRef}
onScroll={(e: ReactNS.UIEvent<HTMLDivElement>) => {
onScroll={(e: UIEvent<HTMLDivElement>) => {
updateTurnLabels(e.currentTarget)
syncTip(e.currentTarget)
}}
+5 -7
View File
@@ -10,12 +10,10 @@
* until the harness is actually updated.
*/
import type * as ReactNS from 'react'
import { useCallback, useState, type ReactElement } from 'react'
import type { ViewKit } from '../viewkit'
import { useEscapeClose } from './escapeClose'
import { React } from '../react'
/** Sessions whose gate the user already dismissed this browser session. */
const dismissed = new Set<string>()
@@ -27,15 +25,15 @@ export interface UpgradeGateProps {
minimum: string
}
export function makeUpgradeGate(kit: ViewKit): (props: UpgradeGateProps) => ReactNS.ReactElement | null {
export function makeUpgradeGate(kit: ViewKit): (props: UpgradeGateProps) => ReactElement | null {
const { t } = kit
return function UpgradeGate(props: UpgradeGateProps): ReactNS.ReactElement | null {
return function UpgradeGate(props: UpgradeGateProps): ReactElement | null {
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
// `closedFor` re-renders on dismiss within one mount; the ledger covers
// remounts and in-place session switches.
const [closedFor, setClosedFor] = React.useState<string | null>(null)
const [closedFor, setClosedFor] = useState<string | null>(null)
const closed = closedFor === sessionId || dismissed.has(sessionId)
const close = React.useCallback(() => {
const close = useCallback(() => {
dismissed.add(sessionId)
setClosedFor(sessionId)
}, [sessionId])
+1 -2
View File
@@ -22,6 +22,7 @@
* beyond the bundled source.
*/
import { createElement as h } from 'react'
import { DICT_EN, DICT_ZH } from './i18n'
import { registerContextCommand } from './command'
import { makeContextModal } from './components/contextModal'
@@ -55,8 +56,6 @@ import './styles/detailSections.css'
import './styles/attachments.css'
import './styles/agentGraph.css'
import { h } from './react'
const NS = 'dsh-context'
function apply(ctx: ClientCtx): void {
-19
View File
@@ -1,19 +0,0 @@
/**
* The browser module table supplies React through the injected `require` —
* every component imports it from here instead of repeating the require.
*/
import type * as ReactNS from 'react'
// The loader contract: a closure-factory bundle has no ESM imports; the
// browser module table answers this injected require.
// oxlint-disable-next-line typescript/no-require-imports
export const React: typeof ReactNS = require('react') as typeof ReactNS
export const h = React.createElement
// react-dom rides the same platform seed; only the portal API is used (the
// image lightbox escapes transformed ancestors by portaling to body).
// oxlint-disable-next-line typescript/no-require-imports
export const ReactDOM = require('react-dom') as {
createPortal: (children: ReactNS.ReactNode, container: Element) => ReactNS.ReactPortal
}
+5 -5
View File
@@ -26,8 +26,8 @@
* empty chart.
*/
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import type { ContextTimeline, ContextTimelineDetail } from '../shared/types'
import { React } from './react'
import type { ClientCtx, SessionStandardProps } from './services'
import { asRecord, numOf, objectsOf, projectionOf, rpcCallOf, timelineOf } from './services'
@@ -275,19 +275,19 @@ export function useTimelineSource(ctx: ClientCtx, props: SessionStandardProps):
// inline generation — `headRev` null means no detail channel work at all.
const headRev = head !== null && typeof head.detailRev === 'number' ? head.detailRev : null
const slim = headRev !== null
const store = React.useMemo(
const store = useMemo(
() => (slim ? detailStoreOf(ctx, sessionId) : null),
[ctx, sessionId, slim],
)
const snap = React.useSyncExternalStore(
const snap = useSyncExternalStore(
store !== null ? store.subscribe : noopSubscribe,
store !== null ? store.getSnapshot : () => EMPTY_SNAP,
)
React.useEffect(() => {
useEffect(() => {
if (store !== null && headRev !== null) store.request(headRev)
}, [store, headRev])
return React.useMemo<TimelineSource>(() => {
return useMemo<TimelineSource>(() => {
if (head === null) return { data: null, detailState: 'loading', retryDetail: noopRetry }
if (!slim || store === null) return { data: head, detailState: 'legacy', retryDetail: noopRetry }
const detail = snap.detail
+1 -1
View File
@@ -9,6 +9,7 @@
// The real-code complement — the ACTUAL dsh sources per tag — runs in the
// `compat` vitest project (tests/compat/matrix.spec.ts).
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test, vi } from 'vitest'
import { BASELINES } from '../../baselines'
@@ -20,7 +21,6 @@ import { makeContextView } from '../../../src/client/components/contextView'
import { createContextSettings } from '../../../src/client/settings'
import { DICT_EN } from '../../../src/client/i18n'
import type { ContextTimeline } from '../../../src/shared/types'
import { h } from '../../../src/client/react'
import { asClientCtx, TestClientCtx } from '../helpers/harness'
import { click, makeKit, mount, queryAll, text } from '../helpers/kit'
import { baselineCtx, chatSeat, convNodes } from './baselineFaces'
+1 -2
View File
@@ -4,10 +4,9 @@
// navigation, catalog refresh, live snapshot updates, and every degrade arm
// (no service, no anchor, no stats).
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { act } from 'react'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeAgentGraph, ringColorOf } from '../../../src/client/components/agentGraph'
import type { AgentSelfStats } from '../../../src/client/agentTree'
import { TestClientCtx, asClientCtx } from '../helpers/harness'
+1 -2
View File
@@ -3,10 +3,9 @@
// narrowing matrix), conversation join (block cascade, tail-status matrix),
// targeted content fetch, hover linkage, and the focus bridges.
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { act } from 'react'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeContextBrowser, type ContextBrowserProps } from '../../../src/client/components/browser'
import { makeStackedBar } from '../../../src/client/components/stackedBar'
import { DICT_EN } from '../../../src/client/i18n'
+1 -1
View File
@@ -3,13 +3,13 @@
// request seq resolved off the served node seat, the relay + tab activation
// driven through the real viewFocus module.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeContextJumpButton } from '../../../src/client/components/contextJump'
import type { ConversationNodeLike, UseChatLike } from '../../../src/client/services'
import { takeContextFocus } from '../../../src/client/viewFocus'
import { DICT_EN } from '../../../src/client/i18n'
import { h } from '../../../src/client/react'
import { click, makeKit, mount, query, queryAll } from '../helpers/kit'
const kit = makeKit()
+2 -3
View File
@@ -3,10 +3,9 @@
// a TestClientCtx), with the per-session modal store driving open/close,
// real sessions faces for the consume-token bail, and real projections.
import { act, createElement as h, useSyncExternalStore } from 'react'
import assert from 'node:assert/strict'
import { act } from 'react'
import { afterEach, describe, test, vi } from 'vitest'
import { React, h } from '../../../src/client/react'
import { makeContextModal } from '../../../src/client/components/contextModal'
import { modalStoreOf, setPendingConsume, takePendingConsume } from '../../../src/client/modalStore'
import type { ContextTimeline } from '../../../src/shared/types'
@@ -34,7 +33,7 @@ function timeline(over: Record<string, unknown> = {}): ContextTimeline {
function boundModalHook(sessionId: string) {
const store = modalStoreOf(sessionId)
return (sel: (open: boolean) => boolean): boolean =>
React.useSyncExternalStore(store.subscribe, () => sel(store.getSnapshot()))
useSyncExternalStore(store.subscribe, () => sel(store.getSnapshot()))
}
const OPEN = (): boolean => true
+3 -4
View File
@@ -5,11 +5,10 @@
// garbage / well-formed, granularity/trend-mode state, brief→browser
// locate bridge, kind filter, scroll ledger, locale arms, error boundary).
import { act, createElement as h, type ReactElement } from 'react'
import assert from 'node:assert/strict'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, test, vi } from 'vitest'
import { h } from '../../../src/client/react'
import { makeContextView } from '../../../src/client/components/contextView'
import { watchHistoryFaces } from '../../../src/client/historyPage'
import { requestContextFocus, takeContextFocus } from '../../../src/client/viewFocus'
@@ -93,7 +92,7 @@ function buttonByText(container: ParentNode, label: string): HTMLElement {
}
/** Mount inside a `[data-conversation-scroll]` scroller so the view's shared-scrollport probes find it. */
async function mountInScroller(el: React.ReactElement, scroller: HTMLElement) {
async function mountInScroller(el: ReactElement, scroller: HTMLElement) {
const inner = document.createElement('div')
scroller.appendChild(inner)
document.body.appendChild(scroller)
@@ -103,7 +102,7 @@ async function mountInScroller(el: React.ReactElement, scroller: HTMLElement) {
})
return {
container: inner,
async update(next: React.ReactElement) {
async update(next: ReactElement) {
await act(async () => {
root.render(next)
})
@@ -1,9 +1,9 @@
// CurrentComposition (src/client/components/currentComposition.tsx) rendered
// with real React over the real StackedBar/Legend pair.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeCurrentComposition } from '../../../src/client/components/currentComposition'
import { makeLegend, makeStackedBar } from '../../../src/client/components/stackedBar'
import type { Headline } from '../../../src/client/headline'
+1 -1
View File
@@ -2,9 +2,9 @@
// detail-pending note — loading strip, the failed state's retry button, and
// the inert plain-text failure when no retry is wired.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeDetailNote } from '../../../src/client/components/detailNote'
import { DICT_EN, DICT_ZH } from '../../../src/client/i18n'
import { click, makeKit, mount, query, text } from '../helpers/kit'
+2 -2
View File
@@ -2,9 +2,9 @@
// proportional SVG segments around the center label, with the empty ring
// fallback and hostile-value skips.
import { createElement as h, useState } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h, React } from '../../../src/client/react'
import { makeDonut } from '../../../src/client/components/donut'
import type { DonutSegment } from '../../../src/client/components/donut'
import { hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
@@ -14,7 +14,7 @@ const Donut = makeDonut(kit)
/** A parent that really holds the hover key, so hovering re-renders the ring. */
function HoverHarness(props: { segments: DonutSegment[] }) {
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
const [hoverKey, setHoverKey] = useState<string | null>(null)
return h(Donut, { segments: props.segments, centerTop: 'x', hoverKey, onHoverKey: setHoverKey })
}
@@ -4,9 +4,9 @@
// fake DOM event — silenced with a window-error preventer and a spy, never
// mocked.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import { h } from '../../../src/client/react'
import { makeErrorBoundary } from '../../../src/client/components/errorBoundary'
import { click, makeKit, mount, query, silenceWindowErrors, text } from '../helpers/kit'
+1 -2
View File
@@ -2,10 +2,9 @@
// React (real shared icon components), including the truncation layout effect
// and the empty→non-empty hook-count regression (issue #12).
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { act } from 'react'
import { h } from '../../../src/client/react'
import { makeEventList, makeEventText } from '../../../src/client/components/events'
import { fmtTime } from '../../../src/client/format'
import type { ContextEventRecord } from '../../../src/shared/types'
+1 -2
View File
@@ -3,10 +3,9 @@
// and line deltas, expandable operation logs, the op-level locate hook,
// workspace-relative path display, and the system-open affordance.
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { act } from 'react'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeFileCard } from '../../../src/client/components/fileCard'
import type { FileActivity, FileEntry, FileOp } from '../../../src/client/fileActivity'
import { createContextSettings } from '../../../src/client/settings'
+1 -2
View File
@@ -1,10 +1,9 @@
// images.tsx — imageRefOf narrowing (pure) plus ImageCard and the
// AttachmentLightbox rendered with real React (portal to document.body).
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test, vi } from 'vitest'
import { act } from 'react'
import { h } from '../../../src/client/react'
import { imageRefOf, makeImageCard } from '../../../src/client/components/images'
import type { ImageRefLike } from '../../../src/client/services'
import { estimateImageTokens } from '../../../src/shared/imageTokens'
+1 -1
View File
@@ -3,9 +3,9 @@
// modules are re-imported fresh (vi.resetModules) so the baked-in version
// define and the fetch TTL cache both start clean.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import { h } from '../../../src/client/react'
import type { makePluginInfo as makePluginInfoFn } from '../../../src/client/components/pluginInfo'
import { flush, makeKit, mount, query, queryAll, text } from '../helpers/kit'
@@ -2,9 +2,9 @@
// React in jsdom: header chips, delta mode, per-category rows, and the step
// brief (chipParts/nodeLine cascades, locate linkage).
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeRequestDetail } from '../../../src/client/components/requestDetail'
import { makeStackedBar } from '../../../src/client/components/stackedBar'
import type { RequestRecord, ContextEventRecord, SurfaceNode } from '../../../src/shared/types'
+1 -1
View File
@@ -2,9 +2,9 @@
// markdown mode renders through the REAL shared MarkdownText; raw mode is an
// exact-text line-numbered <pre>; the switch drives the mode hook.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeRichText } from '../../../src/client/components/richText'
import type { RichMode } from '../../../src/client/components/richText'
import { click, makeKit, mount, query, queryAll } from '../helpers/kit'
+1 -1
View File
@@ -4,9 +4,9 @@
// "Open in Settings" jump path mounts the card pre-expanded (settingsJump.ts
// expand request), with the scroll best-effort against stubbed prototypes.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeSettingsCard } from '../../../src/client/components/settingsCard'
import type { SettingsState } from '../../../src/client/settings'
import { DICT_EN } from '../../../src/client/i18n'
+2 -2
View File
@@ -2,9 +2,9 @@
// the donut legend rows — the primary line (color dot, label, bold share)
// over the secondary quantity line.
import { createElement as h, useState } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h, React } from '../../../src/client/react'
import { makeSliceList } from '../../../src/client/components/sliceList'
import type { SliceRow } from '../../../src/client/components/sliceList'
import { hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
@@ -14,7 +14,7 @@ const SliceList = makeSliceList(kit)
/** A parent that really holds the hover key, so hovering re-renders the rows. */
function HoverHarness(props: { rows: SliceRow[] }) {
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
const [hoverKey, setHoverKey] = useState<string | null>(null)
return h(SliceList, { rows: props.rows, hoverKey, onHoverKey: setHoverKey })
}
+2 -2
View File
@@ -2,9 +2,9 @@
// real React through a state-holding hover harness, plus direct mounts for
// the scale/free/reserve/tooltip branch matrix.
import { createElement as h, useState } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h, React } from '../../../src/client/react'
import { makeLegend, makeStackedBar } from '../../../src/client/components/stackedBar'
import type { PartsPart } from '../../../src/client/categories'
import { hover, makeKit, mount, query, queryAll, unhover } from '../helpers/kit'
@@ -19,7 +19,7 @@ function part(key: string, value: number, raw?: number): PartsPart {
/** A parent that really holds the hover key, so hovering re-renders the bar. */
function HoverHarness(props: { parts: PartsPart[]; max?: number; reserve?: { ratio: number; label: string } }) {
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
const [hoverKey, setHoverKey] = useState<string | null>(null)
return h(StackedBar, { parts: props.parts, max: props.max, hoverKey, onHoverKey: setHoverKey, reserve: props.reserve })
}
+1 -1
View File
@@ -5,9 +5,9 @@
// `countsOfRecords` is the inline generation's derivation, pinned here to the
// same totals.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { countsOfRecords, makeStatsContext } from '../../../src/client/components/statsContext'
import type { ContextEventRecord, RequestRecord, SessionCostUsage } from '../../../src/shared/types'
import { makeKit, mount, query, queryAll, text } from '../helpers/kit'
+1 -3
View File
@@ -4,14 +4,12 @@
// and every hostile or absent dock shape degrades to a silent feature-off.
// jsdom supplies the real dock DOM and its live MutationObserver.
import { act, createElement as h, type ReactNode } from 'react'
import assert from 'node:assert/strict'
import { act } from 'react'
import type { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, test } from 'vitest'
import { makeStatsJump, statsRowOf, wireDock } from '../../../src/client/components/statsJump'
import { DICT_EN } from '../../../src/client/i18n'
import { h } from '../../../src/client/react'
import { makeKit } from '../helpers/kit'
const StatsJump = makeStatsJump(makeKit())
+1 -1
View File
@@ -3,9 +3,9 @@
// the pct-led slice rows with call counts and true durations — plus the empty
// and hostile-timing degrades.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeDonut } from '../../../src/client/components/donut'
import { makeStatsTiming } from '../../../src/client/components/statsTiming'
import type { TimingTotals } from '../../../src/shared/types'
+1 -1
View File
@@ -2,9 +2,9 @@
// React: the billed-bucket donut with the cache-hit center and the
// pct-led bucket rows.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeDonut } from '../../../src/client/components/donut'
import { makeStatsTokens } from '../../../src/client/components/statsTokens'
import type { TokenUsage } from '../../../src/shared/types'
+1 -2
View File
@@ -12,10 +12,9 @@
* empty frame here.
*/
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterAll, beforeAll, describe, test } from 'vitest'
import { act } from 'react'
import { h } from '../../../src/client/react'
import { aggregateByTurn, attachMarkers, jumpTargetOf, makeTrendChart, type TrendChartProps } from '../../../src/client/components/trendChart'
import { CATS } from '../../../src/client/categories'
import type { ContextEventRecord, RequestRecord } from '../../../src/shared/types'
+1 -1
View File
@@ -3,9 +3,9 @@
// dismissal, and the per-session dismissal ledger across remounts and
// in-place session switches.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeUpgradeGate } from '../../../src/client/components/upgradeGate'
import { click, keydown, makeKit, mount, query, text } from '../helpers/kit'
+1 -2
View File
@@ -3,9 +3,8 @@
// contracts, not mocks of plugin code). Shared by every client spec so each
// test file stays small and stateless.
import { act } from 'react'
import { act, type ReactElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { ReactElement, ReactNode } from 'react'
import { DICT_EN, DICT_ZH } from '../../../src/client/i18n'
import type { Translate } from '../../../src/client/i18n'
import { makeViewKit, type ViewKit } from '../../../src/client/viewkit'
+1 -2
View File
@@ -3,12 +3,11 @@
// /context trigger source, and the deferred settingsScope inject — plus real
// renders of the registered components.
import { createElement as h, type ReactElement } from 'react'
import assert from 'node:assert/strict'
import type { ReactElement } from 'react'
import { describe, test } from 'vitest'
import { DICT_EN, DICT_ZH } from '../../src/client/i18n'
import { modalStoreOf, type ModalStore } from '../../src/client/modalStore'
import { h } from '../../src/client/react'
import type { SettingsField, SettingsScopeLike, SettingsState } from '../../src/client/settings'
import { TestClientCtx, TestSessions, asClientCtx } from './helpers/harness'
import { click, mount, query, queryAll } from './helpers/kit'
-31
View File
@@ -1,31 +0,0 @@
// The platform React module table (src/client/react.ts): the plugin's React
// is the real react module, h creates elements, and ReactDOM exposes portals.
import assert from 'node:assert/strict'
import { createElement } from 'react'
import { describe, test } from 'vitest'
import { h, React, ReactDOM } from '../../src/client/react'
describe('react module table', () => {
test('React is the real react module', () => {
assert.equal(React.createElement, createElement)
})
test('h creates elements', () => {
// Viewed structurally: React's public types hide $$typeof and the
// children prop slot, but both are part of the real element shape.
const el = h('div', { className: 'box' }, 'hi') as unknown as {
$$typeof: symbol
type: unknown
props: Record<string, unknown>
}
assert.equal(el.$$typeof, Symbol.for('react.element'))
assert.equal(el.type, 'div')
assert.equal(el.props.className, 'box')
assert.equal(el.props.children, 'hi')
})
test('ReactDOM exposes createPortal', () => {
assert.equal(typeof ReactDOM.createPortal, 'function')
})
})
+1 -3
View File
@@ -4,11 +4,9 @@
// the useTimelineSource hook merging both wire generations into the value
// the cards render.
import { act, createElement as h, type ReactElement } from 'react'
import assert from 'node:assert/strict'
import { describe, test, vi, afterEach } from 'vitest'
import { act } from 'react'
import type { ReactElement } from 'react'
import { h } from '../../src/client/react'
import type { ClientCtx } from '../../src/client/services'
import {
DetailStore,
+2
View File
@@ -17,6 +17,7 @@ import { beforeAll, describe, test } from 'vitest'
import { JSDOM } from 'jsdom'
import React from 'react'
import ReactDOM from 'react-dom'
import * as jsxRuntime from 'react/jsx-runtime'
import * as staging from './staging'
describe.skipIf(staging.artifactsMissing())('bundle smoke — the built lib/client.js', () => {
@@ -56,6 +57,7 @@ describe.skipIf(staging.artifactsMissing())('bundle smoke — the built lib/clie
}
const moduleTable = (spec: string): unknown => {
if (spec === 'react') return React
if (spec === 'react/jsx-runtime') return jsxRuntime
if (spec === 'react-dom') return ReactDOM
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
return new Proxy({}, { get: (_t, key) => (typeof key === 'string' ? inert(key) : undefined) })
+1 -1
View File
@@ -4,7 +4,7 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,