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:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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[] = []
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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)', () => {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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 } = {}) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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('截断')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
)),
|
)),
|
||||||
])}
|
])}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' }))
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user