Merge pull request #763 from deepseek-harness/lint-tsx-baseline

chore(lint): bring .tsx files into the eslint, lefthook, and jscpd lanes
This commit is contained in:
Tianyi Cui
2026-07-27 22:30:16 +08:00
committed by GitHub
71 changed files with 646 additions and 555 deletions

View File

@@ -2,8 +2,8 @@
"minTokens": 60, "minTokens": 60,
"minLines": 6, "minLines": 6,
"mode": "mild", "mode": "mild",
"format": ["typescript"], "format": ["typescript", "tsx"],
"pattern": "**/*.ts", "pattern": "**/*.{ts,tsx}",
"ignore": ["**/tests/**", "**/tsdown.config.ts"], "ignore": ["**/tests/**", "**/tsdown.config.ts"],
"ignorePattern": [ "ignorePattern": [
"(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/" "(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/"

View File

@@ -27,7 +27,13 @@ export default tseslint.config(
// --- our packages: full strictness ------------------------------------- // --- our packages: full strictness -------------------------------------
{ {
files: ['packages/*/*/src/**/*.ts', 'apps/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], files: [
'packages/*/*/src/**/*.{ts,tsx}',
'apps/*/src/**/*.{ts,tsx}',
'examples/**/*.{ts,tsx}',
'scripts/**/*.{ts,tsx}',
'website/**/*.{ts,tsx}',
],
extends: [ extends: [
...tseslint.configs.strictTypeChecked, ...tseslint.configs.strictTypeChecked,
], ],
@@ -80,7 +86,12 @@ export default tseslint.config(
// --- tests: same rules, minus the friction that fights test ergonomics -- // --- tests: same rules, minus the friction that fights test ergonomics --
{ {
files: ['packages/*/*/tests/**/*.ts', 'apps/*/tests/**/*.ts', 'examples/*/tests/**/*.ts', 'scripts/**/*.spec.ts'], files: [
'packages/*/*/tests/**/*.{ts,tsx}',
'apps/*/tests/**/*.{ts,tsx}',
'examples/*/tests/**/*.{ts,tsx}',
'scripts/**/*.spec.{ts,tsx}',
],
extends: [ extends: [
...tseslint.configs.strictTypeChecked, ...tseslint.configs.strictTypeChecked,
], ],
@@ -117,7 +128,10 @@ export default tseslint.config(
// Context merges collide), so the shared project service cannot resolve // Context merges collide), so the shared project service cannot resolve
// them — parse these through the client aggregate explicitly. // them — parse these through the client aggregate explicitly.
{ {
files: ['packages/client/*/tests/**/*.ts', 'scripts/client-bundle-purity.spec.ts'], files: [
'packages/client/*/tests/**/*.{ts,tsx}',
'scripts/client-bundle-purity.spec.ts',
],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
projectService: false, projectService: false,
@@ -129,7 +143,7 @@ export default tseslint.config(
// --- file-local duplication (all owned TypeScript) --------------------- // --- file-local duplication (all owned TypeScript) ---------------------
{ {
files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}', 'scripts/**/*.{ts,tsx}', 'website/**/*.{ts,tsx}'],
plugins: { sonarjs }, plugins: { sonarjs },
rules: { rules: {
// Cross-file clones are covered separately by jscpd. // Cross-file clones are covered separately by jscpd.
@@ -146,7 +160,14 @@ export default tseslint.config(
// --- formatting (everything we own) ------------------------------------- // --- formatting (everything we own) -------------------------------------
{ {
files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'], files: [
'packages/**/*.{ts,tsx}',
'apps/**/*.{ts,tsx}',
'examples/**/*.{ts,tsx}',
'scripts/**/*.{ts,tsx}',
'website/**/*.{ts,tsx}',
'eslint.config.mjs',
],
plugins: { '@stylistic': stylistic }, plugins: { '@stylistic': stylistic },
rules: { rules: {
'@stylistic/indent': ['error', 2], '@stylistic/indent': ['error', 2],

View File

@@ -5,7 +5,7 @@
pre-commit: pre-commit:
jobs: jobs:
- name: lint (staged) - name: lint (staged)
glob: '*.{ts,mts,cts,mjs}' glob: '*.{ts,tsx,mts,cts,mjs}'
exclude: exclude:
- 'vendor/*/src/**' - 'vendor/*/src/**'
run: node_modules/.bin/eslint --fix {staged_files} run: node_modules/.bin/eslint --fix {staged_files}

View File

@@ -45,7 +45,7 @@ async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResu
} }
function rowLabels(): string[] { function rowLabels(): string[] {
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent!) return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent)
} }
describe('PopupSelectView', () => { describe('PopupSelectView', () => {

View File

@@ -86,7 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
seq: number seq: number
onOpenDetails: OpenDetails onOpenDetails: OpenDetails
selected: boolean selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */ /** `run_code` sub-dispatches in dispatch order (reference-stable per
* parent; running entries settle in place); undefined for ordinary calls. */
subCalls?: readonly CodeSubCall[] | undefined subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined selectedCallId?: string | undefined
@@ -103,7 +104,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
})} })}
{subCalls !== undefined && subCalls.length > 0 && ( {subCalls !== undefined && subCalls.length > 0 && (
<div className={css.subCalls} data-subcalls> <div className={css.subCalls} data-subcalls>
{subCalls.map((node) => ( {subCalls.map(node => (
<SubCallRow <SubCallRow
key={node.callId} key={node.callId}
renderSlot={renderSlot} renderSlot={renderSlot}
@@ -130,7 +131,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
}) { }) {
return ( return (
<div className={css.toolGroup}> <div className={css.toolGroup}>
{results.map((node) => ( {results.map(node => (
<CallRow <CallRow
key={node.callId} key={node.callId}
renderSlot={renderSlot} renderSlot={renderSlot}
@@ -154,7 +155,7 @@ function StreamingTail({ useSession, onGrow }: {
useSession: UseConversation useSession: UseConversation
onGrow: () => void onGrow: () => void
}) { }) {
const partial = useSession((s) => s.partial) const partial = useSession(s => s.partial)
useLayoutEffect(() => { useLayoutEffect(() => {
onGrow() onGrow()
}) })
@@ -162,17 +163,20 @@ function StreamingTail({ useSession, onGrow }: {
return <AssistantMarkdown blocks={partial.blocks} streaming /> return <AssistantMarkdown blocks={partial.blocks} streaming />
} }
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */ /**
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes) const nodes = useSession(s => s.nodes)
const runningCalls = useSession((s) => s.runningCalls) const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession((s) => s.codeDispatches) const codeDispatches = useSession(s => s.codeDispatches)
const pending = useSession((s) => s.pending) const pending = useSession(s => s.pending)
const openState = useSession((s) => s.openState) const openState = useSession(s => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
const hasMore = useSession((s) => s.hasMore) const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder) const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore((s) => s.selection?.callId) const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes]) const items = useMemo(() => deriveChatFlow(nodes), [nodes])
@@ -254,8 +258,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
const renderItem = (item: ChatFlowItem): ReactNode => { const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') { if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId && item.results.some(r => r.callId === selectedCallId
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true) || codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
return ( return (
<ToolGroup <ToolGroup
key={item.key} key={item.key}
@@ -293,7 +297,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
<StreamingTail useSession={useSession} onGrow={onGrow} /> <StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && ( {runningCalls.length > 0 && (
<div className={css.toolGroup}> <div className={css.toolGroup}>
{runningCalls.map((call) => ( {runningCalls.map(call => (
<CallRow <CallRow
key={call.callId} key={call.callId}
renderSlot={renderSlot} renderSlot={renderSlot}
@@ -309,7 +313,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
))} ))}
</div> </div>
)} )}
{pending.map((item) => <PendingCard key={item.key} item={item} />)} {pending.map(item => <PendingCard key={item.key} item={item} />)}
</div> </div>
</div> </div>
<StatsLine useSession={useSession} /> <StatsLine useSession={useSession} />

View File

@@ -32,6 +32,9 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */ /** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> { async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
@@ -40,6 +43,9 @@ async function writeClipboard(text: string): Promise<void> {
} }
return return
} }
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function' const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document) ? document.execCommand.bind(document)
: undefined : undefined
@@ -56,6 +62,7 @@ async function writeClipboard(text: string): Promise<void> {
} catch { } catch {
// Clipboard unavailable; the button stays idle. // Clipboard unavailable; the button stays idle.
} }
/* eslint-enable @typescript-eslint/no-deprecated */
el.remove() el.remove()
} }

View File

@@ -53,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> } export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) { export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
const nodes = useSession((s) => s.nodes) const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes]) const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null if (stats.steps === 0) return null
const parts: string[] = [] const parts: string[] = []

View File

@@ -52,7 +52,7 @@ export function ToolRow({
const open = expanded && expandable const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => { const toggleExpand = () => {
setExpanded((v) => !v) setExpanded(v => !v)
} }
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => { const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation() event.stopPropagation()

View File

@@ -144,7 +144,7 @@ export interface ToolRowOwnerProps {
/** Frozen call slice: the running call or the settled result node. */ /** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock block: ToolCallBlock
/** Open the details panel for this call (session-level facility, supplied by the view). */ /** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails(): void openDetails: () => void
} }
/** /**
@@ -175,21 +175,21 @@ export interface ConversationInjected {
* Connect the selected Workspace and open its reusable/new blank session. * Connect the selected Workspace and open its reusable/new blank session.
* When a blank session is already current, carry its draft to the target. * When a blank session is already current, carry its draft to the target.
*/ */
selectWorkspace(workspaceId: WorkspaceId): Promise<void> selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
} }
/** Business callbacks injected into the strict session content seat. */ /** Business callbacks injected into the strict session content seat. */
export interface ConversationSessionInjected { export interface ConversationSessionInjected {
/** Views projected from the `conversation.view` slot ledger. */ /** Views projected from the `conversation.view` slot ledger. */
views: { views: {
list(): readonly ViewTab[] list: () => readonly ViewTab[]
subscribe(fn: () => void): () => void subscribe: (fn: () => void) => () => void
version(): number version: () => number
} }
/** Bind the input machine's draft persistence mirror to the session store. */ /** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror(write: (text: string) => void): () => void bindDraftMirror: (write: (text: string) => void) => () => void
/** Select a real Session through the runtime navigation owner. */ /** Select a real Session through the runtime navigation owner. */
open(sessionId: SessionId): void open: (sessionId: SessionId) => void
} }
/** /**
@@ -219,7 +219,7 @@ export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */ /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
keyboard: ComposerKeyboard keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */ /** Cancel the in-flight turn. */
stop(): void stop: () => void
} }
/** /**
@@ -275,8 +275,8 @@ export type ConversationSessionSlotProps =
*/ */
export interface ChatViewInjected { export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */ /** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void openDetails: (target: SelectionTarget) => void
loadOlder(): void loadOlder: () => void
} }
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */ /** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
@@ -290,7 +290,7 @@ export type ChatViewSlotProps =
*/ */
export interface DetailsInjected { export interface DetailsInjected {
/** Close the details panel (layout geometry stays with ctx.layout). */ /** Close the details panel (layout geometry stays with ctx.layout). */
closeDetails(): void closeDetails: () => void
} }
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */ /** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
@@ -300,6 +300,6 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
export interface EmptyWorkspaceOwnerProps { export interface EmptyWorkspaceOwnerProps {
open: boolean open: boolean
anchorRef?: RefObject<HTMLElement> anchorRef?: RefObject<HTMLElement>
onPick(workspaceId: WorkspaceId): void onPick: (workspaceId: WorkspaceId) => void
onClose(): void onClose: () => void
} }

View File

@@ -41,8 +41,8 @@ export function ConversationSession({
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
const unmirror = bindDraftMirror(actions.setDraft) const unmirror = bindDraftMirror(actions.setDraft)
return () => { unmirror() } return () => { unmirror() }
// Mount-only: later store writes come from the machine mirror. // Mount-only (deps pinned to inputActions): later store writes come from
// eslint-disable-next-line react-hooks/exhaustive-deps // the machine mirror, not this seed effect.
}, [inputActions]) }, [inputActions])
if (blank && composerPhase === 'blank') return null if (blank && composerPhase === 'blank') return null

View File

@@ -31,7 +31,11 @@ export function InputBar({
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) { }: InputBarProps) {
const input = useInput(s => s) const input = useInput(s => s)
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot) const noticeStore = keyboard.notices
const notice = useSyncExternalStore(
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const promptError = useSession(s => s.promptError) const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running) const running = useSession(s => s.running)
const disabled = useSession(s => s.removed) const disabled = useSession(s => s.removed)
@@ -75,6 +79,8 @@ export function InputBar({
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the // Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line. // IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return if (e.key === 'Enter' && e.shiftKey) return
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
@@ -92,7 +98,7 @@ export function InputBar({
// the browser stack cannot represent); never let the native stack run. // the browser stack cannot represent); never let the native stack run.
e.preventDefault() e.preventDefault()
if (machineBusy || locked) return if (machineBusy || locked) return
const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z')) const redo = e.key === 'y' || e.shiftKey
if (redo) keyboard.redo() if (redo) keyboard.redo()
else keyboard.undo() else keyboard.undo()
return return
@@ -134,6 +140,8 @@ export function InputBar({
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value const next = e.target.value
keyboard.setDraft(next) keyboard.setDraft(next)
// selectionStart is number|null in lib.dom; the eslint program narrows it.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
keyboard.track(next, e.target.selectionStart ?? next.length) keyboard.track(next, e.target.selectionStart ?? next.length)
} }
@@ -145,10 +153,13 @@ export function InputBar({
// too (one char = one step). Mouse selection of a chip is handled in the // too (one char = one step). Mouse selection of a chip is handled in the
// backdrop click handler below. Undo/redo must NOT reach the browser: the // backdrop click handler below. Undo/redo must NOT reach the browser: the
// machine owns the transaction log. // machine owns the transaction log.
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({ const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0, start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0, end: el.selectionEnd ?? el.selectionStart ?? 0,
}) })
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => { const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
const el = e.currentTarget const el = e.currentTarget
@@ -330,8 +341,8 @@ export function InputBar({
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onSelect={onSelect} onSelect={onSelect}
onCopy={e => { onCopyOrCut(e, false) }} onCopy={(e) => { onCopyOrCut(e, false) }}
onCut={e => { onCopyOrCut(e, true) }} onCut={(e) => { onCopyOrCut(e, true) }}
onPaste={onPaste} onPaste={onPaste}
onCompositionStart={onCompositionStart} onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd} onCompositionEnd={onCompositionEnd}

View File

@@ -36,10 +36,12 @@ const SCOPE_TAG: symbol = (() => {
const spy = new Proxy(new Context(), { const spy = new Proxy(new Context(), {
get(target, prop, receiver) { get(target, prop, receiver) {
recorded.push(prop) recorded.push(prop)
// Reflect.get is typed any; the probe only records property names.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver) return Reflect.get(target, prop, receiver)
}, },
}) })
void scopeOf(spy as Context) void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol') const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read') if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol return symbol
@@ -73,14 +75,15 @@ async function bench() {
const mint = (id: SessionId): Context => { const mint = (id: SessionId): Context => {
let scoped = scopes.get(id) let scoped = scopes.get(id)
if (scoped === undefined) { if (scoped === undefined) {
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id })
scopes.set(id, scoped) scopes.set(id, scoped)
} }
return scoped return scoped
} }
type TestProvider = { type TestProvider = {
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): { resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
hooks?: Record<string, unknown>; props?: Record<string, unknown> hooks?: Record<string, unknown>
props?: Record<string, unknown>
} }
} }
const providers: TestProvider[] = [] const providers: TestProvider[] = []
@@ -158,10 +161,12 @@ async function bench() {
const inputSurface = (id: SessionId) => { const inputSurface = (id: SessionId) => {
const contribution = providers[0]!.resolve(sessionsFake.binding(id)) const contribution = providers[0]!.resolve(sessionsFake.binding(id))
const state = contribution.hooks!['input'] as { const state = contribution.hooks!['input'] as {
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void getSnapshot: () => { draft: string }
subscribe: (fn: () => void) => () => void
} }
const actions = contribution.props!['inputActions'] as { const actions = contribution.props!['inputActions'] as {
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void setDraft: (text: string) => void
submit: (mode?: 'queue' | 'steer') => void
} }
return { state, actions } return { state, actions }
} }
@@ -234,11 +239,11 @@ describe('conversation slot inject surface', () => {
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
// Unknown session: sessions.scope answers nothing. // Unknown session: sessions.scope answers nothing.
;(b.sessionsFake.scope as unknown) = () => undefined ;(b.sessionsFake.scope as unknown) = () => undefined
expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/) expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/)
// A scope minted outside the service tree: no conversation service on it. // A scope minted outside the service tree: no conversation service on it.
const foreign = new Context() const foreign = new Context()
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({}) ;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/) expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
}) })
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => { it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
@@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => {
// no draft movement, plain re-open. // no draft movement, plain re-open.
const { state, actions } = b.inputSurface(ROOT) const { state, actions } = b.inputSurface(ROOT)
actions.setDraft('carry me') actions.setDraft('carry me')
resident.selectWorkspace('workspace-1' as never) void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) }) await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1') expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
expect(state.getSnapshot().draft).toBe('carry me') expect(state.getSnapshot().draft).toBe('carry me')
@@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => {
// new session's machine receives the text, then navigation lands there. // new session's machine receives the text, then navigation lands there.
const OTHER = 'other-1' as SessionId const OTHER = 'other-1' as SessionId
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER) b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
resident.selectWorkspace('workspace-2' as never) void resident.selectWorkspace('workspace-2' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) }) await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
expect(state.getSnapshot().draft).toBe('') expect(state.getSnapshot().draft).toBe('')
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me') expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')

View File

@@ -31,7 +31,7 @@ async function bench() {
}, },
current: undefined, current: undefined,
phase: 'ready', phase: 'ready',
} as SessionListState) })
const sessionsFake = { const sessionsFake = {
list: listStore, list: listStore,
binding: vi.fn(), binding: vi.fn(),
@@ -83,7 +83,7 @@ describe('apply wiring', () => {
const b = await bench() const b = await bench()
await b.fiber.await() await b.fiber.await()
const entries = b.slots.entries('conversation.view') const entries = b.slots.entries('conversation.view')
expect(entries.map((e) => e.options.id)).toEqual(['chat']) expect(entries.map(e => e.options.id)).toEqual(['chat'])
expect(entries[0]?.options.label).toBe('Chat') expect(entries[0]?.options.label).toBe('Chat')
expect(entries[0]?.options.order).toBe(0) expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on // Declaring is claiming: the chat entry's registration put the hole on
@@ -117,7 +117,7 @@ describe('apply wiring', () => {
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. // service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview') const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write']) expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
}) })
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => { it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {

View File

@@ -59,7 +59,7 @@ function snapshotWith(
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot }
} }
/** Test-owned AppFrame role: declares and renders the resident conversation area. */ /** Test-owned AppFrame role: declares and renders the resident conversation area. */

View File

@@ -36,7 +36,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init } let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>() const subs = new Set<() => void>()
return { return {
set(next: Partial<ConversationSnapshot>) { set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next } snap = { ...snap, ...next }
for (const fn of [...subs]) fn() for (const fn of [...subs]) fn()
}, },
@@ -100,9 +100,9 @@ describe('StatsLine', () => {
render(<Counting {...props(source)} />) render(<Counting {...props(source)} />)
const before = renders const before = renders
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract). // Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } })) act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } })) act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) })
act(() => set({ running: true })) act(() => { set({ running: true }) })
expect(renders).toBe(before) expect(renders).toBe(before)
}) })
}) })
@@ -128,7 +128,7 @@ describe('bash sample row', () => {
}, },
current: undefined, current: undefined,
phase: 'ready', phase: 'ready',
} as SessionListState) })
} }
const rowProps = (sessionId: SessionId, over?: { const rowProps = (sessionId: SessionId, over?: {

View File

@@ -42,7 +42,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot }
} }
/** Test-owned AppFrame role: declares and renders the resident conversation area. */ /** Test-owned AppFrame role: declares and renders the resident conversation area. */
@@ -108,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) {
return info return info
}, },
maybeProvideInfo(id: string | undefined) { maybeProvideInfo(id: string | undefined) {
// `this` inside an object-literal method is any under strict lint; the
// fake resolves through its own provideInfo above.
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} } return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
}, },
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },

View File

@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react' import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react' import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type { import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState, AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -39,7 +40,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init } let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>() const subs = new Set<() => void>()
return { return {
set(next: Partial<ConversationSnapshot>) { set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next } snap = { ...snap, ...next }
for (const fn of [...subs]) fn() for (const fn of [...subs]) fn()
}, },
@@ -104,8 +105,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useSession: bindSnapshotSelector(source), useSession: bindSnapshotSelector(source),
useSessions: emptySessions(), useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(), useWorkspaces: emptyWorkspaces(),
useInput: (() => { throw new Error('unused') }) as never, useInput: (() => { throw new Error('unused') }),
inputActions: { setDraft: () => {}, submit: () => {} } as never, inputActions: { setDraft: () => {}, submit: () => {} },
useStore: bindSnapshotSelector(chat), useStore: bindSnapshotSelector(chat),
actions: chat.actions, actions: chat.actions,
renderSlot, renderSlot,
@@ -124,9 +125,9 @@ describe('chat-flow derivation', () => {
assistant(5, 'found'), toolResult(6, 'c'), assistant(5, 'found'), toolResult(6, 'c'),
] ]
const items = deriveChatFlow(nodes) const items = deriveChatFlow(nodes)
expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group']) expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
const group = items[2]! const group = items[2]!
expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b']) expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
}) })
@@ -155,7 +156,7 @@ describe('ChatView', () => {
fireEvent.scroll(scroller) fireEvent.scroll(scroller)
fireEvent.click(view.getByText('加载更早')) fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true }) Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] })) act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800) expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
}) })
@@ -240,10 +241,10 @@ describe('ChatView', () => {
// Count renderSlot invocations: the memo boundary holds when CallRow does // Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks. // not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0 let rowRenders = 0
h.props.renderSlot = (((_key: string, _owner: object) => { h.props.renderSlot = ((_key: string, _owner: object) => {
rowRenders += 1 rowRenders += 1
return <div data-testid="counting-row" /> return <div data-testid="counting-row" />
}) as unknown as ChatViewSlotProps['renderSlot']) })
const view = render(<h.ChatView {...h.props} />) const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('counting-row')).toBeTruthy() expect(view.getByTestId('counting-row')).toBeTruthy()
const afterMount = rowRenders const afterMount = rowRenders
@@ -270,7 +271,7 @@ describe('ChatView', () => {
fireEvent.click(view.getByText('run a')) fireEvent.click(view.getByText('run a'))
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' }) expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
expect(view.container.querySelector('[data-selected]')).toBeNull() expect(view.container.querySelector('[data-selected]')).toBeNull()
act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' })) act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
expect(view.container.querySelector('[data-selected]')).not.toBeNull() expect(view.container.querySelector('[data-selected]')).not.toBeNull()
}) })
@@ -284,10 +285,10 @@ describe('ChatView', () => {
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => { it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] }) const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const calls: { key: string; entryKey?: string }[] = [] const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) }) calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null return opts?.fallback ?? null
}) as unknown as ChatViewSlotProps['renderSlot']) })
render(<h.ChatView {...h.props} />) render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool // Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger. // name, and the fallback (GenericToolCard) renders on an empty ledger.
@@ -306,10 +307,10 @@ describe('ChatView', () => {
// Arm the paging anchor, then deliver an older page (head seq decreases). // Arm the paging anchor, then deliver an older page (head seq decreases).
fireEvent.click(view.getByText('加载更早')) fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true }) Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] })) act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000) expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
// A new trailing user bubble (own words) force-scrolls to the bottom. // A new trailing user bubble (own words) force-scrolls to the bottom.
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] })) act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
expect(scroller.scrollTop).toBe(1600) expect(scroller.scrollTop).toBe(1600)
}) })
@@ -324,7 +325,7 @@ describe('ChatView', () => {
const backButton = view.getByLabelText('回到底部') const backButton = view.getByLabelText('回到底部')
expect(backButton).toBeTruthy() expect(backButton).toBeTruthy()
// Streaming growth must NOT drag a scrolled-away reader down. // Streaming growth must NOT drag a scrolled-away reader down.
act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } })) act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) })
expect(scroller.scrollTop).toBe(100) expect(scroller.scrollTop).toBe(100)
fireEvent.click(backButton) fireEvent.click(backButton)
expect(scroller.scrollTop).toBe(1000) expect(scroller.scrollTop).toBe(1000)
@@ -337,7 +338,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />) const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('加载更早')) fireEvent.click(view.getByText('加载更早'))
expect(h.loadOlder).toHaveBeenCalledTimes(1) expect(h.loadOlder).toHaveBeenCalledTimes(1)
act(() => h.set({ loadingOlder: true })) act(() => { h.set({ loadingOlder: true }) })
expect(view.getByText('加载中…')).toBeTruthy() expect(view.getByText('加载中…')).toBeTruthy()
}) })

