chore(lint): apply eslint auto-fixes across the .tsx backlog

Mechanical --fix output over the newly linted .tsx files (indent,
arrow-parens, comma-dangle, member-delimiter-style, unnecessary type
assertions), plus the three generic-arrow test hooks converted to
function declarations up front: the comma-dangle fixer strips the
<T,> disambiguation comma and turns them into parse errors otherwise.
This commit is contained in:
imccyu
2026-07-27 21:49:40 +08:00
parent 36e8141145
commit 49c2e85ac7
58 changed files with 479 additions and 475 deletions

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

@@ -103,7 +103,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 +130,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 +154,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()
}) })
@@ -164,15 +164,15 @@ function StreamingTail({ useSession, onGrow }: {
/** 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 +254,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 +293,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 +309,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

@@ -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

@@ -330,8 +330,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

@@ -39,7 +39,7 @@ const SCOPE_TAG: symbol = (() => {
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,7 +73,7 @@ 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
@@ -234,11 +234,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 () => {

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

@@ -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. */

View File

@@ -104,8 +104,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 +124,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 +155,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 +240,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 +270,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 +284,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 +306,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 +324,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 +337,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

@@ -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

@@ -100,7 +100,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 +121,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 +134,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 +145,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

@@ -86,7 +86,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

@@ -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

@@ -64,7 +64,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])

View File

@@ -23,7 +23,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

@@ -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

@@ -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('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('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('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('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

@@ -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).

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()
}) })
@@ -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

@@ -32,7 +32,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 +61,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 +96,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 +108,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 +129,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 +189,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 +204,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 (

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,

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('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('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('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('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('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('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('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('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

@@ -232,13 +232,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
} }
} }
@@ -297,7 +297,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
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 +335,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 +388,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 +403,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,11 @@ 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) observableHook(absentSource)(() => undefined)
} }
/** /**
@@ -87,7 +87,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 +112,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

@@ -96,10 +96,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 +121,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 +145,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 +187,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 +230,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 +242,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 +256,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 +291,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 +309,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 +327,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 +347,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 +372,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 +387,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 +422,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 +490,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 +525,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 +561,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 +578,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 +591,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 +606,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 +669,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 +684,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 +707,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 +730,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 +745,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 +767,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 +807,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 +824,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

@@ -26,7 +26,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}`)