View File

@@ -22,7 +22,7 @@ afterEach(cleanup)
describe('tails', () => { describe('tails', () => {
it('node-half apply is an intentional no-op', () => { it('node-half apply is an intentional no-op', () => {
expect(nodeApply()).toBeUndefined() expect(() => { nodeApply() }).not.toThrow()
}) })
it('ToolRow stopped state renders the warning dot in the leading slot', () => { it('ToolRow stopped state renders the warning dot in the leading slot', () => {
@@ -83,7 +83,7 @@ describe('tails', () => {
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined, current: undefined,
phase: 'ready', phase: 'ready',
} as SessionListState) })
const props = (block: RunningToolCall | ToolResultNode) => ({ const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(), callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list), sessionId: sid, useSessions: bindSnapshotSelector(list),

View File

@@ -21,7 +21,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot }
} }
describe('render branch tails', () => { describe('render branch tails', () => {
@@ -73,11 +73,11 @@ describe('render branch tails', () => {
const view = render( const view = render(
<DetailsPanel <DetailsPanel
sessionId={SID} sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>} useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(emptyList)} useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useInput={(() => { throw new Error('unused') }) as never} useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} } as never} inputActions={{ setDraft: () => {}, submit: () => {} }}
useStore={bindSnapshotSelector(chat)} useStore={bindSnapshotSelector(chat)}
actions={chat.actions} actions={chat.actions}
closeDetails={vi.fn()} closeDetails={vi.fn()}
@@ -108,11 +108,11 @@ describe('render branch tails', () => {
const view = render( const view = render(
<DetailsPanel <DetailsPanel
sessionId={SID} sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>} useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(emptyList)} useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useInput={(() => { throw new Error('unused') }) as never} useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} } as never} inputActions={{ setDraft: () => {}, submit: () => {} }}
useStore={bindSnapshotSelector(chat)} useStore={bindSnapshotSelector(chat)}
actions={chat.actions} actions={chat.actions}
closeDetails={vi.fn()} closeDetails={vi.fn()}

View File

@@ -79,11 +79,11 @@ function bench(over?: BenchOptions) {
useSession: bindSnapshotSelector(session), useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({ useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready', ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'], })),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({ useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null, items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined, baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'], })),
useInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions, inputActions: shell.actions,
keyboard: shell, keyboard: shell,
@@ -212,7 +212,7 @@ describe('running and lock semantics (queue cut 1)', () => {
const { textarea, wiring } = bench() const { textarea, wiring } = bench()
fireEvent.change(textarea, { target: { value: 'typed' } }) fireEvent.change(textarea, { target: { value: 'typed' } })
expect(wiring.state.getSnapshot().draft).toBe('typed') expect(wiring.state.getSnapshot().draft).toBe('typed')
expect((textarea as HTMLTextAreaElement).value).toBe('typed') expect((textarea).value).toBe('typed')
}) })
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
@@ -364,10 +364,10 @@ describe('placeholder chrome and control seats', () => {
expect(view.getByTestId('plan-entry')).toBeTruthy() expect(view.getByTestId('plan-entry')).toBeTruthy()
expect(view.getByTestId('model-entry')).toBeTruthy() expect(view.getByTestId('model-entry')).toBeTruthy()
// The bar hands its chrome disable state to the filling entry. // The bar hands its chrome disable state to the filling entry.
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true) expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
cleanup() cleanup()
const live = bench({ running: true }) const live = bench({ running: true })
expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true) expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
}) })
it('disabled locks the Access placeholder and attach control (running does not)', () => { it('disabled locks the Access placeholder and attach control (running does not)', () => {

View File

@@ -34,11 +34,11 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSession: bindSnapshotSelector(session), useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({ useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready', ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'], })),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({ useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null, items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined, baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'], })),
useInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions, inputActions: shell.actions,
keyboard: shell, keyboard: shell,
@@ -88,7 +88,7 @@ describe('matrix row: claimed', () => {
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' }) expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ') expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标') expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false) expect((textarea).readOnly).toBe(false)
// Free editing beyond the token: hint drops, claim holds. // Free editing beyond the token: hint drops, claim holds.
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } }) fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
expect(shell.snapshot.phase).toBe('claimed') expect(shell.snapshot.phase).toBe('claimed')
@@ -104,7 +104,7 @@ describe('matrix row: claimed', () => {
expect(sink).not.toHaveBeenCalled() expect(sink).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) }) await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
// Commit: draft cleared, notice surfaced, back to plain. // Commit: draft cleared, notice surfaced, back to plain.
await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') }) await vi.waitFor(() => { expect((textarea).value).toBe('') })
expect(view.getByText('完成')).toBeTruthy() expect(view.getByText('完成')).toBeTruthy()
}) })
@@ -126,7 +126,7 @@ describe('matrix row: submitting', () => {
fireEvent.keyDown(textarea, { key: 'Enter' }) fireEvent.keyDown(textarea, { key: 'Enter' })
expect(shell.snapshot.phase).toBe('submitting') expect(shell.snapshot.phase).toBe('submitting')
expect(shell.snapshot.claim).toBeDefined() expect(shell.snapshot.claim).toBeDefined()
expect((textarea as HTMLTextAreaElement).readOnly).toBe(true) expect((textarea).readOnly).toBe(true)
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull() expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
// Enter is dead inside the lock (submit dispatch is microtask-deferred). // Enter is dead inside the lock (submit dispatch is microtask-deferred).
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) }) await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
@@ -145,7 +145,7 @@ describe('matrix row: submitting', () => {
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() }) await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
act(() => { rejectSubmit(new Error('执行失败')) }) act(() => { rejectSubmit(new Error('执行失败')) })
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') }) await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ') expect((first.textarea).value).toBe('/goal ')
expect(first.view.getByText('执行失败')).toBeTruthy() expect(first.view.getByText('执行失败')).toBeTruthy()
cleanup() cleanup()
// Drift: typing during flight wins; no restore, plain, notice only. // Drift: typing during flight wins; no restore, plain, notice only.
@@ -157,7 +157,7 @@ describe('matrix row: submitting', () => {
act(() => { second.shell.setDraft('用户飞行中打的新稿') }) act(() => { second.shell.setDraft('用户飞行中打的新稿') })
act(() => { rejectSubmit(new Error('晚到失败')) }) act(() => { rejectSubmit(new Error('晚到失败')) })
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') }) await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿') expect((second.textarea).value).toBe('用户飞行中打的新稿')
expect(second.view.getByText('晚到失败')).toBeTruthy() expect(second.view.getByText('晚到失败')).toBeTruthy()
}) })
}) })
@@ -165,14 +165,14 @@ describe('matrix row: submitting', () => {
describe('matrix row: locked (session disabled)', () => { describe('matrix row: locked (session disabled)', () => {
it('disables the textarea and chrome; the machine currency is untouched', () => { it('disables the textarea and chrome; the machine currency is untouched', () => {
const { view, textarea, shell } = bench({ disabled: true }) const { view, textarea, shell } = bench({ disabled: true })
expect((textarea as HTMLTextAreaElement).disabled).toBe(true) expect((textarea).disabled).toBe(true)
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect(shell.snapshot.phase).toBe('plain') expect(shell.snapshot.phase).toBe('plain')
}) })
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => { it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
const { textarea, sink } = bench({ running: true }) const { textarea, sink } = bench({ running: true })
expect((textarea as HTMLTextAreaElement).disabled).toBe(false) expect((textarea).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' }) fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队', 'queue') expect(sink).toHaveBeenCalledWith('排队', 'queue')

View File

@@ -12,7 +12,6 @@ import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react' import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
@@ -100,7 +99,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
await ctx.plugin(SlashService).await() await ctx.plugin(SlashService).await()
const slash = ctx.get('slash') as SlashService const slash = ctx.get('slash') as SlashService
register?.(slash) register?.(slash)
const actx = sessions.scope(sessionId)! as ClientContext const actx = sessions.scope(sessionId)!
const controller = slash.sessionOf(actx) const controller = slash.sessionOf(actx)
const sink = vi.fn() const sink = vi.fn()
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink }) const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
@@ -121,11 +120,11 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useSession: bindSnapshotSelector(sessionStore), useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({ useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready', ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'], })),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({ useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null, items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined, baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'], })),
useInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions, inputActions: shell.actions,
keyboard: shell, keyboard: shell,
@@ -134,7 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
variant: 'composer', variant: 'composer',
} }
const view = render(<InputBar {...barProps} />) const view = render(<InputBar {...barProps} />)
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement const textarea = view.container.querySelector('textarea')!
const type = (text: string): void => { const type = (text: string): void => {
fireEvent.change(textarea, { target: { value: text } }) fireEvent.change(textarea, { target: { value: text } })
} }
@@ -145,7 +144,7 @@ async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
const execute = vi.fn(executeImpl ?? ((line: string) => const execute = vi.fn(executeImpl ?? ((line: string) =>
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` }))) Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
const { source, executed } = commandSource(COMMANDS, execute) const { source, executed } = commandSource(COMMANDS, execute)
const base = await scopedBench((slash) => { slash.registerSource(source as never) }) const base = await scopedBench((slash) => { slash.registerSource(source) })
return { ...base, execute, executed } return { ...base, execute, executed }
} }

View File

@@ -33,7 +33,10 @@ function DetailsColumn(props: { children?: ReactNode }) {
return <div className={css.detailsCol}>{props.children}</div> return <div className={css.detailsCol}>{props.children}</div>
} }
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */ /**
* One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin.
* `side` keys the hover-reveal CSS to the owning column.
*/
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) { function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const origin = useRef(0) const origin = useRef(0)
@@ -86,7 +89,7 @@ export function AppFrame({
actions, actions,
renderSlot, renderSlot,
}: AppFrameProps) { }: AppFrameProps) {
const panels = useStore((s) => s) const panels = useStore(s => s)
const frameRef = useRef<HTMLDivElement | null>(null) const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth) const [viewport, setViewport] = useState(() => window.innerWidth)

View File

@@ -39,7 +39,7 @@ let fireResize: (() => void) | null = null
class ResizeObserverStub { class ResizeObserverStub {
#cb: ResizeObserverCallback #cb: ResizeObserverCallback
constructor(cb: ResizeObserverCallback) { this.#cb = cb } constructor(cb: ResizeObserverCallback) { this.#cb = cb }
observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } } observe(): void { fireResize = () => { this.#cb([], this) } }
unobserve(): void {} unobserve(): void {}
disconnect(): void { fireResize = null } disconnect(): void { fireResize = null }
} }
@@ -48,7 +48,7 @@ let frameWidth = 1920
/** Test-local selector hook over a framework-neutral store instance. */ /** Test-local selector hook over a framework-neutral store instance. */
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
} }
function mountFrame() { function mountFrame() {
@@ -118,7 +118,7 @@ beforeEach(() => {
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) }) vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
window.innerWidth = frameWidth window.innerWidth = frameWidth
Element.prototype.getBoundingClientRect = function () { Element.prototype.getBoundingClientRect = function () {
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } as DOMRect return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) }
} }
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass. // jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
const captured = new WeakSet<Element>() const captured = new WeakSet<Element>()
@@ -143,12 +143,12 @@ describe('AppFrame', () => {
const { slotCalls, getByTestId } = mountFrame() const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy() expect(getByTestId('center-content')).toBeTruthy()
expect(getByTestId('details-content')).toBeTruthy() expect(getByTestId('details-content')).toBeTruthy()
const keys = slotCalls.map((c) => c.key) const keys = slotCalls.map(c => c.key)
expect(keys).toContain('conversation') expect(keys).toContain('conversation')
expect(keys).toContain('details') expect(keys).toContain('details')
expect(keys).not.toContain('conversation.empty') expect(keys).not.toContain('conversation.empty')
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({}) expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({}) expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({})
}) })
it('keeps the conversation slot mounted while no session is current', () => { it('keeps the conversation slot mounted while no session is current', () => {
@@ -157,7 +157,7 @@ describe('AppFrame', () => {
sessionMode.current = false sessionMode.current = false
const { slotCalls, getByTestId } = mountFrame() const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy() expect(getByTestId('center-content')).toBeTruthy()
expect(slotCalls.map((c) => c.key)).toContain('conversation') expect(slotCalls.map(c => c.key)).toContain('conversation')
}) })
it('renders both column occupants before baselines settle (no loading gate)', () => { it('renders both column occupants before baselines settle (no loading gate)', () => {
@@ -165,13 +165,13 @@ describe('AppFrame', () => {
// pending rendering — both occupants mount from first paint. // pending rendering — both occupants mount from first paint.
baselinesReady.current = false baselinesReady.current = false
const { slotCalls } = mountFrame() const { slotCalls } = mountFrame()
expect(slotCalls.map((c) => c.key)).toContain('conversation') expect(slotCalls.map(c => c.key)).toContain('conversation')
expect(slotCalls.map((c) => c.key)).toContain('details') expect(slotCalls.map(c => c.key)).toContain('details')
}) })
it('sidebar slot receives live concession output as owner props', () => { it('sidebar slot receives live concession output as owner props', () => {
const { slotCalls } = mountFrame() const { slotCalls } = mountFrame()
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 }) expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
}) })
it('sidebar drag widens through rAF-batched pointer moves', () => { it('sidebar drag widens through rAF-batched pointer moves', () => {
@@ -211,7 +211,7 @@ describe('AppFrame', () => {
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360]) expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
expect(getByTestId('sidebar-content')).toBeTruthy() expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)! const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
}) })

View File

@@ -19,7 +19,7 @@ export function Button({ variant = 'ghost', size = 'md', icon, className, childr
variant?: ButtonVariant variant?: ButtonVariant
size?: 'md' | 'sm' size?: 'md' | 'sm'
icon?: ReactNode icon?: ReactNode
className?: string className?: string | undefined
children?: ReactNode children?: ReactNode
} & ButtonHTMLAttributes<HTMLButtonElement>) { } & ButtonHTMLAttributes<HTMLButtonElement>) {
return ( return (

View File

@@ -158,7 +158,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
// (open/toggle) after onSelect. // (open/toggle) after onSelect.
onClick={(e) => { e.stopPropagation() }} onClick={(e) => { e.stopPropagation() }}
> >
{items.map(entry => { {items.map((entry) => {
if (isSeparator(entry)) { if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" /> return <div key={entry.id} className={css.separator} role="separator" />
} }

View File

@@ -27,7 +27,8 @@ interface AnchorProps {
* Attach a hover/focus tooltip to an anchor element. * Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text. * @param props.label - bubble text.
* @param props.side - placement relative to the anchor (default 'right'). * @param props.side - placement relative to the anchor (default 'right').
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions). * @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/ */

View File

@@ -20,6 +20,9 @@ export interface CodeBlockProps {
/** @returns true only when the host accepted the write. */ /** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> { async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
@@ -30,6 +33,9 @@ async function writeClipboard(text: string): Promise<boolean> {
} }
} }
// jsdom and older hosts: best-effort execCommand path when present. // jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function' const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document) ? document.execCommand.bind(document)
: undefined : undefined
@@ -48,6 +54,7 @@ async function writeClipboard(text: string): Promise<boolean> {
} finally { } finally {
el.remove() el.remove()
} }
/* eslint-enable @typescript-eslint/no-deprecated */
} }
export function CodeBlock({ code, lang, className }: CodeBlockProps) { export function CodeBlock({ code, lang, className }: CodeBlockProps) {
@@ -64,7 +71,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
void writeClipboard(text).then((ok) => { void writeClipboard(text).then((ok) => {
if (!ok) return if (!ok) return
setCopied(true) setCopied(true)
window.setTimeout(() => setCopied(false), 1000) window.setTimeout(() => { setCopied(false) }, 1000)
}) })
}, [copied, trimmed]) }, [copied, trimmed])
@@ -73,9 +80,9 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
<pre className={css.plain}><code>{trimmed}</code></pre> <pre className={css.plain}><code>{trimmed}</code></pre>
) )
: ( : (
// eslint-disable-next-line react/no-danger -- shiki's output is a static // shiki's output is a static span tree it generated from `code` (no user
// span tree it generated from `code` (no user HTML passes through), the // HTML passes through), the sanctioned innerHTML consumption path per
// sanctioned innerHTML consumption path per shiki's own docs. // shiki's own docs.
<div dangerouslySetInnerHTML={{ __html: html }} /> <div dangerouslySetInnerHTML={{ __html: html }} />
) )

View File

@@ -15,6 +15,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
if (!open) return '' if (!open) return ''
let s: string let s: string
try { try {
// lib typing hides stringify's undefined arm (undefined/function/symbol payloads).
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
s = JSON.stringify(payload, null, 2) ?? String(payload) s = JSON.stringify(payload, null, 2) ?? String(payload)
} catch { } catch {
s = String(payload) s = String(payload)
@@ -23,7 +25,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
}, [open, payload]) }, [open, payload])
return ( return (
<div className={css.root}> <div className={css.root}>
<button type="button" className={css.toggle} onClick={() => setOpen((v) => !v)}> <button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
{open ? '▾' : '▸'} {label} {open ? '▾' : '▸'} {label}
</button> </button>
{open && <pre className={css.body}>{body}</pre>} {open && <pre className={css.body}>{body}</pre>}

View File

@@ -53,7 +53,9 @@ function buildComponents(streaming: boolean): Components {
// plain arm — retokenizing a growing fence on every chunk is quadratic // plain arm — retokenizing a growing fence on every chunk is quadratic
// main-thread work; the finalize swap highlights it once. // main-thread work; the finalize swap highlights it once.
pre: ({ children }) => { pre: ({ children }) => {
/* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */ // The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
/* v8 ignore next 2 */
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children const raw = child?.props.children
// A fence whose content isn't one plain string (e.g. an empty fence) // A fence whose content isn't one plain string (e.g. an empty fence)

View File

@@ -13,7 +13,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
wrapper.getBoundingClientRect = () => ({ wrapper.getBoundingClientRect = () => ({
top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34, top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}), width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
} as DOMRect) })
} }
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {

View File

@@ -18,7 +18,7 @@ describe('ic_ds_ icon set', () => {
expect(iconNames.length).toBe(55) expect(iconNames.length).toBe(55)
}) })
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
const Icon = icons[name]! const Icon = icons[name]!
const { container } = render(<Icon />) const { container } = render(<Icon />)
const svg = container.querySelector('svg') const svg = container.querySelector('svg')

View File

@@ -153,7 +153,7 @@ describe('JsonBlock', () => {
it('truncates beyond the size cap with a suffix note', () => { it('truncates beyond the size cap with a suffix note', () => {
const big = 'x'.repeat(30_000) const big = 'x'.repeat(30_000)
const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />) const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
const body = container.querySelector('pre')!.textContent! const body = container.querySelector('pre')!.textContent
expect(body.length).toBeLessThan(30_000) expect(body.length).toBeLessThan(30_000)
expect(body).toContain('截断') expect(body).toContain('截断')
}) })

View File

@@ -7,7 +7,7 @@ import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup) afterEach(cleanup)
describe('StateDot', () => { describe('StateDot', () => {
it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', state => { it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', (state) => {
const { container } = render(<StateDot state={state} />) const { container } = render(<StateDot state={state} />)
const dot = container.firstElementChild as HTMLElement const dot = container.firstElementChild as HTMLElement
expect(dot.dataset['state']).toBe(state) expect(dot.dataset['state']).toBe(state)

View File

@@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string {
/** Return whether a textarea key event belongs to an active IME composition. */ /** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean { function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
} }
@@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
}))) })))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null) const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const question = questions[index]! const question = questions[index]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const draft = drafts[index]! const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0 const hasOptions = (question.options?.length ?? 0) > 0

View File

@@ -50,7 +50,7 @@ const QUESTIONS = [
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */ /** Carrier fixture: a real PendingWait over a scripted respond carrier. */
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) { function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
const carrier = new PendingWait( const carrier = new PendingWait(
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond) 'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond)
return { carrier, respond } return { carrier, respond }
} }
@@ -99,7 +99,7 @@ describe('QuestionComposer', () => {
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' }, { id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] }, { id: 'signals', selected: ['系统设计', '代码质量'] },
])) ]))
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
}) })
it('skips individual questions without discarding earlier answers', () => { it('skips individual questions without discarding earlier answers', () => {
@@ -173,7 +173,7 @@ describe('QuestionComposer', () => {
// Receipt rejection surfaces through the domain face's thrown message. // Receipt rejection surfaces through the domain face's thrown message.
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy() expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false) expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('第二次取消失败')).toBeTruthy() expect(await screen.findByText('第二次取消失败')).toBeTruthy()
@@ -199,7 +199,7 @@ describe('QuestionComposer', () => {
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' })) fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('网络中断')).toBeTruthy() expect(await screen.findByText('网络中断')).toBeTruthy()
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false) expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '提交' })) fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('字符串错误')).toBeTruthy() expect(await screen.findByText('字符串错误')).toBeTruthy()

View File

@@ -49,7 +49,7 @@ describe('GeneralSection', () => {
mount() mount()
expect(screen.getByText('Permission')).toBeTruthy() expect(screen.getByText('Permission')).toBeTruthy()
expect(screen.getByText('Choose default permission mode')).toBeTruthy() expect(screen.getByText('Choose default permission mode')).toBeTruthy()
const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement const selector = screen.getByRole<HTMLButtonElement>('button', { name: /Read only/ })
expect(selector.disabled).toBe(true) expect(selector.disabled).toBe(true)
}) })

View File

@@ -34,7 +34,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
// Local selection; entries can unmount underneath it, so the render-time // Local selection; entries can unmount underneath it, so the render-time
// projection falls back to the first row when the id is gone. // projection falls back to the first row when the id is gone.
const [activeId, setActiveId] = useState<string | undefined>(undefined) const [activeId, setActiveId] = useState<string | undefined>(undefined)
const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
const titleId = useId() const titleId = useId()
useEffect(() => { useEffect(() => {
@@ -56,7 +56,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
<nav className={css.nav}> <nav className={css.nav}>
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div> <div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
<div className={css.navList}> <div className={css.navList}>
{rows.map((row) => ( {rows.map(row => (
<button <button
key={row.id} key={row.id}
type="button" type="button"

View File

@@ -26,7 +26,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
startSession={startSession} toggleSidebar={toggleSidebar} startSession={startSession} toggleSidebar={toggleSidebar}
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => { renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
if (key === 'sidebar.settings') { if (key === 'sidebar.settings') {
settingsOwner = owner as SidebarSettingsOwnerProps settingsOwner = owner
return <div data-testid="settings-seat" data-wide={owner.wide} /> return <div data-testid="settings-seat" data-wide={owner.wide} />
} }
regionOwner = owner as SidebarSectionOwnerProps regionOwner = owner as SidebarSectionOwnerProps

View File

@@ -34,5 +34,5 @@ export interface MenuViewInjected {
* @param source - source (group) name. * @param source - source (group) name.
* @param index - candidate index within the group. * @param index - candidate index within the group.
*/ */
onPick(source: string, index: number): void onPick: (source: string, index: number) => void
} }

View File

@@ -41,7 +41,7 @@ function createPanelStore() {
}) })
} }
const chatStore = () => defineStore({ const _chatStore = () => defineStore({
init: () => ({ selection: null as { id: string } | null, draft: '' }), init: () => ({ selection: null as { id: string } | null, draft: '' }),
actions: { actions: {
select: (d, t: { id: string }) => { d.selection = t }, select: (d, t: { id: string }) => { d.selection = t },
@@ -49,7 +49,7 @@ const chatStore = () => defineStore({
clearDraft: (d) => { d.draft = '' }, clearDraft: (d) => { d.draft = '' },
}, },
}) })
type ChatHandle = ReturnType<typeof chatStore> type ChatHandle = ReturnType<typeof _chatStore>
type FrameProps = type FrameProps =
& PropsRuntime<'chain.frame'> & PropsRuntime<'chain.frame'>
@@ -115,7 +115,7 @@ describe('terminal-design type chain', () => {
// member payloads are the runtime merge's property — not probed here // member payloads are the runtime merge's property — not probed here
// (the runtime package's own tests cover them). // (the runtime package's own tests cover them).
fp.renderSlot('chain.side', { collapsed: false, width: 280 }) fp.renderSlot('chain.side', { collapsed: false, width: 280 })
const draft: string = cp.useStore((s) => s.draft) const draft: string = cp.useStore(s => s.draft)
cp.actions.select({ id: 'm1' }) cp.actions.select({ id: 'm1' })
void draft void draft
@@ -127,7 +127,7 @@ describe('terminal-design type chain', () => {
// chain position. // chain position.
core.register({ core.register({
name: 'chain.takeover', name: 'chain.takeover',
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null, select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
priority: 1, priority: 1,
}, Takeover) }, Takeover)
@@ -135,7 +135,7 @@ describe('terminal-design type chain', () => {
// checks through parameter contravariance. // checks through parameter contravariance.
core.register({ core.register({
name: 'chain.takeover', name: 'chain.takeover',
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null, select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
}, WideTakeover) }, WideTakeover)
// renderSlotChain share: chain keys dispatch with the fallback bag; // renderSlotChain share: chain keys dispatch with the fallback bag;
@@ -179,7 +179,7 @@ describe('terminal-design type chain', () => {
name: 'chain.side', name: 'chain.side',
// @ts-expect-error root-scope inject has no sessionId parameter // @ts-expect-error root-scope inject has no sessionId parameter
inject: (sessionId: string) => ({ x: sessionId }), inject: (sessionId: string) => ({ x: sessionId }),
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>) }, (_p => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
// keyed registration without key. // keyed registration without key.
// @ts-expect-error keyed registration requires options.key // @ts-expect-error keyed registration requires options.key
@@ -195,14 +195,14 @@ describe('terminal-design type chain', () => {
// @ts-expect-error component matched prop drifts from the select return // @ts-expect-error component matched prop drifts from the select return
core.register({ core.register({
name: 'chain.takeover', name: 'chain.takeover',
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null, select: ({ items }: { items: readonly Item[] }) => items.find(i => i.kind === 'q') ?? null,
}, NarrowTakeover) }, NarrowTakeover)
// select must return M | null, not undefined (find() must be coalesced). // select must return M | null, not undefined (find() must be coalesced).
// @ts-expect-error select may not return undefined // @ts-expect-error select may not return undefined
core.register({ core.register({
name: 'chain.takeover', name: 'chain.takeover',
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'), select: ({ items }: { items: readonly Item[] }) => items.find(i => i.kind === 'q'),
}, Takeover) }, Takeover)
// Chain keys are not renderSlot-dispatchable (and vice versa). // Chain keys are not renderSlot-dispatchable (and vice versa).
@@ -211,7 +211,7 @@ describe('terminal-design type chain', () => {
// @ts-expect-error non-chain keys have no renderSlotChain dispatch // @ts-expect-error non-chain keys have no renderSlotChain dispatch
chainSlots.renderSlotChain('chain.conv', {}) chainSlots.renderSlotChain('chain.conv', {})
// @ts-expect-error a children set without chain keys provides no renderSlotChain // @ts-expect-error a children set without chain keys provides no renderSlotChain
fp.renderSlotChain type _NoChainSeat = typeof fp.renderSlotChain
// renderSlot owner share typed at the call site. // renderSlot owner share typed at the call site.
// @ts-expect-error owner shape mismatch (width missing) // @ts-expect-error owner shape mismatch (width missing)

View File

@@ -16,11 +16,11 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
subtool: 'Sub', subtool: 'Sub',
} }
const TAG_CLASS: Record<TrajectoryCellKind, string> = { const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
user: css.tagUser!, user: css.tagUser,
message: css.tagMessage!, message: css.tagMessage,
tool: css.tagTool!, tool: css.tagTool,
subtool: css.tagSubtool!, subtool: css.tagSubtool,
} }
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> { export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
@@ -84,7 +84,7 @@ export function TrajectoryCell({
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}> <div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
<span className={css.index}>#{index}</span> <span className={css.index}>#{index}</span>
<span className={css.tagSlot}> <span className={css.tagSlot}>
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span> <span className={[css.tag, TAG_CLASS[kind]].filter((c): c is string => c !== undefined).join(' ')}>{KIND_LABEL[kind]}</span>
</span> </span>
<span className={css.text}>{text}</span> <span className={css.text}>{text}</span>
<span className={css.trailing}> <span className={css.trailing}>

View File

@@ -14,7 +14,7 @@ import css from './TrajectoryStatsHeader.module.css'
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> } export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) { export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
const nodes = useSession((s) => s.nodes) const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes]) const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
if (stats.turns === 0) return null if (stats.turns === 0) return null
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div> return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>

View File

@@ -20,7 +20,7 @@ export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
<div className={css.inner}> <div className={css.inner}>
<span className={css.title}>Turn {turn}</span> <span className={css.title}>Turn {turn}</span>
<div className={css.columns} aria-hidden="true"> <div className={css.columns} aria-hidden="true">
{COLUMN_LABELS.map((label) => ( {COLUMN_LABELS.map(label => (
<span key={label} className={css.column}>{label}</span> <span key={label} className={css.column}>{label}</span>
))} ))}
</div> </div>

View File

@@ -9,10 +9,10 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css' import css from './views.module.css'
export function TrajectoryView({ useSession }: ConvViewProps) { export function TrajectoryView({ useSession }: ConvViewProps) {
const nodes = useSession((s) => s.nodes) const nodes = useSession(s => s.nodes)
const partial = useSession((s) => s.partial) const partial = useSession(s => s.partial)
const runningCalls = useSession((s) => s.runningCalls) const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession((s) => s.codeDispatches) const codeDispatches = useSession(s => s.codeDispatches)
const turns = useMemo( const turns = useMemo(
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }), () => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
[nodes, partial, runningCalls, codeDispatches], [nodes, partial, runningCalls, codeDispatches],
@@ -22,15 +22,15 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
} }
return ( return (
<div className={css.root}> <div className={css.root}>
{turns.map((turn) => ( {turns.map(turn => (
<TrajectoryTurn key={turn.turn} turn={turn.turn}> <TrajectoryTurn key={turn.turn} turn={turn.turn}>
{turn.groups.flatMap((group) => [ {turn.groups.flatMap(group => [
<TrajectoryGroupHeader <TrajectoryGroupHeader
key={`${group.title}-h`} key={`${group.title}-h`}
title={group.title} title={group.title}
{...(group.description !== undefined ? { description: group.description } : {})} {...(group.description !== undefined ? { description: group.description } : {})}
/>, />,
...group.cells.map((cell) => ( ...group.cells.map(cell => (
<TrajectoryCell key={cell.index} {...cell} /> <TrajectoryCell key={cell.index} {...cell} />
)), )),
])} ])}

View File

@@ -24,8 +24,8 @@ export interface WaterfallExtraProps {
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) { export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
const scale = pxPerNode ?? PX_PER_NODE const scale = pxPerNode ?? PX_PER_NODE
const nodes = useSession((s) => s.nodes) const nodes = useSession(s => s.nodes)
const codeDispatches = useSession((s) => s.codeDispatches) const codeDispatches = useSession(s => s.codeDispatches)
const spans = useMemo(() => deriveSpans(nodes), [nodes]) const spans = useMemo(() => deriveSpans(nodes), [nodes])
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches]) const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div> if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
@@ -50,7 +50,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
/> />
)} )}
</div> </div>
{(subSpans.get(span.turn) ?? []).map((lane) => ( {(subSpans.get(span.turn) ?? []).map(lane => (
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}> <div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
<span className={css.subTag}>{lane.name}</span> <span className={css.subTag}>{lane.name}</span>
<span <span

View File

@@ -58,7 +58,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('381')).toBeTruthy() expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy() expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('+235.2s')).toBeTruthy() expect(screen.getByText('+235.2s')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent) const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381')) expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155')) expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s')) expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))

View File

@@ -73,13 +73,13 @@ describe('deriveTrajectoryLayout', () => {
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1) expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1) expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind)) const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
expect(kinds).toEqual(['user', 'message', 'tool']) expect(kinds).toEqual(['user', 'message', 'tool'])
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({ expect(message).toMatchObject({
input: 10, output: 20, think: 5, timeSeconds: 5, input: 10, output: 20, think: 5, timeSeconds: 5,
}) })
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool') const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
expect(tool?.text).toBe('bash · {"command":"ls"}') expect(tool?.text).toBe('bash · {"command":"ls"}')
expect(tool?.timeSeconds).toBe(1.3) expect(tool?.timeSeconds).toBe(1.3)
}) })
@@ -87,14 +87,14 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => { it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({ const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), codeDispatches: new Map(),
nodes: [] as unknown as ConversationSnapshot['nodes'], nodes: [],
partial: null, partial: null,
runningCalls: [{ runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null, turn: 1, step: 2, time: 9_000, callView: null,
}], }],
}) })
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2']) expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({ expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null, kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
}) })
@@ -113,9 +113,9 @@ describe('deriveTrajectoryLayout', () => {
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? [] const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull() expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined() expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
}) })
it('builds a wall-span step description with a tool histogram', () => { it('builds a wall-span step description with a tool histogram', () => {
@@ -156,9 +156,9 @@ describe('deriveTrajectoryLayout', () => {
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns.map((t) => t.turn)).toEqual([1, 2]) expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1']) expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2']) expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
}) })
it('keeps usage on the fallback Message row when assistant has no text block', () => { it('keeps usage on the fallback Message row when assistant has no text block', () => {
@@ -170,7 +170,7 @@ describe('deriveTrajectoryLayout', () => {
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({ expect(message).toMatchObject({
text: '', input: 11, output: 22, think: 3, text: '', input: 11, output: 22, think: 3,
}) })
@@ -199,8 +199,8 @@ describe('deriveTrajectoryLayout', () => {
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups const message = turns[0]?.groups
.flatMap((g) => g.cells) .flatMap(g => g.cells)
.find((c) => c.kind === 'message' && c.text === 'done') .find(c => c.kind === 'message' && c.text === 'done')
// From context at 9s, not from the earlier user/tool surfaces. // From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1) expect(message?.timeSeconds).toBe(1)
}) })
@@ -234,10 +234,10 @@ describe('run_code sub-dispatch cells', () => {
settledSub(2, 'read', 7_300, 7_800), settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches'] ]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap((g) => g.cells) const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool']) expect(cells.map(c => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
// Sequential indexes across the interleave; durations from the pair times. // Sequential indexes across the interleave; durations from the pair times.
expect(cells.map((c) => c.index)).toEqual([1, 2, 3]) expect(cells.map(c => c.index)).toEqual([1, 2, 3])
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 }) expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
}) })
@@ -249,7 +249,7 @@ describe('run_code sub-dispatch cells', () => {
} }
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches'] const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap((g) => g.cells).find((c) => c.kind === 'subtool') const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null }) expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
}) })
}) })

View File

@@ -144,7 +144,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
version: () => slots.getVersion('conversation.view'), version: () => slots.getVersion('conversation.view'),
}} }}
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never} useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() } as never} inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}} bindDraftMirror={() => () => {}}
open={vi.fn()} open={vi.fn()}
/>, />,
@@ -164,7 +164,7 @@ describe('plugin registration', () => {
it('fiber disposal removes both tabs and leaves chat standing', async () => { it('fiber disposal removes both tabs and leaves chat standing', async () => {
const b = await bench() const b = await bench()
await b.fiber.dispose() await b.fiber.dispose()
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat']) expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
}) })
}) })
@@ -173,7 +173,7 @@ describe('tab switching in ConversationRoot', () => {
const b = await bench() const b = await bench()
mount(b.slots) mount(b.slots)
expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.queryByText(/turns ·/)).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -198,7 +198,7 @@ describe('tab switching in ConversationRoot', () => {
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => { it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
const b = await bench() const b = await bench()
mount(b.slots, [] as unknown as ConversationSnapshot['nodes']) mount(b.slots, [])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy() expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
expect(screen.queryByText(/turns ·/)).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -221,12 +221,12 @@ describe('span derivation', () => {
}) })
it('empty inputs produce zero stats and standalone components render their empty forms', () => { it('empty inputs produce zero stats and standalone components render their empty forms', () => {
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 }) expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes']) const { useSession } = fakeSession([])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never })) const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
expect(container.firstChild).toBeNull() expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>, render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes']))) standaloneProps([])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy() expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
}) })
}) })
@@ -234,7 +234,7 @@ describe('span derivation', () => {
describe('WaterfallView standalone branches', () => { describe('WaterfallView standalone branches', () => {
it('empty window renders the placeholder copy', () => { it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>, render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes']))) standaloneProps([])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy() expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
}) })
@@ -248,7 +248,7 @@ describe('WaterfallView standalone branches', () => {
describe('node half', () => { describe('node half', () => {
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => { it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
expect(nodeApply()).toBeUndefined() expect(() => { nodeApply() }).not.toThrow()
}) })
}) })
@@ -295,7 +295,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null }, { callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
]]]) as unknown as ConversationSnapshot['codeDispatches'] ]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches) const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const running = lanes.get(3)?.find((lane) => lane.name === 'grep') const running = lanes.get(3)?.find(lane => lane.name === 'grep')
expect(running).toMatchObject({ durationMs: null, timing: 'running' }) expect(running).toMatchObject({ durationMs: null, timing: 'running' })
// Extends from its start to the window end. // Extends from its start to the window end.
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1) expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)

View File

@@ -21,7 +21,10 @@ import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx' import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css' import css from './WorkspaceBrowser.module.css'
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ /**
* Column slide length (--ds-transition-duration-slow): rail-search focus waits it out —
* focus() forces a synchronous layout and would jank the slide.
*/
const EXPAND_SLIDE_MS = 300 const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [ const GROUP_BY_ITEMS = [
@@ -32,7 +35,7 @@ const GROUP_BY_ITEMS = [
/** Immutable membership toggle for the local expansion arrays. */ /** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] { function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
} }
/** Group-by strategy menu; own open state so it resets with the wide chrome. */ /** Group-by strategy menu; own open state so it resets with the wide chrome. */
@@ -61,7 +64,7 @@ function GroupByMenu({ groupBy, onPick }: {
type="button" type="button"
className={clsx(css.iconButton, css.wide)} className={clsx(css.iconButton, css.wide)}
aria-label="Group by" aria-label="Group by"
onClick={() => { setOpen((v) => !v) }} onClick={() => { setOpen(v => !v) }}
> >
<IconPersonalizationOutline16 /> <IconPersonalizationOutline16 />
</button> </button>
@@ -96,7 +99,7 @@ function SessionTree({
useSessions, startSession, open, workspaces, query, useSessions, startSession, open, workspaces, query,
onRenameRequest, onDeleteRequest, insertSessionBefore, onRenameRequest, onDeleteRequest, insertSessionBefore,
}: SessionTreeProps) { }: SessionTreeProps) {
const list = useSessions((s) => s) const list = useSessions(s => s)
const current = list.current const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([]) const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([]) const [expandedSessions, setExpandedSessions] = useState<string[]>([])
@@ -108,7 +111,7 @@ function SessionTree({
?? UNGROUPED_KEY ?? UNGROUPED_KEY
useEffect(() => { useEffect(() => {
if (current === undefined || currentGroup === undefined) return if (current === undefined || currentGroup === undefined) return
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup]) }, [current, currentGroup])
const groups = useMemo( const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
@@ -129,7 +132,7 @@ function SessionTree({
<div key={group.key} className={css.groupSection}> <div key={group.key} className={css.groupSection}>
<ProjectRowItem <ProjectRowItem
group={group} group={group}
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }} onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
onCreate={() => { onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId) if (group.workspaceId !== undefined) startSession(group.workspaceId)
}} }}
@@ -189,7 +192,7 @@ function SessionTree({
currentId={current} currentId={current}
now={now} now={now}
onOpen={open} onOpen={open}
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }} onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
drag={dragProps} drag={dragProps}
/> />
) )
@@ -204,7 +207,7 @@ function SessionTree({
/** The flat "In one list" body: every session a top-level row, newest-first. */ /** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) { function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
const list = useSessions((s) => s) const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now() const now = Date.now()
return ( return (
@@ -292,7 +295,7 @@ export function WorkspaceBrowser({
setRenameError(null) setRenameError(null)
} }
const confirmRename = () => { const confirmRename = () => {
if (renameBlocked || renameTarget === null) return if (renameBlocked) return
setRenaming(true) setRenaming(true)
setRenameError(null) setRenameError(null)
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => { renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
@@ -481,7 +484,7 @@ export function WorkspaceBrowser({
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button> <Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button <Button
variant="outline" variant="outline"
className={css.deleteAction!} className={css.deleteAction}
disabled={deleting} disabled={deleting}
onClick={confirmDelete} onClick={confirmDelete}
> >

View File

@@ -71,7 +71,7 @@ export function WorkspaceCreateFlow({
const items: MenuEntry[] = [ const items: MenuEntry[] = [
...workspaces.map(workspace => ({ ...workspaces.map(workspace => ({
id: workspace.workspaceId as string, id: workspace.workspaceId,
label: workspace.title, label: workspace.title,
icon: <IconFolderClose16 size={16} />, icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder, disabled: pickingFolder,
@@ -161,8 +161,8 @@ export function WorkspaceCreateFlow({
title={folderConflict ? 'A workspace with this name already exists' : 'Couldn’t open folder'} title={folderConflict ? 'A workspace with this name already exists' : 'Couldn’t open folder'}
footer={( footer={(
<> <>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button> <Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction!} onClick={openLocalFolder}>Choose again</Button> <Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
</> </>
)} )}
> >
@@ -179,10 +179,10 @@ export function WorkspaceCreateFlow({
description="The name is used for both the workspace and its new folder." description="The name is used for both the workspace and its new folder."
footer={( footer={(
<> <>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button> <Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button <Button
variant="primary" variant="primary"
className={css.modalAction!} className={css.modalAction}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName} disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate} onClick={confirmCreate}
> >

View File

@@ -59,9 +59,9 @@ export type WorkspaceBrowserProps =
*/ */
export type WorkspacePickerInjected = { export type WorkspacePickerInjected = {
/** Explicitly create or adopt a real Workspace before targeting a Session. */ /** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView> createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */ /** Ask the local Host to open its native single-directory picker. */
pickDirectory(): Promise<string | null> pickDirectory: () => Promise<string | null>
} }
/** /**

View File

@@ -16,7 +16,7 @@ function stubRect(row: HTMLElement): void {
row.getBoundingClientRect = () => ({ row.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
x: 0, y: 100, toJSON: () => ({}), x: 0, y: 100, toJSON: () => ({}),
} as DOMRect) })
} }
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps { function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {

View File

@@ -32,7 +32,9 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true, items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId, recentWorkspaceId: items[0]?.workspaceId,
}) })
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot) function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
@@ -297,7 +299,7 @@ describe('WorkspaceBrowser', () => {
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement] const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
three.getBoundingClientRect = () => ({ three.getBoundingClientRect = () => ({
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
} as DOMRect) })
const dataTransfer = { effectAllowed: '', dropEffect: '' } const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer }) fireEvent.dragStart(one, { dataTransfer })
// Drop on the top half of "three": insert one before three. // Drop on the top half of "three": insert one before three.
@@ -310,7 +312,7 @@ describe('WorkspaceBrowser', () => {
fireEvent.dragStart(one, { dataTransfer }) fireEvent.dragStart(one, { dataTransfer })
one.getBoundingClientRect = () => ({ one.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
} as DOMRect) })
fireDrag(one, 'dragOver', 105) fireDrag(one, 'dragOver', 105)
fireDrag(one, 'drop', 105) fireDrag(one, 'drop', 105)
expect(insertSessionBefore).toHaveBeenCalledTimes(1) expect(insertSessionBefore).toHaveBeenCalledTimes(1)
@@ -336,7 +338,7 @@ describe('WorkspaceBrowser', () => {
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
two.getBoundingClientRect = () => ({ two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect) })
fireDrag(two, 'drop', 155) fireDrag(two, 'drop', 155)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two')) expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
}) })
@@ -353,7 +355,7 @@ describe('WorkspaceBrowser', () => {
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({ two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect) })
const dataTransfer = { effectAllowed: '', dropEffect: '' } const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer }) fireEvent.dragStart(one, { dataTransfer })
fireEvent.dragEnd(one) fireEvent.dragEnd(one)
@@ -382,7 +384,7 @@ describe('WorkspaceBrowser', () => {
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({ two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect) })
const dataTransfer = { effectAllowed: '', dropEffect: '' } const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer }) fireEvent.dragStart(one, { dataTransfer })
fireDrag(two, 'drop', 180) fireDrag(two, 'drop', 180)
@@ -404,13 +406,13 @@ describe('WorkspaceBrowser', () => {
const input = screen.getByLabelText<HTMLInputElement>('Workspace name') const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
expect(input.value).toBe('Alpha') expect(input.value).toBe('Alpha')
// Unchanged and blank names stay blocked. // Unchanged and blank names stay blocked.
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: ' ' } }) fireEvent.change(input, { target: { value: ' ' } })
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
// A duplicate of another workspace's title shows the inline conflict. // A duplicate of another workspace's title shows the inline conflict.
fireEvent.change(input, { target: { value: ' Beta ' } }) fireEvent.change(input, { target: { value: ' Beta ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.') expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: 'Gamma' } }) fireEvent.change(input, { target: { value: 'Gamma' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' })) fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma') expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
@@ -473,13 +475,13 @@ describe('WorkspaceBrowser', () => {
expect(dialog.textContent).toContain('folder and session logs will be kept') expect(dialog.textContent).toContain('folder and session logs will be kept')
expect(dialog.textContent).toContain('sessions will appear under Ungrouped') expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
fireEvent.click(confirm) fireEvent.click(confirm)
fireEvent.click(confirm) fireEvent.click(confirm)
expect(deleteWorkspace).toHaveBeenCalledOnce() expect(deleteWorkspace).toHaveBeenCalledOnce()
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha')) expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
expect(confirm.disabled).toBe(true) expect(confirm.disabled).toBe(true)
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…') expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
fireEvent.keyDown(document, { key: 'Escape' }) fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Close' })) fireEvent.click(screen.getByRole('button', { name: 'Close' }))

View File

@@ -16,7 +16,9 @@ function workspace(id: string, title = id): WorkspaceView {
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
} }
} }
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot) function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
const sessions: SessionListState = { const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready', ids: [], byId: {}, current: undefined, phase: 'ready',
} }
@@ -131,8 +133,8 @@ describe('WorkspacePicker', () => {
const pending = new Promise<string | null>((settle) => { resolve = settle }) const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending)) const b = mount([], vi.fn(), vi.fn(() => pending))
chooseItem('Open local folder…') chooseItem('Open local folder…')
expect((screen.getByRole('menuitem', { name: 'Open local folder…' }) as HTMLButtonElement).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect((screen.getByRole('menuitem', { name: 'Create a new workspace' }) as HTMLButtonElement).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
expect(b.pickDirectory).toHaveBeenCalledTimes(1) expect(b.pickDirectory).toHaveBeenCalledTimes(1)
await act(async () => { resolve(null); await pending }) await act(async () => { resolve(null); await pending })
@@ -159,7 +161,7 @@ describe('WorkspacePicker', () => {
chooseItem('Create a new workspace') chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' }) fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
expect(b.createWorkspace).not.toHaveBeenCalled() expect(b.createWorkspace).not.toHaveBeenCalled()
}) })

View File

@@ -200,7 +200,8 @@ function standardKit(
scope: SlotScope, scope: SlotScope,
info: SessionMaybeProvideInfo | undefined, info: SessionMaybeProvideInfo | undefined,
): { ): {
kit: InjectedProps; actions: object | undefined kit: InjectedProps
actions: object | undefined
} { } {
const kit: InjectedProps = { const kit: InjectedProps = {
useSessions: observableHook(host.sessions.list), useSessions: observableHook(host.sessions.list),
@@ -232,13 +233,13 @@ function standardKit(
kit['renderSlot'] = boundRenderSlot(host, entry) kit['renderSlot'] = boundRenderSlot(host, entry)
// renderSlotChain rides the same declaration source: only entries whose // renderSlotChain rides the same declaration source: only entries whose
// children include a chain-kind slot receive the chain dispatch seat. // children include a chain-kind slot receive the chain dispatch seat.
if (Object.values(entry.children).some((spec) => spec.kind === 'chain')) { if (Object.values(entry.children).some(spec => spec.kind === 'chain')) {
kit['renderSlotChain'] = boundRenderSlotChain(host, entry) kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
} }
// SessionProvider standard seat: entries declaring a session-scope child // SessionProvider standard seat: entries declaring a session-scope child
// render the session area, so the framework hands them the self-wired // render the session area, so the framework hands them the self-wired
// provider (module-level component = stable reference; no value import). // provider (module-level component = stable reference; no value import).
if (Object.values(entry.children).some((spec) => spec.scope === 'session')) { if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
kit['SessionProvider'] = SessionProvider kit['SessionProvider'] = SessionProvider
} }
} }
@@ -253,7 +254,9 @@ function standardKit(
* composition point, one per scope branch). * composition point, one per scope branch).
*/ */
function SessionEntry({ entry, ownerProps, info }: { function SessionEntry({ entry, ownerProps, info }: {
entry: StoredEntry; ownerProps: object; info: SessionProvideInfo entry: StoredEntry
ownerProps: object
info: SessionProvideInfo
}) { }) {
const host = useHost() const host = useHost()
const Comp = entry.component as FC<InjectedProps> const Comp = entry.component as FC<InjectedProps>
@@ -280,7 +283,9 @@ function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: obje
} }
function StrictSessionEntry({ slotKey, entry, ownerProps }: { function StrictSessionEntry({ slotKey, entry, ownerProps }: {
slotKey: string; entry: StoredEntry; ownerProps: object slotKey: string
entry: StoredEntry
ownerProps: object
}) { }) {
const info = useSessionMaybeProvideInfo() const info = useSessionMaybeProvideInfo()
if (info.sessionId === undefined) return null if (info.sessionId === undefined) return null
@@ -292,12 +297,14 @@ function StrictSessionEntry({ slotKey, entry, ownerProps }: {
} }
function SlotOutlet({ slotKey, ownerProps, opts }: { function SlotOutlet({ slotKey, ownerProps, opts }: {
slotKey: string; ownerProps: object; opts?: (RenderOpts & ChainRenderOpts) | undefined slotKey: string
ownerProps: object
opts?: (RenderOpts & ChainRenderOpts) | undefined
}) { }) {
const host = useHost() const host = useHost()
// Version tick drives entries() re-read; the host batches per microtask. // Version tick drives entries() re-read; the host batches per microtask.
useSyncExternalStore( useSyncExternalStore(
(fn) => host.subscribe(slotKey, fn), fn => host.subscribe(slotKey, fn),
() => host.getVersion(slotKey), () => host.getVersion(slotKey),
) )
const sessionInfo = useSessionMaybeProvideInfo() const sessionInfo = useSessionMaybeProvideInfo()
@@ -335,7 +342,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
return guarded(entry) return guarded(entry)
} }
if (spec.kind === 'keyed') { if (spec.kind === 'keyed') {
const entry = entries.find((e) => e.options?.key === opts?.entryKey) const entry = entries.find(e => e.options.key === opts?.entryKey)
if (!entry) return <>{opts?.fallback ?? null}</> if (!entry) return <>{opts?.fallback ?? null}</>
return guarded(entry) return guarded(entry)
} }
@@ -388,13 +395,13 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
return elected ?? <>{opts?.fallback ?? null}</> return elected ?? <>{opts?.fallback ?? null}</>
} }
// list: registration order refined by explicit order, optional id filter. // list: registration order refined by explicit order, optional id filter.
const withListOptions = entries.map((entry) => ({ const withListOptions = entries.map(entry => ({
entry, entry,
id: entry.options?.id, id: entry.options.id,
order: entry.options?.order ?? 0, order: entry.options.order ?? 0,
})) }))
let list = [...withListOptions].sort((a, b) => a.order - b.order) let list = [...withListOptions].sort((a, b) => a.order - b.order)
if (opts?.only !== undefined) list = list.filter((item) => item.id === opts.only) if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only)
if (list.length === 0) return <>{opts?.fallback ?? null}</> if (list.length === 0) return <>{opts?.fallback ?? null}</>
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</> return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
} }
@@ -403,7 +410,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
function RootOutlet({ ownerProps }: { ownerProps: object }) { function RootOutlet({ ownerProps }: { ownerProps: object }) {
const host = useHost() const host = useHost()
useSyncExternalStore( useSyncExternalStore(
(fn) => host.subscribe('root', fn), fn => host.subscribe('root', fn),
() => host.getVersion('root'), () => host.getVersion('root'),
) )
const entry = host.entriesOf('root')[0] const entry = host.entriesOf('root')[0]

View File

@@ -73,11 +73,14 @@ const absentSource: HostObservable<undefined> = {
/** Bind a source that disappears with the current session to an optional selector hook. */ /** Bind a source that disappears with the current session to an optional selector hook. */
export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> { export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
if (source !== undefined) return observableHook(source) if (source !== undefined) return observableHook(source)
return useAbsentSnapshot as MaybeSnapshotSelectorHook<T> return useAbsentSnapshot
} }
function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined { function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
return observableHook(absentSource)(() => undefined) // The uSES subscription must still run (hook-order stability); the absent
// source always snapshots undefined, returned explicitly.
observableHook(absentSource)(() => undefined)
return undefined
} }
/** /**
@@ -87,7 +90,7 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
*/ */
export function SessionMaybeProvider({ children }: { children: ReactNode }) { export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost() const host = useHost()
const id = observableHook(host.sessions.current)((s) => s) const id = observableHook(host.sessions.current)(s => s)
return ( return (
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}> <BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
{children} {children}
@@ -112,7 +115,7 @@ export interface SessionProviderProps {
*/ */
export function SessionProvider({ empty, children }: SessionProviderProps) { export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost() const host = useHost()
const id = observableHook(host.sessions.current)((s) => s) const id = observableHook(host.sessions.current)(s => s)
const info = id === undefined ? undefined : host.sessions.provideInfo(id) const info = id === undefined ? undefined : host.sessions.provideInfo(id)
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</> if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
return ( return (

View File

@@ -8,7 +8,7 @@ import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from
// Keep equality local: this suite asserts the eq parameter contract without // Keep equality local: this suite asserts the eq parameter contract without
// adding a reverse dependency from web-react to runtime. // adding a reverse dependency from web-react to runtime.
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean => const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k])) Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(k => Object.is(a[k], b[k]))
interface Snap { a: number; b: number } interface Snap { a: number; b: number }
@@ -51,7 +51,7 @@ describe('bindSnapshotSelector', () => {
const { source, set } = makeSource({ a: 1, b: 10 }) const { source, set } = makeSource({ a: 1, b: 10 })
const useSelector = bindSnapshotSelector(source) const useSelector = bindSnapshotSelector(source)
const probe = { renders: 0, value: undefined as number | undefined } const probe = { renders: 0, value: undefined as number | undefined }
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />) render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
expect(probe.value).toBe(1) expect(probe.value).toBe(1)
const before = probe.renders const before = probe.renders
act(() => { set({ a: 1, b: 11 }) }) // unrelated field: Object.is bail act(() => { set({ a: 1, b: 11 }) }) // unrelated field: Object.is bail
@@ -65,7 +65,7 @@ describe('bindSnapshotSelector', () => {
const { source, set } = makeSource({ a: 1, b: 10 }) const { source, set } = makeSource({ a: 1, b: 10 })
const useSelector = bindSnapshotSelector(source) const useSelector = bindSnapshotSelector(source)
const probe = { renders: 0, value: undefined as { a: number } | undefined } const probe = { renders: 0, value: undefined as { a: number } | undefined }
render(<Harness useSelector={useSelector} sel={(s) => ({ a: s.a })} eq={shallowEqual} probe={probe} />) render(<Harness useSelector={useSelector} sel={s => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
const before = probe.renders const before = probe.renders
act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice
expect(probe.renders).toBe(before) expect(probe.renders).toBe(before)
@@ -78,11 +78,11 @@ describe('bindSnapshotSelector', () => {
const { source, set, stats } = makeSource({ a: 1, b: 10 }) const { source, set, stats } = makeSource({ a: 1, b: 10 })
const useSelector = bindSnapshotSelector(source) const useSelector = bindSnapshotSelector(source)
const probe = { renders: 0, value: undefined as number | undefined } const probe = { renders: 0, value: undefined as number | undefined }
const { rerender } = render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />) const { rerender } = render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
const after = stats.subscribeCalls const after = stats.subscribeCalls
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />) rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
act(() => { set({ a: 2, b: 10 }) }) act(() => { set({ a: 2, b: 10 }) })
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />) rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
expect(stats.subscribeCalls).toBe(after) expect(stats.subscribeCalls).toBe(after)
}) })
@@ -92,7 +92,7 @@ describe('bindSnapshotSelector', () => {
const probe = { renders: 0, value: undefined as number | undefined } const probe = { renders: 0, value: undefined as number | undefined }
const view = render( const view = render(
<StrictMode> <StrictMode>
<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} /> <Harness useSelector={useSelector} sel={s => s.a} probe={probe} />
</StrictMode>, </StrictMode>,
) )
expect(probe.value).toBe(1) expect(probe.value).toBe(1)
@@ -112,7 +112,7 @@ describe('bindSnapshotSelector', () => {
} }
const useSelector = bindSnapshotSelector(new MethodSource()) const useSelector = bindSnapshotSelector(new MethodSource())
const probe = { renders: 0, value: undefined as number | undefined } const probe = { renders: 0, value: undefined as number | undefined }
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />) render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
expect(probe.value).toBe(7) expect(probe.value).toBe(7)
}) })

View File

@@ -28,10 +28,10 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
function hostOver(core: SlotCore): SlotRendererHost { function hostOver(core: SlotCore): SlotRendererHost {
return { return {
subscribe: (key, fn) => core.subscribe(key, fn), subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: (key) => core.getVersion(key), getVersion: key => core.getVersion(key),
entriesOf: (key) => core.entries(key), entriesOf: key => core.entries(key),
specOf: (key) => core.specDynamic(key), specOf: key => core.specDynamic(key),
isLive: (entry) => core.isLive(entry), isLive: entry => core.isLive(entry),
storeOf: () => undefined, storeOf: () => undefined,
sessions: { sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} }, list: { getSnapshot: () => ({}), subscribe: () => () => {} },
@@ -61,7 +61,7 @@ function mountFrame(core: SlotCore, body: (renderSlot: FrameSlots['renderSlot'])
describe('createSlotRenderer over the real SlotCore', () => { describe('createSlotRenderer over the real SlotCore', () => {
it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => { it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
const core = new SlotCore() const core = new SlotCore()
const { view } = mountFrame(core, (renderSlot) => const { view } = mountFrame(core, renderSlot =>
renderSlot('spec.single', {}, { fallback: <i>none</i> })) renderSlot('spec.single', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none') expect(view.container.textContent).toBe('none')
let dispose = () => {} let dispose = () => {}
@@ -78,7 +78,7 @@ describe('createSlotRenderer over the real SlotCore', () => {
const core = new SlotCore() const core = new SlotCore()
const notified = vi.fn() const notified = vi.fn()
core.subscribe('spec.list', notified) core.subscribe('spec.list', notified)
const { view } = mountFrame(core, (renderSlot) => renderSlot('spec.list', {})) const { view } = mountFrame(core, renderSlot => renderSlot('spec.list', {}))
await act(async () => { await act(async () => {
core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>) core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>) core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)

View File

@@ -33,7 +33,10 @@ const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry
* but entry.store is typed to the full contract — the real defineStore lives * but entry.store is typed to the full contract — the real defineStore lives
* in runtime, which web-react tests must not import (dependency direction). * in runtime, which web-react tests must not import (dependency direction).
*/ */
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> { function miniStore<T extends object>(
init: () => T,
mutators: Record<string, (state: T, ...params: never[]) => T>,
): StoreHandle<T, ActionsDecl<T>> {
return { return {
spec: { init, actions: {} }, spec: { init, actions: {} },
create: () => { create: () => {
@@ -96,10 +99,10 @@ function makeHost() {
subs.set(key, set) subs.set(key, set)
return () => { set.delete(fn) } return () => { set.delete(fn) }
}, },
getVersion: (key) => versions.get(key) ?? 0, getVersion: key => versions.get(key) ?? 0,
entriesOf: (key) => entries.get(key) ?? [], entriesOf: key => entries.get(key) ?? [],
specOf: (key) => specs.get(key), specOf: key => specs.get(key),
isLive: (entry) => live.has(entry), isLive: entry => live.has(entry),
storeOf: (entry, scopeKey) => { storeOf: (entry, scopeKey) => {
if (entry.store === undefined) return undefined if (entry.store === undefined) return undefined
let perScope = storeCache.get(entry) let perScope = storeCache.get(entry)
@@ -121,8 +124,8 @@ function makeHost() {
sessions: { sessions: {
list, list,
current, current,
provideInfo: (id) => infos.get(id), provideInfo: id => infos.get(id),
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id)) maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: {}, props: {} }, ?? { sessionId: undefined, hooks: {}, props: {} },
}, },
workspaces: { list: workspaces }, workspaces: { list: workspaces },
@@ -145,7 +148,7 @@ function makeHost() {
live.add(entry) live.add(entry)
bump(key) bump(key)
return () => { return () => {
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry)) entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
live.delete(entry) live.delete(entry)
bump(key) bump(key)
} }
@@ -187,7 +190,7 @@ const chainEntryOf = (partial: {
priority?: number priority?: number
}): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({ }): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
component: partial.component, component: partial.component,
select: partial.select as StoredEntry['select'], select: partial.select,
...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}), ...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
}) })
@@ -230,7 +233,7 @@ describe('child outlets and the renderSlot binding', () => {
const h = makeHost() const h = makeHost()
h.declare('k.single', SINGLE_ROOT) h.declare('k.single', SINGLE_ROOT)
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => renderSlot('k.single', {}, { fallback: <i>none</i> })) renderSlot => renderSlot('k.single', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none') expect(view.container.textContent).toBe('none')
let dispose = () => {} let dispose = () => {}
act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) }) act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) })
@@ -242,7 +245,7 @@ describe('child outlets and the renderSlot binding', () => {
it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => { it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => {
const h = makeHost() const h = makeHost()
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>) renderSlot => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
// Declared by children (authorization) but absent from the ledger (specOf // Declared by children (authorization) but absent from the ledger (specOf
// undefined): the outlet renders nothing, not even the fallback path's spec dispatch. // undefined): the outlet renders nothing, not even the fallback path's spec dispatch.
expect(view.container.querySelector('main')!.textContent).toBe('') expect(view.container.querySelector('main')!.textContent).toBe('')
@@ -256,7 +259,7 @@ describe('child outlets and the renderSlot binding', () => {
h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } }) h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } })
h.add('k.keyed', { component: () => <span>goal</span>, options: { key: 'goal' } }) h.add('k.keyed', { component: () => <span>goal</span>, options: { key: 'goal' } })
const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec } const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec }
const { view } = mountRoot(h, children, (renderSlot) => <> const { view } = mountRoot(h, children, renderSlot => <>
<main>{renderSlot('k.list', {})}</main> <main>{renderSlot('k.list', {})}</main>
<aside>{renderSlot('k.list', {}, { only: 'b' })}</aside> <aside>{renderSlot('k.list', {}, { only: 'b' })}</aside>
<nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav> <nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav>
@@ -291,7 +294,7 @@ describe('child outlets and the renderSlot binding', () => {
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } }) h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } }, const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
(renderSlot) => renderSlot('k.list', {})) renderSlot => renderSlot('k.list', {}))
spy.mockRestore() spy.mockRestore()
expect(view.container.textContent).toBe('alive') expect(view.container.textContent).toBe('alive')
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull() expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
@@ -309,10 +312,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
})) }))
h.add('k.chain', chainEntryOf({ h.add('k.chain', chainEntryOf({
component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>, component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>,
select: (owner) => ({ label: `hit:${(owner as { tag: string }).tag}` }), select: owner => ({ label: `hit:${(owner as { tag: string }).tag}` }),
})) }))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' })) renderSlotChain => renderSlotChain('k.chain', { tag: 'T' }))
// The declining entry never mounts: the routing decision is select-layer only. // The declining entry never mounts: the routing decision is select-layer only.
expect(view.container.textContent).toBe('hit:T') expect(view.container.textContent).toBe('hit:T')
expect(declinerBody).not.toHaveBeenCalled() expect(declinerBody).not.toHaveBeenCalled()
@@ -327,10 +330,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
})) }))
h.add('k.chain', chainEntryOf({ h.add('k.chain', chainEntryOf({
component: ({ matched }: { matched?: string }) => <b>{matched}</b>, component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
select: (owner) => (owner as { pick?: string }).pick ?? null, select: owner => (owner as { pick?: string }).pick ?? null,
})) }))
const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <> const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
<main>{renderSlotChain('k.chain', { pick: 'OK' })}</main> <main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
<aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside> <aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
</>) </>)
@@ -347,16 +350,16 @@ describe('chain outlets and the renderSlotChain binding', () => {
h.declare('k.chain', CHAIN_ROOT) h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({ h.add('k.chain', chainEntryOf({
component: () => { throw new Error('entry A boom') }, component: () => { throw new Error('entry A boom') },
select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null, select: owner => (owner as { pick?: string }).pick === 'A' ? {} : null,
})) }))
h.add('k.chain', chainEntryOf({ h.add('k.chain', chainEntryOf({
component: () => <b>B-ok</b>, component: () => <b>B-ok</b>,
select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null, select: owner => (owner as { pick?: string }).pick === 'B' ? {} : null,
})) }))
let pick = 'A' let pick = 'A'
const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { pick })) renderSlotChain => renderSlotChain('k.chain', { pick }))
spy.mockRestore() spy.mockRestore()
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull() expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
// Re-elect entry B: the entry-keyed boundary remounts fresh instead of // Re-elect entry B: the entry-keyed boundary remounts fresh instead of
@@ -372,9 +375,9 @@ describe('chain outlets and the renderSlotChain binding', () => {
h.declare('k.chain', CHAIN_ROOT) h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({ h.add('k.chain', chainEntryOf({
component: ({ matched }: { matched?: string }) => <b>{matched}</b>, component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
select: (owner) => (owner as { pick?: string }).pick ?? null, select: owner => (owner as { pick?: string }).pick ?? null,
})) }))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <> const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
<main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main> <main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main>
<aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside> <aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
</>) </>)
@@ -387,7 +390,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
const h = makeHost() const h = makeHost()
h.declare('k.chain', CHAIN_ROOT) h.declare('k.chain', CHAIN_ROOT)
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>none</i> })) renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none') expect(view.container.textContent).toBe('none')
let dispose = () => {} let dispose = () => {}
act(() => { act(() => {
@@ -422,7 +425,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
priority: 1, priority: 1,
})) }))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', {})) renderSlotChain => renderSlotChain('k.chain', {}))
expect(view.container.textContent).toBe('early') expect(view.container.textContent).toBe('early')
}) })
@@ -490,13 +493,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
h.declare('k.chain', CHAIN_ROOT) h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({ h.add('k.chain', chainEntryOf({
component: () => <b>TAKEOVER</b>, component: () => <b>TAKEOVER</b>,
select: (owner) => (owner as { take?: boolean }).take ? {} : null, select: owner => (owner as { take?: boolean }).take ? {} : null,
})) }))
const mounted = vi.fn() const mounted = vi.fn()
const Probe = fallbackProbe(mounted) const Probe = fallbackProbe(mounted)
let take = false let take = false
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true })) renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
const wrapper = () => view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')! const wrapper = () => view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
const input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')! const input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
@@ -525,13 +528,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
h.declare('k.chain', CHAIN_ROOT) h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({ h.add('k.chain', chainEntryOf({
component: () => <b>TAKEOVER</b>, component: () => <b>TAKEOVER</b>,
select: (owner) => (owner as { take?: boolean }).take ? {} : null, select: owner => (owner as { take?: boolean }).take ? {} : null,
})) }))
const mounted = vi.fn() const mounted = vi.fn()
const Probe = fallbackProbe(mounted) const Probe = fallbackProbe(mounted)
let take = false let take = false
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe /> })) renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } }) fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } })
expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull() expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull()
@@ -561,7 +564,7 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
priority: 2, priority: 2,
})) }))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true })) renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
expect(view.container.textContent).toContain('ELECTED') expect(view.container.textContent).toContain('ELECTED')
expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true) expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
spy.mockRestore() spy.mockRestore()
@@ -578,9 +581,9 @@ describe('standard-kit synthesis', () => {
h.declare('k.single', SINGLE_ROOT) h.declare('k.single', SINGLE_ROOT)
h.add('k.single', { h.add('k.single', {
component: ({ useSessions }: { useSessions: <S>(sel: (s: { ids: string[] }) => S) => S }) => component: ({ useSessions }: { useSessions: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
<b>{useSessions((s) => s.ids.length)}</b>, <b>{useSessions(s => s.ids.length)}</b>,
}) })
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0') expect(view.container.textContent).toBe('0')
act(() => { h.list.set({ ids: ['a', 'b'] }) }) act(() => { h.list.set({ ids: ['a', 'b'] }) })
expect(view.container.textContent).toBe('2') expect(view.container.textContent).toBe('2')
@@ -591,9 +594,9 @@ describe('standard-kit synthesis', () => {
h.declare('k.single', SINGLE_ROOT) h.declare('k.single', SINGLE_ROOT)
h.add('k.single', { h.add('k.single', {
component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) => component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
<b>{useWorkspaces((s) => s.ids.length)}</b>, <b>{useWorkspaces(s => s.ids.length)}</b>,
}) })
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0') expect(view.container.textContent).toBe('0')
act(() => { h.workspaces.set({ ids: ['w1'] }) }) act(() => { h.workspaces.set({ ids: ['w1'] }) })
expect(view.container.textContent).toBe('1') expect(view.container.textContent).toBe('1')
@@ -606,11 +609,11 @@ describe('standard-kit synthesis', () => {
const seen: AnyProps[] = [] const seen: AnyProps[] = []
h.add('k.session', { h.add('k.session', {
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => { component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
seen.push({ ...props, read: props.useSession!((s) => s.sid) }) seen.push({ ...props, read: props.useSession!(s => s.sid) })
return null return null
}, },
}) })
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => ( mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
<SessionProvider empty={() => <i>empty</i>}> <SessionProvider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})} {() => renderSlot('k.session', {})}
</SessionProvider> </SessionProvider>
@@ -669,14 +672,14 @@ describe('standard-kit synthesis', () => {
h.declare('k.session', SINGLE_SESSION) h.declare('k.session', SINGLE_SESSION)
h.add('k.session', { component: () => <b>x</b> }) h.add('k.session', { component: () => <b>x</b> })
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
(renderSlot) => renderSlot('k.session', {})) renderSlot => renderSlot('k.session', {}))
expect(view.container.querySelector('b')).toBeNull() expect(view.container.querySelector('b')).toBeNull()
}) })
it('delivers the store pair for store-declaring entries and writes through baked actions', () => { it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
const h = makeHost() const h = makeHost()
h.declare('k.single', SINGLE_ROOT) h.declare('k.single', SINGLE_ROOT)
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) }) const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
let bump = () => {} let bump = () => {}
h.add('k.single', { h.add('k.single', {
component: ({ useStore, actions }: { component: ({ useStore, actions }: {
@@ -684,11 +687,11 @@ describe('standard-kit synthesis', () => {
actions: { inc: () => void } actions: { inc: () => void }
}) => { }) => {
bump = actions.inc bump = actions.inc
return <b>{useStore((s) => s.n)}</b> return <b>{useStore(s => s.n)}</b>
}, },
store: handle, store: handle,
}) })
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0') expect(view.container.textContent).toBe('0')
act(() => { bump() }) act(() => { bump() })
expect(view.container.textContent).toBe('1') expect(view.container.textContent).toBe('1')
@@ -707,11 +710,11 @@ describe('standard-kit synthesis', () => {
actions: { setDraft: (text: string) => void } actions: { setDraft: (text: string) => void }
}) => { }) => {
setDraft = actions.setDraft setDraft = actions.setDraft
return <b>{useStore((s) => s.draft) || '(blank)'}</b> return <b>{useStore(s => s.draft) || '(blank)'}</b>
}, },
store: handle, store: handle,
}) })
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => ( const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider> <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
)) ))
act(() => { h.current.set('s1') }) act(() => { h.current.set('s1') })
@@ -730,7 +733,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
h.declare('k.single', SINGLE_ROOT) h.declare('k.single', SINGLE_ROOT)
const inject = vi.fn(() => ({ tag: 'FROM-INJECT' })) const inject = vi.fn(() => ({ tag: 'FROM-INJECT' }))
h.add('k.single', { component: ({ tag }: { tag?: string }) => <b>{tag}</b>, inject }) h.add('k.single', { component: ({ tag }: { tag?: string }) => <b>{tag}</b>, inject })
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('FROM-INJECT') expect(view.container.textContent).toBe('FROM-INJECT')
act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet
expect(inject).toHaveBeenCalledTimes(1) expect(inject).toHaveBeenCalledTimes(1)
@@ -745,9 +748,9 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
const inject = vi.fn((sessionId: string) => ({ sid: sessionId })) const inject = vi.fn((sessionId: string) => ({ sid: sessionId }))
h.add('k.session', { h.add('k.session', {
component: ({ sid }: { sid?: string }) => <b>{sid}</b>, component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
inject: inject as unknown as StoredEntry['inject'], inject: inject,
}) })
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => ( const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider> <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
)) ))
act(() => { h.current.set('s1') }) act(() => { h.current.set('s1') })
@@ -767,22 +770,22 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
h.declare('k.single', SINGLE_ROOT) h.declare('k.single', SINGLE_ROOT)
h.declare('k.session', SINGLE_SESSION) h.declare('k.session', SINGLE_SESSION)
h.addSession('s1') h.addSession('s1')
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) }) const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions })) const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions })) const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
const seenRoot: AnyProps[] = [] const seenRoot: AnyProps[] = []
const seenSession: AnyProps[] = [] const seenSession: AnyProps[] = []
h.add('k.single', { h.add('k.single', {
component: (props: object) => { seenRoot.push(props as AnyProps); return null }, component: (props: object) => { seenRoot.push(props as AnyProps); return null },
inject: rootInject as unknown as StoredEntry['inject'], inject: rootInject,
store: handle, store: handle,
}) })
h.add('k.session', { h.add('k.session', {
component: (props: object) => { seenSession.push(props as AnyProps); return null }, component: (props: object) => { seenSession.push(props as AnyProps); return null },
inject: sessionInject as unknown as StoredEntry['inject'], inject: sessionInject,
store: handle, store: handle,
}) })
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot) => <> mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, renderSlot => <>
{renderSlot('k.single', {})} {renderSlot('k.single', {})}
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider> <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
</>) </>)
@@ -807,7 +810,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } }) h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } }, const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
(renderSlot) => <main>{renderSlot('k.list', {})}</main>) renderSlot => <main>{renderSlot('k.list', {})}</main>)
spy.mockRestore() spy.mockRestore()
// The failing entry blacks out alone; the sibling and the tree above survive. // The failing entry blacks out alone; the sibling and the tree above survive.
expect(view.container.querySelector('main')).not.toBeNull() expect(view.container.querySelector('main')).not.toBeNull()
@@ -824,7 +827,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
inject: () => ({ fromInject: 'inject', shared: 'inject' }), inject: () => ({ fromInject: 'inject', shared: 'inject' }),
}) })
mountRoot(h, { 'k.single': SINGLE_ROOT }, mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' })) renderSlot => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
const props = seen.at(-1)! const props = seen.at(-1)!
expect(typeof props['useSessions']).toBe('function') // kit always present expect(typeof props['useSessions']).toBe('function') // kit always present
expect(typeof props['useWorkspaces']).toBe('function') expect(typeof props['useWorkspaces']).toBe('function')

View File

@@ -43,15 +43,15 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
const host: SlotRendererHost = { const host: SlotRendererHost = {
subscribe: () => () => {}, subscribe: () => () => {},
getVersion: () => 0, getVersion: () => 0,
entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries, entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined, specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true, isLive: () => true,
storeOf: () => undefined, storeOf: () => undefined,
sessions: { sessions: {
list: observable<unknown>({ ids: [] }), list: observable<unknown>({ ids: [] }),
current, current,
provideInfo: (id) => infos.get(id), provideInfo: id => infos.get(id),
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id)) maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: { session: undefined }, props: {} }, ?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
}, },
workspaces: { list: observable<unknown>({ items: [] }) }, workspaces: { list: observable<unknown>({ items: [] }) },
@@ -78,7 +78,7 @@ describe('SessionProvider', () => {
const h = makeHost({ const h = makeHost({
root: () => ( root: () => (
<SessionProvider empty={() => <span>empty</span>}> <SessionProvider empty={() => <span>empty</span>}>
{(id) => <div data-testid="body">{id}</div>} {id => <div data-testid="body">{id}</div>}
</SessionProvider> </SessionProvider>
), ),
}) })
@@ -93,7 +93,7 @@ describe('SessionProvider', () => {
it('renders null empty state when the empty prop is omitted', () => { it('renders null empty state when the empty prop is omitted', () => {
const h = makeHost({ const h = makeHost({
root: () => <SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>, root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
}) })
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>) const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('') expect(view.container.textContent).toBe('')
@@ -110,7 +110,7 @@ describe('SessionProvider', () => {
return <div>{id}</div> return <div>{id}</div>
} }
const h = makeHost({ const h = makeHost({
root: () => <SessionProvider>{(id) => <Body id={id} />}</SessionProvider>, root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
}) })
h.addSession('s1') h.addSession('s1')
h.addSession('s2') h.addSession('s2')
@@ -127,7 +127,7 @@ describe('SessionProvider', () => {
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => { it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
const seen: Record<string, unknown>[] = [] const seen: Record<string, unknown>[] = []
const h = makeHost({ const h = makeHost({
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>, root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
}) })
h.addSession('s1') h.addSession('s1')
h.addSession('s2') h.addSession('s2')
@@ -135,7 +135,7 @@ describe('SessionProvider', () => {
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => { component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
// The bound hook reads the cell's bare source — asserting through it // The bound hook reads the cell's bare source — asserting through it
// proves the machinery wired THIS session's source, not another's. // proves the machinery wired THIS session's source, not another's.
seen.push({ sessionId: props.sessionId, read: props.useSession!((s) => s.sid) }) seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
return null return null
}, },
options: {}, options: {},
@@ -152,7 +152,7 @@ describe('SessionProvider', () => {
it('fails loud when mounted outside the renderer tree (no host channel)', () => { it('fails loud when mounted outside the renderer tree (no host channel)', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render( expect(() => render(
<SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>, <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
)).toThrow(/outside the installed renderer tree/) )).toThrow(/outside the installed renderer tree/)
spy.mockRestore() spy.mockRestore()
}) })

View File

@@ -32,10 +32,10 @@ function makeHost() {
subs.set(key, set) subs.set(key, set)
return () => { set.delete(fn) } return () => { set.delete(fn) }
}, },
getVersion: (key) => versions.get(key) ?? 0, getVersion: key => versions.get(key) ?? 0,
entriesOf: (key) => entries.get(key) ?? [], entriesOf: key => entries.get(key) ?? [],
specOf: () => ({ kind: 'single', scope: 'root' }), specOf: () => ({ kind: 'single', scope: 'root' }),
isLive: (entry) => live.has(entry), isLive: entry => live.has(entry),
storeOf: () => undefined, storeOf: () => undefined,
sessions: { sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} }, list: { getSnapshot: () => ({}), subscribe: () => () => {} },
@@ -54,7 +54,7 @@ function makeHost() {
live.add(entry) live.add(entry)
bump(key) bump(key)
return () => { return () => {
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry)) entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
live.delete(entry) live.delete(entry)
bump(key) bump(key)
} }

View File

@@ -3,10 +3,10 @@ import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react' import { act, render } from '@testing-library/react'
import { useInvoke } from '@deepseek-ai/dsh-client-web-react' import { useInvoke } from '@deepseek-ai/dsh-client-web-react'
function deferred<T>() { function deferred() {
let resolve!: (v: T) => void let resolve!: () => void
let reject!: (e: unknown) => void let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej }) const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject } return { promise, resolve, reject }
} }
@@ -28,7 +28,7 @@ const newProbe = (): Probe => ({ invoke: () => {}, pending: false, renders: 0 })
describe('useInvoke', () => { describe('useInvoke', () => {
it('tracks pending across the action lifecycle', async () => { it('tracks pending across the action lifecycle', async () => {
const d = deferred<void>() const d = deferred()
const probe = newProbe() const probe = newProbe()
render(<Harness fn={() => d.promise} probe={probe} />) render(<Harness fn={() => d.promise} probe={probe} />)
expect(probe.pending).toBe(false) expect(probe.pending).toBe(false)
@@ -39,8 +39,8 @@ describe('useInvoke', () => {
}) })
it('keeps pending true until the last concurrent call settles', async () => { it('keeps pending true until the last concurrent call settles', async () => {
const d1 = deferred<void>() const d1 = deferred()
const d2 = deferred<void>() const d2 = deferred()
const queue = [d1, d2] const queue = [d1, d2]
const probe = newProbe() const probe = newProbe()
render(<Harness fn={() => queue.shift()!.promise} probe={probe} />) render(<Harness fn={() => queue.shift()!.promise} probe={probe} />)
@@ -68,7 +68,7 @@ describe('useInvoke', () => {
it('resets pending and logs when the action rejects', async () => { it('resets pending and logs when the action rejects', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const d = deferred<void>() const d = deferred()
const probe = newProbe() const probe = newProbe()
render(<Harness fn={() => d.promise} probe={probe} />) render(<Harness fn={() => d.promise} probe={probe} />)
act(() => { probe.invoke() }) act(() => { probe.invoke() })

View File

@@ -7,7 +7,6 @@
*/ */
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { DocumentTitle } from './DocumentTitle.tsx' import { DocumentTitle } from './DocumentTitle.tsx'
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program. // Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
@@ -26,7 +25,7 @@ export interface AssemblyDeps {
*/ */
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
const { ctx } = deps const { ctx } = deps
const sessions = ctx.get('sessions') as SessionsService | undefined const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable') if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
const useSessions = bindSnapshotSelector(sessions.list) const useSessions = bindSnapshotSelector(sessions.list)
const SessionDocumentTitle = (): ReactNode => { const SessionDocumentTitle = (): ReactNode => {

View File

@@ -150,8 +150,8 @@ export class AppWebEntry {
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */ /** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
private async prefetchImmediateTier(): Promise<void> { private async prefetchImmediateTier(): Promise<void> {
await Promise.all(this.manifest.plugins await Promise.all(this.manifest.plugins
.filter((row) => row.immediately) .filter(row => row.immediately)
.map((row) => this.modules.prefetch(row.id).catch(() => { .map(row => this.modules.prefetch(row.id).catch(() => {
// Import refetches and reports this loudly per entry; swallowing // Import refetches and reports this loudly per entry; swallowing
// here keeps one failing prefetch from masking the others. // here keeps one failing prefetch from masking the others.
}))) })))
@@ -186,7 +186,7 @@ export class AppWebEntry {
// its wrapper apply reads the kernel slot and provides ctx.modules (the // its wrapper apply reads the kernel slot and provides ctx.modules (the
// provide lives on the plugin face; see MODULES_ID for why the row loop // provide lives on the plugin face; see MODULES_ID for why the row loop
// must then skip it). // must then skip it).
const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID] const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID), APP_SHELL_ID]
// Entry creation order carries no semantics (fiber inject waiting owns // Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle // activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the // fetches parallelize. The app-shell assembly entry is appended by the
@@ -225,7 +225,7 @@ export class AppWebEntry {
const state = STATE_LABELS[entry.fiber.state] const state = STATE_LABELS[entry.fiber.state]
if (state === 'active') continue if (state === 'active') continue
if (state === 'pending') { if (state === 'pending') {
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined) const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else { } else {
failures.push(`${name}: ${state}`) failures.push(`${name}: ${state}`)

View File

@@ -46,13 +46,13 @@ export type LoaderStatus = Record<string, LoaderEntryState>
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */ /** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
export interface KernelSignal<T> { export interface KernelSignal<T> {
/** Current value (stable reference between changes). */ /** Current value (stable reference between changes). */
getSnapshot(): T getSnapshot: () => T
/** /**
* Subscribe to changes. * Subscribe to changes.
* @param fn - change listener. * @param fn - change listener.
* @returns the unsubscribe disposer. * @returns the unsubscribe disposer.
*/ */
subscribe(fn: () => void): () => void subscribe: (fn: () => void) => () => void
} }
/** Writable one-value signal (settled flag, boot failure report). */ /** Writable one-value signal (settled flag, boot failure report). */
@@ -61,7 +61,7 @@ export interface KernelValueSignal<T> extends KernelSignal<T> {
* Publish a new value and notify subscribers. * Publish a new value and notify subscribers.
* @param next - the new value. * @param next - the new value.
*/ */
set(next: T): void set: (next: T) => void
} }
/** /**
@@ -90,7 +90,7 @@ export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
* @param id - entry name. * @param id - entry name.
* @param state - projected fiber state. * @param state - projected fiber state.
*/ */
set(id: string, state: LoaderEntryState): void set: (id: string, state: LoaderEntryState) => void
} }
/** /**