Merge pull request #763 from deepseek-harness/lint-tsx-baseline
chore(lint): bring .tsx files into the eslint, lefthook, and jscpd lanes
This commit is contained in:
+2
-2
@@ -2,8 +2,8 @@
|
||||
"minTokens": 60,
|
||||
"minLines": 6,
|
||||
"mode": "mild",
|
||||
"format": ["typescript"],
|
||||
"pattern": "**/*.ts",
|
||||
"format": ["typescript", "tsx"],
|
||||
"pattern": "**/*.{ts,tsx}",
|
||||
"ignore": ["**/tests/**", "**/tsdown.config.ts"],
|
||||
"ignorePattern": [
|
||||
"(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/"
|
||||
|
||||
+26
-5
@@ -27,7 +27,13 @@ export default tseslint.config(
|
||||
|
||||
// --- our packages: full strictness -------------------------------------
|
||||
{
|
||||
files: ['packages/*/*/src/**/*.ts', 'apps/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
|
||||
files: [
|
||||
'packages/*/*/src/**/*.{ts,tsx}',
|
||||
'apps/*/src/**/*.{ts,tsx}',
|
||||
'examples/**/*.{ts,tsx}',
|
||||
'scripts/**/*.{ts,tsx}',
|
||||
'website/**/*.{ts,tsx}',
|
||||
],
|
||||
extends: [
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
],
|
||||
@@ -80,7 +86,12 @@ export default tseslint.config(
|
||||
|
||||
// --- tests: same rules, minus the friction that fights test ergonomics --
|
||||
{
|
||||
files: ['packages/*/*/tests/**/*.ts', 'apps/*/tests/**/*.ts', 'examples/*/tests/**/*.ts', 'scripts/**/*.spec.ts'],
|
||||
files: [
|
||||
'packages/*/*/tests/**/*.{ts,tsx}',
|
||||
'apps/*/tests/**/*.{ts,tsx}',
|
||||
'examples/*/tests/**/*.{ts,tsx}',
|
||||
'scripts/**/*.spec.{ts,tsx}',
|
||||
],
|
||||
extends: [
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
],
|
||||
@@ -117,7 +128,10 @@ export default tseslint.config(
|
||||
// Context merges collide), so the shared project service cannot resolve
|
||||
// them — parse these through the client aggregate explicitly.
|
||||
{
|
||||
files: ['packages/client/*/tests/**/*.ts', 'scripts/client-bundle-purity.spec.ts'],
|
||||
files: [
|
||||
'packages/client/*/tests/**/*.{ts,tsx}',
|
||||
'scripts/client-bundle-purity.spec.ts',
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: false,
|
||||
@@ -129,7 +143,7 @@ export default tseslint.config(
|
||||
|
||||
// --- file-local duplication (all owned TypeScript) ---------------------
|
||||
{
|
||||
files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
|
||||
files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}', 'scripts/**/*.{ts,tsx}', 'website/**/*.{ts,tsx}'],
|
||||
plugins: { sonarjs },
|
||||
rules: {
|
||||
// Cross-file clones are covered separately by jscpd.
|
||||
@@ -146,7 +160,14 @@ export default tseslint.config(
|
||||
|
||||
// --- formatting (everything we own) -------------------------------------
|
||||
{
|
||||
files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'],
|
||||
files: [
|
||||
'packages/**/*.{ts,tsx}',
|
||||
'apps/**/*.{ts,tsx}',
|
||||
'examples/**/*.{ts,tsx}',
|
||||
'scripts/**/*.{ts,tsx}',
|
||||
'website/**/*.{ts,tsx}',
|
||||
'eslint.config.mjs',
|
||||
],
|
||||
plugins: { '@stylistic': stylistic },
|
||||
rules: {
|
||||
'@stylistic/indent': ['error', 2],
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
pre-commit:
|
||||
jobs:
|
||||
- name: lint (staged)
|
||||
glob: '*.{ts,mts,cts,mjs}'
|
||||
glob: '*.{ts,tsx,mts,cts,mjs}'
|
||||
exclude:
|
||||
- 'vendor/*/src/**'
|
||||
run: node_modules/.bin/eslint --fix {staged_files}
|
||||
|
||||
@@ -45,7 +45,7 @@ async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResu
|
||||
}
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -86,7 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
seq: number
|
||||
onOpenDetails: OpenDetails
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
@@ -103,7 +104,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
})}
|
||||
{subCalls !== undefined && subCalls.length > 0 && (
|
||||
<div className={css.subCalls} data-subcalls>
|
||||
{subCalls.map((node) => (
|
||||
{subCalls.map(node => (
|
||||
<SubCallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
@@ -130,7 +131,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
{results.map((node) => (
|
||||
{results.map(node => (
|
||||
<CallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
@@ -154,7 +155,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
useSession: UseConversation
|
||||
onGrow: () => void
|
||||
}) {
|
||||
const partial = useSession((s) => s.partial)
|
||||
const partial = useSession(s => s.partial)
|
||||
useLayoutEffect(() => {
|
||||
onGrow()
|
||||
})
|
||||
@@ -162,17 +163,20 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
||||
}
|
||||
|
||||
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
|
||||
/**
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const pending = useSession((s) => s.pending)
|
||||
const openState = useSession((s) => s.openState)
|
||||
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession((s) => s.hasMore)
|
||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const pending = useSession(s => s.pending)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
|
||||
@@ -254,8 +258,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
const inGroup = selectedCallId !== undefined
|
||||
&& item.results.some((r) => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true)
|
||||
&& item.results.some(r => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
|
||||
return (
|
||||
<ToolGroup
|
||||
key={item.key}
|
||||
@@ -280,36 +284,36 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map((call) => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map(call => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => <PendingCard key={item.key} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
|
||||
@@ -32,6 +32,9 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
|
||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -40,6 +43,9 @@ async function writeClipboard(text: string): Promise<void> {
|
||||
}
|
||||
return
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
@@ -56,6 +62,7 @@ async function writeClipboard(text: string): Promise<void> {
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
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])
|
||||
if (stats.steps === 0) return null
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ToolRow({
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
setExpanded((v) => !v)
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -144,7 +144,7 @@ export interface ToolRowOwnerProps {
|
||||
/** Frozen call slice: the running call or the settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
||||
openDetails(): void
|
||||
openDetails: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,21 +175,21 @@ export interface ConversationInjected {
|
||||
* Connect the selected Workspace and open its reusable/new blank session.
|
||||
* When a blank session is already current, carry its draft to the target.
|
||||
*/
|
||||
selectWorkspace(workspaceId: WorkspaceId): Promise<void>
|
||||
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the strict session content seat. */
|
||||
export interface ConversationSessionInjected {
|
||||
/** Views projected from the `conversation.view` slot ledger. */
|
||||
views: {
|
||||
list(): readonly ViewTab[]
|
||||
subscribe(fn: () => void): () => void
|
||||
version(): number
|
||||
list: () => readonly ViewTab[]
|
||||
subscribe: (fn: () => void) => () => void
|
||||
version: () => number
|
||||
}
|
||||
/** Bind the input machine's draft persistence mirror to the session store. */
|
||||
bindDraftMirror(write: (text: string) => void): () => void
|
||||
bindDraftMirror: (write: (text: string) => void) => () => void
|
||||
/** Select a real Session through the runtime navigation owner. */
|
||||
open(sessionId: SessionId): void
|
||||
open: (sessionId: SessionId) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +219,7 @@ export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop(): void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,8 +275,8 @@ export type ConversationSessionSlotProps =
|
||||
*/
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
loadOlder(): void
|
||||
openDetails: (target: SelectionTarget) => void
|
||||
loadOlder: () => void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
|
||||
@@ -290,7 +290,7 @@ export type ChatViewSlotProps =
|
||||
*/
|
||||
export interface DetailsInjected {
|
||||
/** Close the details panel (layout geometry stays with ctx.layout). */
|
||||
closeDetails(): void
|
||||
closeDetails: () => void
|
||||
}
|
||||
|
||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||
@@ -300,6 +300,6 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
open: boolean
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
onPick(workspaceId: WorkspaceId): void
|
||||
onClose(): void
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -78,12 +78,12 @@ export function ConversationRoot({
|
||||
const inputBar = sessionId === undefined
|
||||
? <DisabledInputBar />
|
||||
: renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
})
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
})
|
||||
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
|
||||
@@ -41,8 +41,8 @@ export function ConversationSession({
|
||||
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
|
||||
const unmirror = bindDraftMirror(actions.setDraft)
|
||||
return () => { unmirror() }
|
||||
// Mount-only: later store writes come from the machine mirror.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// Mount-only (deps pinned to inputActions): later store writes come from
|
||||
// the machine mirror, not this seed effect.
|
||||
}, [inputActions])
|
||||
|
||||
if (blank && composerPhase === 'blank') return null
|
||||
|
||||
@@ -86,27 +86,27 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
: material === null
|
||||
? <div className={css.empty}>该调用不在当前窗口内</div>
|
||||
: (
|
||||
<>
|
||||
{material.argsRaw !== null && (
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Input</div>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||
</section>
|
||||
)}
|
||||
<>
|
||||
{material.argsRaw !== null && (
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
<div className={css.sectionLabel}>Input</div>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,11 @@ export function InputBar({
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot)
|
||||
const noticeStore = keyboard.notices
|
||||
const notice = useSyncExternalStore(
|
||||
(fn: () => void) => noticeStore.subscribe(fn),
|
||||
() => noticeStore.getSnapshot(),
|
||||
)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
@@ -75,6 +79,8 @@ export function InputBar({
|
||||
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
||||
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
||||
if (e.key === 'Enter' && e.shiftKey) return
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
|
||||
@@ -92,7 +98,7 @@ export function InputBar({
|
||||
// the browser stack cannot represent); never let the native stack run.
|
||||
e.preventDefault()
|
||||
if (machineBusy || locked) return
|
||||
const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z'))
|
||||
const redo = e.key === 'y' || e.shiftKey
|
||||
if (redo) keyboard.redo()
|
||||
else keyboard.undo()
|
||||
return
|
||||
@@ -134,6 +140,8 @@ export function InputBar({
|
||||
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
||||
const next = e.target.value
|
||||
keyboard.setDraft(next)
|
||||
// selectionStart is number|null in lib.dom; the eslint program narrows it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
keyboard.track(next, e.target.selectionStart ?? next.length)
|
||||
}
|
||||
|
||||
@@ -145,10 +153,13 @@ export function InputBar({
|
||||
// too (one char = one step). Mouse selection of a chip is handled in the
|
||||
// backdrop click handler below. Undo/redo must NOT reach the browser: the
|
||||
// machine owns the transaction log.
|
||||
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
const selectionOf = (el: HTMLTextAreaElement) => ({
|
||||
start: el.selectionStart ?? 0,
|
||||
end: el.selectionEnd ?? el.selectionStart ?? 0,
|
||||
})
|
||||
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
||||
|
||||
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
|
||||
const el = e.currentTarget
|
||||
@@ -330,8 +341,8 @@ export function InputBar({
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={e => { onCopyOrCut(e, false) }}
|
||||
onCut={e => { onCopyOrCut(e, true) }}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
onCut={(e) => { onCopyOrCut(e, true) }}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
|
||||
@@ -36,10 +36,12 @@ const SCOPE_TAG: symbol = (() => {
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver) {
|
||||
recorded.push(prop)
|
||||
// Reflect.get is typed any; the probe only records property names.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(spy as Context)
|
||||
void scopeOf(spy)
|
||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||
return symbol
|
||||
@@ -73,14 +75,15 @@ async function bench() {
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
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)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
type TestProvider = {
|
||||
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
|
||||
hooks?: Record<string, unknown>; props?: Record<string, unknown>
|
||||
hooks?: Record<string, unknown>
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
@@ -158,10 +161,12 @@ async function bench() {
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
||||
const state = contribution.hooks!['input'] as {
|
||||
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
|
||||
getSnapshot: () => { draft: string }
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
const actions = contribution.props!['inputActions'] as {
|
||||
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
|
||||
setDraft: (text: string) => void
|
||||
submit: (mode?: 'queue' | 'steer') => void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
@@ -234,11 +239,11 @@ describe('conversation slot inject surface', () => {
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
||||
// Unknown session: sessions.scope answers nothing.
|
||||
;(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.
|
||||
const foreign = new Context()
|
||||
;(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 () => {
|
||||
@@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => {
|
||||
// no draft movement, plain re-open.
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
resident.selectWorkspace('workspace-1' as never)
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
||||
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||
expect(state.getSnapshot().draft).toBe('carry me')
|
||||
@@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => {
|
||||
// new session's machine receives the text, then navigation lands there.
|
||||
const OTHER = 'other-1' as SessionId
|
||||
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
||||
resident.selectWorkspace('workspace-2' as never)
|
||||
void resident.selectWorkspace('workspace-2' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
|
||||
@@ -31,7 +31,7 @@ async function bench() {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
@@ -83,7 +83,7 @@ describe('apply wiring', () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
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.order).toBe(0)
|
||||
// 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
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
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 () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ function snapshotWith(
|
||||
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
|
||||
@@ -36,7 +36,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: Partial<ConversationSnapshot>) {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
@@ -100,9 +100,9 @@ describe('StatsLine', () => {
|
||||
render(<Counting {...props(source)} />)
|
||||
const before = renders
|
||||
// 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: 'ab' }] } }))
|
||||
act(() => set({ running: true }))
|
||||
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({ running: true }) })
|
||||
expect(renders).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -128,7 +128,7 @@ describe('bash sample row', () => {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
@@ -108,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
return info
|
||||
},
|
||||
maybeProvideInfo(id: string | undefined) {
|
||||
// `this` inside an object-literal method is any under strict lint; the
|
||||
// fake resolves through its own provideInfo above.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
@@ -252,7 +256,7 @@ describe('registrant load-order seam', () => {
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
|
||||
@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -39,7 +40,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: Partial<ConversationSnapshot>) {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
@@ -104,8 +105,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: { setDraft: () => {}, submit: () => {} },
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
@@ -124,9 +125,9 @@ describe('chat-flow derivation', () => {
|
||||
assistant(5, 'found'), toolResult(6, 'c'),
|
||||
]
|
||||
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]!
|
||||
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(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
@@ -155,7 +156,7 @@ describe('ChatView', () => {
|
||||
fireEvent.scroll(scroller)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -240,10 +241,10 @@ describe('ChatView', () => {
|
||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.props.renderSlot = (((_key: string, _owner: object) => {
|
||||
h.props.renderSlot = ((_key: string, _owner: object) => {
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('counting-row')).toBeTruthy()
|
||||
const afterMount = rowRenders
|
||||
@@ -270,7 +271,7 @@ describe('ChatView', () => {
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -284,10 +285,10 @@ describe('ChatView', () => {
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
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 } : {}) })
|
||||
return opts?.fallback ?? null
|
||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
||||
})
|
||||
render(<h.ChatView {...h.props} />)
|
||||
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
|
||||
// name, and the fallback (GenericToolCard) renders on an empty ledger.
|
||||
@@ -306,10 +307,10 @@ describe('ChatView', () => {
|
||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
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)
|
||||
// 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)
|
||||
})
|
||||
|
||||
@@ -324,7 +325,7 @@ describe('ChatView', () => {
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
// 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)
|
||||
fireEvent.click(backButton)
|
||||
expect(scroller.scrollTop).toBe(1000)
|
||||
@@ -337,7 +338,7 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
||||
act(() => h.set({ loadingOlder: true }))
|
||||
act(() => { h.set({ loadingOlder: true }) })
|
||||
expect(view.getByText('加载中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
@@ -83,7 +83,7 @@ describe('tails', () => {
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
|
||||
@@ -21,7 +21,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
describe('render branch tails', () => {
|
||||
@@ -73,11 +73,11 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
@@ -108,11 +108,11 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
|
||||
@@ -79,11 +79,11 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -212,7 +212,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
const { textarea, wiring } = bench()
|
||||
fireEvent.change(textarea, { target: { value: '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', () => {
|
||||
@@ -364,10 +364,10 @@ describe('placeholder chrome and control seats', () => {
|
||||
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
||||
expect(view.getByTestId('model-entry')).toBeTruthy()
|
||||
// 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()
|
||||
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)', () => {
|
||||
|
||||
@@ -34,11 +34,11 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -88,7 +88,7 @@ describe('matrix row: claimed', () => {
|
||||
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
|
||||
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||
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.
|
||||
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
|
||||
expect(shell.snapshot.phase).toBe('claimed')
|
||||
@@ -104,7 +104,7 @@ describe('matrix row: claimed', () => {
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
|
||||
// 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()
|
||||
})
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('matrix row: submitting', () => {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
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()
|
||||
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
|
||||
@@ -145,7 +145,7 @@ describe('matrix row: submitting', () => {
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
|
||||
act(() => { rejectSubmit(new Error('执行失败')) })
|
||||
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()
|
||||
cleanup()
|
||||
// Drift: typing during flight wins; no restore, plain, notice only.
|
||||
@@ -157,7 +157,7 @@ describe('matrix row: submitting', () => {
|
||||
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
|
||||
act(() => { rejectSubmit(new Error('晚到失败')) })
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -165,14 +165,14 @@ describe('matrix row: submitting', () => {
|
||||
describe('matrix row: locked (session disabled)', () => {
|
||||
it('disables the textarea and chrome; the machine currency is untouched', () => {
|
||||
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(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
|
||||
const { textarea, sink } = bench({ running: true })
|
||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
|
||||
expect((textarea).disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队', 'queue')
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
@@ -100,7 +99,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
await ctx.plugin(SlashService).await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
register?.(slash)
|
||||
const actx = sessions.scope(sessionId)! as ClientContext
|
||||
const actx = sessions.scope(sessionId)!
|
||||
const controller = slash.sessionOf(actx)
|
||||
const sink = vi.fn()
|
||||
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
|
||||
@@ -121,11 +120,11 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useSession: bindSnapshotSelector(sessionStore),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -134,7 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const type = (text: string): void => {
|
||||
fireEvent.change(textarea, { target: { value: text } })
|
||||
}
|
||||
@@ -145,7 +144,7 @@ async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
|
||||
const execute = vi.fn(executeImpl ?? ((line: string) =>
|
||||
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ function DetailsColumn(props: { children?: ReactNode }) {
|
||||
return <div className={css.detailsCol}>{props.children}</div>
|
||||
}
|
||||
|
||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */
|
||||
/**
|
||||
* One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin.
|
||||
* `side` keys the hover-reveal CSS to the owning column.
|
||||
*/
|
||||
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const origin = useRef(0)
|
||||
@@ -86,7 +89,7 @@ export function AppFrame({
|
||||
actions,
|
||||
renderSlot,
|
||||
}: AppFrameProps) {
|
||||
const panels = useStore((s) => s)
|
||||
const panels = useStore(s => s)
|
||||
const frameRef = useRef<HTMLDivElement | null>(null)
|
||||
const [viewport, setViewport] = useState(() => window.innerWidth)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ let fireResize: (() => void) | null = null
|
||||
class ResizeObserverStub {
|
||||
#cb: ResizeObserverCallback
|
||||
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
|
||||
observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } }
|
||||
observe(): void { fireResize = () => { this.#cb([], this) } }
|
||||
unobserve(): void {}
|
||||
disconnect(): void { fireResize = null }
|
||||
}
|
||||
@@ -48,7 +48,7 @@ let frameWidth = 1920
|
||||
|
||||
/** Test-local selector hook over a framework-neutral store instance. */
|
||||
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() {
|
||||
@@ -118,7 +118,7 @@ beforeEach(() => {
|
||||
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
|
||||
window.innerWidth = frameWidth
|
||||
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.
|
||||
const captured = new WeakSet<Element>()
|
||||
@@ -143,12 +143,12 @@ describe('AppFrame', () => {
|
||||
const { slotCalls, getByTestId } = mountFrame()
|
||||
expect(getByTestId('center-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('details')
|
||||
expect(keys).not.toContain('conversation.empty')
|
||||
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
|
||||
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
|
||||
expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
|
||||
expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps the conversation slot mounted while no session is current', () => {
|
||||
@@ -157,7 +157,7 @@ describe('AppFrame', () => {
|
||||
sessionMode.current = false
|
||||
const { slotCalls, getByTestId } = mountFrame()
|
||||
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)', () => {
|
||||
@@ -165,13 +165,13 @@ describe('AppFrame', () => {
|
||||
// pending rendering — both occupants mount from first paint.
|
||||
baselinesReady.current = false
|
||||
const { slotCalls } = mountFrame()
|
||||
expect(slotCalls.map((c) => c.key)).toContain('conversation')
|
||||
expect(slotCalls.map((c) => c.key)).toContain('details')
|
||||
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
||||
expect(slotCalls.map(c => c.key)).toContain('details')
|
||||
})
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
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', () => {
|
||||
@@ -211,7 +211,7 @@ describe('AppFrame', () => {
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
|
||||
expect(getByTestId('sidebar-content')).toBeTruthy()
|
||||
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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Button({ variant = 'ghost', size = 'md', icon, className, childr
|
||||
variant?: ButtonVariant
|
||||
size?: 'md' | 'sm'
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
className?: string | undefined
|
||||
children?: ReactNode
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
|
||||
@@ -158,63 +158,63 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
// (open/toggle) after onSelect.
|
||||
onClick={(e) => { e.stopPropagation() }}
|
||||
>
|
||||
{items.map(entry => {
|
||||
if (isSeparator(entry)) {
|
||||
return <div key={entry.id} className={css.separator} role="separator" />
|
||||
}
|
||||
if (isLabel(entry)) {
|
||||
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
||||
}
|
||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||
const subOpen = hasSub && openSubmenuId === entry.id
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={css.itemWrap}
|
||||
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
||||
disabled={entry.disabled}
|
||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||
aria-expanded={hasSub ? subOpen : undefined}
|
||||
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onClick={() => {
|
||||
if (hasSub) {
|
||||
setOpenSubmenuId(entry.id)
|
||||
return
|
||||
}
|
||||
onSelect(entry.id)
|
||||
}}
|
||||
>
|
||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||
<span className={css.itemLabel}>{entry.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={css.item}
|
||||
disabled={sub.disabled}
|
||||
onClick={() => { onSelect(sub.id) }}
|
||||
>
|
||||
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
||||
<span className={css.itemLabel}>{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{items.map((entry) => {
|
||||
if (isSeparator(entry)) {
|
||||
return <div key={entry.id} className={css.separator} role="separator" />
|
||||
}
|
||||
if (isLabel(entry)) {
|
||||
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
||||
}
|
||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||
const subOpen = hasSub && openSubmenuId === entry.id
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={css.itemWrap}
|
||||
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
||||
disabled={entry.disabled}
|
||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||
aria-expanded={hasSub ? subOpen : undefined}
|
||||
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onClick={() => {
|
||||
if (hasSub) {
|
||||
setOpenSubmenuId(entry.id)
|
||||
return
|
||||
}
|
||||
onSelect(entry.id)
|
||||
}}
|
||||
>
|
||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||
<span className={css.itemLabel}>{entry.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={css.item}
|
||||
disabled={sub.disabled}
|
||||
onClick={() => { onSelect(sub.id) }}
|
||||
>
|
||||
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
||||
<span className={css.itemLabel}>{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ interface AnchorProps {
|
||||
* Attach a hover/focus tooltip to an anchor element.
|
||||
* @param props.label - bubble text.
|
||||
* @param props.side - placement relative to the anchor (default 'right').
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||
* toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
|
||||
@@ -544,14 +544,14 @@ export const IconApiOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<path transform="translate(0.6689 1.073)" d="M11.4818 5.57813C11.4818 4.45301 11.4807 3.66237 11.4075 3.05908C11.3359 2.46953 11.2024 2.13852 10.9939 1.89441C10.9247 1.81341 10.8493 1.73801 10.7683 1.66882C10.5242 1.46033 10.1932 1.32686 9.60364 1.25525C9.00034 1.18198 8.20974 1.18091 7.0846 1.18091L5.57813 1.18091C4.45301 1.18091 3.66238 1.18198 3.05908 1.25525C2.46953 1.32686 2.13852 1.46033 1.89441 1.66882C1.81341 1.73801 1.73801 1.81341 1.66882 1.89441C1.46033 2.13852 1.32686 2.46953 1.25525 3.05908C1.18198 3.66238 1.18091 4.45301 1.18091 5.57813L1.18091 6.2771C1.18091 7.40218 1.18197 8.19288 1.25525 8.79614C1.32687 9.38553 1.46036 9.71674 1.66882 9.96082C1.73797 10.0417 1.81347 10.1173 1.89441 10.1864C2.13851 10.3948 2.46965 10.5275 3.05908 10.5991C3.66238 10.6724 4.45298 10.6735 5.57813 10.6735L7.0846 10.6735C8.20977 10.6735 9.00033 10.6724 9.60364 10.5991C10.1931 10.5275 10.5242 10.3948 10.7683 10.1864C10.8493 10.1173 10.9247 10.0417 10.9939 9.96082C11.2024 9.71674 11.3358 9.38553 11.4075 8.79614C11.4808 8.19288 11.4818 7.40218 11.4818 6.2771L11.4818 5.57813ZM12.6627 6.2771C12.6627 7.37222 12.6637 8.247 12.5798 8.93799C12.4942 9.64284 12.3133 10.2359 11.8928 10.7282C11.7834 10.8562 11.6637 10.9751 11.5356 11.0845C11.0434 11.5049 10.4511 11.6867 9.74634 11.7723C9.05525 11.8563 8.17999 11.8552 7.0846 11.8552L5.57813 11.8552C4.48273 11.8552 3.60747 11.8563 2.91638 11.7723C2.21157 11.6867 1.61933 11.5049 1.12708 11.0845C0.99901 10.9751 0.879281 10.8562 0.769898 10.7282C0.349454 10.2359 0.168506 9.64284 0.0828864 8.93799C-0.00101964 8.247 4.88512e-07 7.37222 6.47206e-07 6.2771L6.47206e-07 5.57813C6.47206e-07 4.48273 -0.00106163 3.60747 0.0828864 2.91638C0.168502 2.21168 0.349594 1.61928 0.769898 1.12708C0.879302 0.998981 0.998981 0.879302 1.12708 0.769898C1.61928 0.349594 2.21168 0.168502 2.91638 0.0828864C3.60747 -0.00106163 4.48273 6.47206e-07 5.57813 6.47206e-07L7.0846 6.47206e-07C8.17999 6.47206e-07 9.05525 -0.00106163 9.74634 0.0828864C10.451 0.168505 11.0434 0.349587 11.5356 0.769898C11.6637 0.879302 11.7834 0.998981 11.8928 1.12708C12.3131 1.61928 12.4942 2.21169 12.5798 2.91638C12.6638 3.60747 12.6627 4.48273 12.6627 5.57813L12.6627 6.2771Z" fill="currentColor"/>
|
||||
<path transform="translate(0.6689 1.073)" d="M6.02607 5.50955L6.44306 5.9274L3.84284 8.52762L3.425 8.11063L3.00715 7.69278L4.77253 5.9274L3.00715 4.16202L3.84284 3.32633L6.02607 5.50955Z" fill="currentColor"/>
|
||||
<path transform="translate(0.6689 1.073)" d="M9.23789 7.35397L9.23789 8.53488L6.96238 8.53488L6.96238 7.35397L9.23789 7.35397Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_personalization_outline_16 (figma extract) */
|
||||
export const IconPersonalizationOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(1.292 1.3)" d="M10.3232 9.18164C11.2868 9.18164 12.0985 9.82833 12.3506 10.7109L13.415 10.7109L13.415 11.8711L12.3496 11.8711C12.0971 12.7532 11.2864 13.3994 10.3232 13.3994C9.36031 13.3992 8.55012 12.7531 8.29785 11.8711L0 11.8711L0 10.7109L8.29688 10.7109C8.54876 9.82845 9.35988 9.18186 10.3232 9.18164ZM10.3232 10.3418C9.7999 10.3421 9.37534 10.7667 9.375 11.29C9.375 11.8137 9.79969 12.239 10.3232 12.2393C10.847 12.2393 11.2725 11.8138 11.2725 11.29C11.2721 10.7666 10.8468 10.3418 10.3232 10.3418ZM12.4326 11.291C12.4326 11.3549 12.4284 11.418 12.4229 11.4805C12.4287 11.4181 12.4326 11.355 12.4326 11.291ZM8.21484 11.2832C8.21484 11.2856 8.21484 11.2886 8.21484 11.291L8.21484 11.29C8.21484 11.2878 8.21484 11.2855 8.21484 11.2832ZM3.08301 4.59082C4.04605 4.59095 4.85696 5.23717 5.10938 6.11914L13.415 6.11914L13.415 7.2793L5.11035 7.2793C4.85833 8.16202 4.04648 8.80846 3.08301 8.80859C2.11972 8.80843 1.30963 8.16179 1.05762 7.2793L0 7.2793L0 6.11914L1.05762 6.11914C1.30994 5.23728 2.12006 4.59098 3.08301 4.59082ZM3.08301 5.75098C2.55962 5.75117 2.13512 6.17587 2.13477 6.69922C2.13477 7.22287 2.5594 7.64824 3.08301 7.64844C3.60665 7.64828 4.03223 7.2229 4.03223 6.69922C4.03187 6.17585 3.60643 5.75113 3.08301 5.75098ZM5.19238 6.69922C5.19238 6.763 5.18816 6.82633 5.18262 6.88867C5.18846 6.82629 5.19238 6.76313 5.19238 6.69922C5.19236 6.63495 5.18853 6.57152 5.18262 6.50879C5.18826 6.57154 5.19236 6.635 5.19238 6.69922ZM0.982422 6.52344C0.977382 6.58136 0.97463 6.63999 0.974609 6.69922C0.974609 6.75775 0.977496 6.81579 0.982422 6.87305C0.977758 6.81579 0.974609 6.75767 0.974609 6.69922C0.974628 6.64 0.977618 6.58142 0.982422 6.52344ZM10.3232 0C11.2869 0 12.0986 0.646596 12.3506 1.5293L13.415 1.5293L13.415 2.68945L12.3496 2.68945C12.363 2.64266 12.3754 2.59488 12.3857 2.54688C12.1838 3.50118 11.3376 4.21777 10.3232 4.21777C9.36037 4.21756 8.55018 3.57139 8.29785 2.68945L0 2.68945L0 1.5293L8.29688 1.5293C8.5487 0.646717 9.35981 0.00021854 10.3232 0ZM10.3232 1.16016C9.79984 1.16042 9.37524 1.58499 9.375 2.1084C9.375 2.63201 9.79969 3.05735 10.3232 3.05762C10.847 3.05762 11.2725 2.63217 11.2725 2.1084C11.2722 1.58483 10.8469 1.16016 10.3232 1.16016ZM12.4229 2.29883C12.4287 2.23641 12.4326 2.17331 12.4326 2.10938C12.4326 2.17327 12.4284 2.23638 12.4229 2.29883ZM8.21484 2.10938L8.21484 2.1084L8.21484 2.10938ZM8.22266 1.93359C8.21785 1.98897 8.21506 2.04499 8.21484 2.10156C8.21503 2.04501 8.2181 1.98902 8.22266 1.93359ZM8.22266 11.1162C8.2179 11.1713 8.21507 11.227 8.21484 11.2832C8.21504 11.227 8.21814 11.1713 8.22266 11.1162Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_project_add_outline_16 (figma extract) */
|
||||
@@ -559,7 +559,7 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(9.52 2.52)" d="M3.55246 0L3.55246 2.44252L6 2.44252L6 3.55748L3.55246 3.55748L3.55246 6L2.43834 6L2.43834 3.55748L0 3.55748L0 2.44252L2.43834 2.44252L2.43834 0L3.55246 0Z" fill="currentColor"/>
|
||||
<path transform="translate(0.3496 2.35)" d="M4.76367 0C5.36861 1.80598e-05 5.93113 0.310294 6.25488 0.821289L6.78027 1.64941C6.79685 1.67558 6.81791 1.69775 6.83887 1.71973C6.72186 2.15521 6.65702 2.61192 6.65137 3.08301C6.25601 2.96045 5.90909 2.70478 5.68164 2.3457L5.15723 1.5166C5.07183 1.38189 4.92318 1.3008 4.76367 1.30078L2.32422 1.30078C1.7589 1.30078 1.30078 1.7589 1.30078 2.32422L1.30078 10.1338C1.30078 10.6991 1.7589 11.1572 2.32422 11.1572L11.9766 11.1572C12.5419 11.1572 13 10.6991 13 10.1338L13 8.58398C13.4545 8.5135 13.8903 8.38748 14.3008 8.21289L14.3008 10.1338C14.3008 11.4171 13.2598 12.458 11.9766 12.458L2.32422 12.458C1.04093 12.458 0 11.4171 0 10.1338L0 2.32422C0 1.04093 1.04093 0 2.32422 0L4.76367 0Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
|
||||
@@ -567,14 +567,14 @@ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
|
||||
<path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** folder_close_16 (figma extract) */
|
||||
export const IconFolderClose16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(1.5 2.429)" d="M5.05582 0.518756L4.50669 0.86654L5.05582 0.518756ZM13 9.4837L13.65 9.4837L13.65 3.53962L13 3.53962L12.35 3.53962L12.35 9.4837L13 9.4837ZM11.3264 1.86603L11.3264 1.21603L6.52313 1.21603L6.52313 1.86603L6.52313 2.51603L11.3264 2.51603L11.3264 1.86603ZM5.58054 1.34727L6.12968 0.999489L5.60495 0.170972L5.05582 0.518756L4.50669 0.86654L5.03141 1.69506L5.58054 1.34727ZM4.11323 1.23058e-13L4.11323 -0.65L1.67359 -0.65L1.67359 5.00699e-14L1.67359 0.65L4.11323 0.65L4.11323 1.23058e-13ZM0 1.67359L-0.65 1.67359L-0.65 9.4837L0 9.4837L0.65 9.4837L0.65 1.67359L0 1.67359ZM11.3264 11.1573L11.3264 10.5073L1.67359 10.5073L1.67359 11.1573L1.67359 11.8073L11.3264 11.8073L11.3264 11.1573ZM0 9.4837L-0.65 9.4837C-0.65 10.767 0.390308 11.8073 1.67359 11.8073L1.67359 11.1573L1.67359 10.5073C1.10828 10.5073 0.65 10.049 0.65 9.4837L0 9.4837ZM1.67359 5.00699e-14L1.67359 -0.65C0.390307 -0.65 -0.65 0.390309 -0.65 1.67359L0 1.67359L0.65 1.67359C0.65 1.10828 1.10828 0.65 1.67359 0.65L1.67359 5.00699e-14ZM5.05582 0.518756L5.60495 0.170972C5.28121 -0.340193 4.71829 -0.65 4.11323 -0.65L4.11323 1.23058e-13L4.11323 0.65C4.27282 0.65 4.4213 0.731715 4.50669 0.86654L5.05582 0.518756ZM6.52313 1.86603L6.52313 1.21603C6.36354 1.21603 6.21507 1.13431 6.12968 0.999489L5.58054 1.34727L5.03141 1.69506C5.35515 2.20622 5.91808 2.51603 6.52313 2.51603L6.52313 1.86603ZM13 3.53962L13.65 3.53962C13.65 2.25634 12.6097 1.21603 11.3264 1.21603L11.3264 1.86603L11.3264 2.51603C11.8917 2.51603 12.35 2.97431 12.35 3.53962L13 3.53962ZM13 9.4837L12.35 9.4837C12.35 10.049 11.8917 10.5073 11.3264 10.5073L11.3264 11.1573L11.3264 11.8073C12.6097 11.8073 13.65 10.767 13.65 9.4837L13 9.4837Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** tree_corner_8x10 (figma extract; session-tree "L" connector, stroke geometry pre-expanded) */
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface CodeBlockProps {
|
||||
|
||||
/** @returns true only when the host accepted the write. */
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -30,6 +33,9 @@ async function writeClipboard(text: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
// jsdom and older hosts: best-effort execCommand path when present.
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing; deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
@@ -48,6 +54,7 @@ async function writeClipboard(text: string): Promise<boolean> {
|
||||
} finally {
|
||||
el.remove()
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
@@ -64,20 +71,20 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1000)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, trimmed])
|
||||
|
||||
const body = html === undefined
|
||||
? (
|
||||
<pre className={css.plain}><code>{trimmed}</code></pre>
|
||||
)
|
||||
<pre className={css.plain}><code>{trimmed}</code></pre>
|
||||
)
|
||||
: (
|
||||
// eslint-disable-next-line react/no-danger -- shiki's output is a static
|
||||
// span tree it generated from `code` (no user HTML passes through), the
|
||||
// sanctioned innerHTML consumption path per shiki's own docs.
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)
|
||||
// shiki's output is a static span tree it generated from `code` (no user
|
||||
// HTML passes through), the sanctioned innerHTML consumption path per
|
||||
// shiki's own docs.
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
||||
|
||||
@@ -15,6 +15,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
if (!open) return ''
|
||||
let s: string
|
||||
try {
|
||||
// lib typing hides stringify's undefined arm (undefined/function/symbol payloads).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
s = JSON.stringify(payload, null, 2) ?? String(payload)
|
||||
} catch {
|
||||
s = String(payload)
|
||||
@@ -23,7 +25,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
}, [open, payload])
|
||||
return (
|
||||
<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}
|
||||
</button>
|
||||
{open && <pre className={css.body}>{body}</pre>}
|
||||
|
||||
@@ -27,25 +27,25 @@ const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||
function buildComponents(streaming: boolean): Components {
|
||||
return {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
// Fenced blocks route through the shared CodeBlock (shiki for registered
|
||||
// grammars, identical-geometry plain fallback for unknown/absent
|
||||
// languages); inline code keeps the default <code> path (the :not(pre)
|
||||
@@ -53,7 +53,9 @@ function buildComponents(streaming: boolean): Components {
|
||||
// plain arm — retokenizing a growing fence on every chunk is quadratic
|
||||
// main-thread work; the finalize swap highlights it once.
|
||||
pre: ({ children }) => {
|
||||
/* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
|
||||
// The markdown pipeline always hands `pre` its single `code` element;
|
||||
// the undefined arm guards a react-markdown representation change.
|
||||
/* v8 ignore next 2 */
|
||||
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
|
||||
const raw = child?.props.children
|
||||
// A fence whose content isn't one plain string (e.g. an empty fence)
|
||||
|
||||
@@ -13,7 +13,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
|
||||
wrapper.getBoundingClientRect = () => ({
|
||||
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: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
}
|
||||
|
||||
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('ic_ds_ icon set', () => {
|
||||
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 { container } = render(<Icon />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('JsonBlock', () => {
|
||||
it('truncates beyond the size cap with a suffix note', () => {
|
||||
const big = 'x'.repeat(30_000)
|
||||
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).toContain('截断')
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
afterEach(cleanup)
|
||||
|
||||
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 dot = container.firstElementChild as HTMLElement
|
||||
expect(dot.dataset['state']).toBe(state)
|
||||
|
||||
@@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string {
|
||||
|
||||
/** Return whether a textarea key event belongs to an active IME composition. */
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
||||
}
|
||||
|
||||
@@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const draft = drafts[index]!
|
||||
const hasOptions = (question.options?.length ?? 0) > 0
|
||||
|
||||
@@ -145,10 +150,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
const skipQuestion = (): void => {
|
||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
||||
? {
|
||||
selected: [], custom: '',
|
||||
customOpen: (question.options?.length ?? 0) === 0,
|
||||
skipped: true,
|
||||
}
|
||||
selected: [], custom: '',
|
||||
customOpen: (question.options?.length ?? 0) === 0,
|
||||
skipped: true,
|
||||
}
|
||||
: item)
|
||||
setDrafts(nextDrafts)
|
||||
setError(null)
|
||||
|
||||
@@ -50,7 +50,7 @@ const QUESTIONS = [
|
||||
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
|
||||
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('QuestionComposer', () => {
|
||||
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
||||
{ id: 'signals', selected: ['系统设计', '代码质量'] },
|
||||
]))
|
||||
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('skips individual questions without discarding earlier answers', () => {
|
||||
@@ -173,7 +173,7 @@ describe('QuestionComposer', () => {
|
||||
// Receipt rejection surfaces through the domain face's thrown message.
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
|
||||
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
||||
@@ -199,7 +199,7 @@ describe('QuestionComposer', () => {
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
expect(await screen.findByText('网络中断')).toBeTruthy()
|
||||
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('GeneralSection', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Permission')).toBeTruthy()
|
||||
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
|
||||
const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
|
||||
const selector = screen.getByRole<HTMLButtonElement>('button', { name: /Read only/ })
|
||||
expect(selector.disabled).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
// Local selection; entries can unmount underneath it, so the render-time
|
||||
// projection falls back to the first row when the id is gone.
|
||||
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()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,7 +56,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
<nav className={css.nav}>
|
||||
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
|
||||
<div className={css.navList}>
|
||||
{rows.map((row) => (
|
||||
{rows.map(row => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
|
||||
@@ -26,7 +26,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
||||
startSession={startSession} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
|
||||
if (key === 'sidebar.settings') {
|
||||
settingsOwner = owner as SidebarSettingsOwnerProps
|
||||
settingsOwner = owner
|
||||
return <div data-testid="settings-seat" data-wide={owner.wide} />
|
||||
}
|
||||
regionOwner = owner as SidebarSectionOwnerProps
|
||||
|
||||
@@ -34,5 +34,5 @@ export interface MenuViewInjected {
|
||||
* @param source - source (group) name.
|
||||
* @param index - candidate index within the group.
|
||||
*/
|
||||
onPick(source: string, index: number): void
|
||||
onPick: (source: string, index: number) => void
|
||||
}
|
||||
@@ -41,7 +41,7 @@ function createPanelStore() {
|
||||
})
|
||||
}
|
||||
|
||||
const chatStore = () => defineStore({
|
||||
const _chatStore = () => defineStore({
|
||||
init: () => ({ selection: null as { id: string } | null, draft: '' }),
|
||||
actions: {
|
||||
select: (d, t: { id: string }) => { d.selection = t },
|
||||
@@ -49,7 +49,7 @@ const chatStore = () => defineStore({
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
type ChatHandle = ReturnType<typeof chatStore>
|
||||
type ChatHandle = ReturnType<typeof _chatStore>
|
||||
|
||||
type FrameProps =
|
||||
& PropsRuntime<'chain.frame'>
|
||||
@@ -115,7 +115,7 @@ describe('terminal-design type chain', () => {
|
||||
// member payloads are the runtime merge's property — not probed here
|
||||
// (the runtime package's own tests cover them).
|
||||
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' })
|
||||
void draft
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('terminal-design type chain', () => {
|
||||
// chain position.
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
|
||||
select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
|
||||
priority: 1,
|
||||
}, Takeover)
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('terminal-design type chain', () => {
|
||||
// checks through parameter contravariance.
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
|
||||
select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
|
||||
}, WideTakeover)
|
||||
|
||||
// renderSlotChain share: chain keys dispatch with the fallback bag;
|
||||
@@ -179,7 +179,7 @@ describe('terminal-design type chain', () => {
|
||||
name: 'chain.side',
|
||||
// @ts-expect-error root-scope inject has no sessionId parameter
|
||||
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.
|
||||
// @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
|
||||
core.register({
|
||||
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)
|
||||
|
||||
// select must return M | null, not undefined (find() must be coalesced).
|
||||
// @ts-expect-error select may not return undefined
|
||||
core.register({
|
||||
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)
|
||||
|
||||
// Chain keys are not renderSlot-dispatchable (and vice versa).
|
||||
@@ -211,7 +211,7 @@ describe('terminal-design type chain', () => {
|
||||
// @ts-expect-error non-chain keys have no renderSlotChain dispatch
|
||||
chainSlots.renderSlotChain('chain.conv', {})
|
||||
// @ts-expect-error a children set without chain keys provides no renderSlotChain
|
||||
fp.renderSlotChain
|
||||
type _NoChainSeat = typeof fp.renderSlotChain
|
||||
|
||||
// renderSlot owner share typed at the call site.
|
||||
// @ts-expect-error owner shape mismatch (width missing)
|
||||
|
||||
@@ -16,11 +16,11 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
subtool: 'Sub',
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
||||
user: css.tagUser!,
|
||||
message: css.tagMessage!,
|
||||
tool: css.tagTool!,
|
||||
subtool: css.tagSubtool!,
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
|
||||
user: css.tagUser,
|
||||
message: css.tagMessage,
|
||||
tool: css.tagTool,
|
||||
subtool: css.tagSubtool,
|
||||
}
|
||||
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
@@ -84,7 +84,7 @@ export function TrajectoryCell({
|
||||
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
|
||||
<span className={css.index}>#{index}</span>
|
||||
<span className={css.tagSlot}>
|
||||
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
|
||||
<span className={[css.tag, TAG_CLASS[kind]].filter((c): c is string => c !== undefined).join(' ')}>{KIND_LABEL[kind]}</span>
|
||||
</span>
|
||||
<span className={css.text}>{text}</span>
|
||||
<span className={css.trailing}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import css from './TrajectoryStatsHeader.module.css'
|
||||
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
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])
|
||||
if (stats.turns === 0) return null
|
||||
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}>
|
||||
<span className={css.title}>Turn {turn}</span>
|
||||
<div className={css.columns} aria-hidden="true">
|
||||
{COLUMN_LABELS.map((label) => (
|
||||
{COLUMN_LABELS.map(label => (
|
||||
<span key={label} className={css.column}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,10 @@ import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const partial = useSession((s) => s.partial)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
|
||||
[nodes, partial, runningCalls, codeDispatches],
|
||||
@@ -22,15 +22,15 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
}
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{turns.map((turn) => (
|
||||
{turns.map(turn => (
|
||||
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
|
||||
{turn.groups.flatMap((group) => [
|
||||
{turn.groups.flatMap(group => [
|
||||
<TrajectoryGroupHeader
|
||||
key={`${group.title}-h`}
|
||||
title={group.title}
|
||||
{...(group.description !== undefined ? { description: group.description } : {})}
|
||||
/>,
|
||||
...group.cells.map((cell) => (
|
||||
...group.cells.map(cell => (
|
||||
<TrajectoryCell key={cell.index} {...cell} />
|
||||
)),
|
||||
])}
|
||||
|
||||
@@ -24,8 +24,8 @@ export interface WaterfallExtraProps {
|
||||
|
||||
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
|
||||
const scale = pxPerNode ?? PX_PER_NODE
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
|
||||
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>
|
||||
{(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 }}>
|
||||
<span className={css.subTag}>{lane.name}</span>
|
||||
<span
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('TrajectoryCell', () => {
|
||||
expect(screen.getByText('381')).toBeTruthy()
|
||||
expect(screen.getByText('155')).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('381')).toBeLessThan(texts.indexOf('155'))
|
||||
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: [] })
|
||||
expect(turns).toHaveLength(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'])
|
||||
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({
|
||||
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?.timeSeconds).toBe(1.3)
|
||||
})
|
||||
@@ -87,14 +87,14 @@ describe('deriveTrajectoryLayout', () => {
|
||||
it('adds runningCalls not already present and leaves their time blank', () => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(),
|
||||
nodes: [] as unknown as ConversationSnapshot['nodes'],
|
||||
nodes: [],
|
||||
partial: null,
|
||||
runningCalls: [{
|
||||
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||
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({
|
||||
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
||||
})
|
||||
@@ -113,9 +113,9 @@ describe('deriveTrajectoryLayout', () => {
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
|
||||
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
|
||||
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a wall-span step description with a tool histogram', () => {
|
||||
@@ -156,9 +156,9 @@ describe('deriveTrajectoryLayout', () => {
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
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[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
|
||||
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[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', () => {
|
||||
@@ -170,7 +170,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
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({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
})
|
||||
@@ -199,8 +199,8 @@ describe('deriveTrajectoryLayout', () => {
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups
|
||||
.flatMap((g) => g.cells)
|
||||
.find((c) => c.kind === 'message' && c.text === 'done')
|
||||
.flatMap(g => g.cells)
|
||||
.find(c => c.kind === 'message' && c.text === 'done')
|
||||
// From context at 9s, not from the earlier user/tool surfaces.
|
||||
expect(message?.timeSeconds).toBe(1)
|
||||
})
|
||||
@@ -234,10 +234,10 @@ describe('run_code sub-dispatch cells', () => {
|
||||
settledSub(2, 'read', 7_300, 7_800),
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]!.groups.flatMap((g) => g.cells)
|
||||
expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
||||
const cells = turns[0]!.groups.flatMap(g => g.cells)
|
||||
expect(cells.map(c => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
||||
// 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[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 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 })
|
||||
})
|
||||
})
|
||||
@@ -144,7 +144,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}}
|
||||
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={() => () => {}}
|
||||
open={vi.fn()}
|
||||
/>,
|
||||
@@ -164,7 +164,7 @@ describe('plugin registration', () => {
|
||||
it('fiber disposal removes both tabs and leaves chat standing', async () => {
|
||||
const b = await bench()
|
||||
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()
|
||||
mount(b.slots)
|
||||
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' }))
|
||||
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 () => {
|
||||
const b = await bench()
|
||||
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
|
||||
mount(b.slots, [])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
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', () => {
|
||||
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
|
||||
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
|
||||
expect(container.firstChild).toBeNull()
|
||||
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -234,7 +234,7 @@ describe('span derivation', () => {
|
||||
describe('WaterfallView standalone branches', () => {
|
||||
it('empty window renders the placeholder copy', () => {
|
||||
render(createElement(WaterfallView as FC<ConvViewProps>,
|
||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('WaterfallView standalone branches', () => {
|
||||
|
||||
describe('node half', () => {
|
||||
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -295,7 +295,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
|
||||
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
|
||||
]]]) as unknown as ConversationSnapshot['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' })
|
||||
// Extends from its start to the window end.
|
||||
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
|
||||
|
||||
@@ -21,7 +21,10 @@ import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
|
||||
import css from './WorkspaceBrowser.module.css'
|
||||
|
||||
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
|
||||
/**
|
||||
* Column slide length (--ds-transition-duration-slow): rail-search focus waits it out —
|
||||
* focus() forces a synchronous layout and would jank the slide.
|
||||
*/
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
@@ -32,7 +35,7 @@ const GROUP_BY_ITEMS = [
|
||||
|
||||
/** Immutable membership toggle for the local expansion arrays. */
|
||||
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. */
|
||||
@@ -61,7 +64,7 @@ function GroupByMenu({ groupBy, onPick }: {
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setOpen((v) => !v) }}
|
||||
onClick={() => { setOpen(v => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
@@ -96,7 +99,7 @@ function SessionTree({
|
||||
useSessions, startSession, open, workspaces, query,
|
||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
@@ -108,7 +111,7 @@ function SessionTree({
|
||||
?? UNGROUPED_KEY
|
||||
useEffect(() => {
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||
@@ -129,22 +132,22 @@ function SessionTree({
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
|
||||
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
actions={group.workspaceId === undefined
|
||||
? undefined
|
||||
: {
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace group roots outside search. The drag
|
||||
@@ -189,7 +192,7 @@ function SessionTree({
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
|
||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||
drag={dragProps}
|
||||
/>
|
||||
)
|
||||
@@ -204,7 +207,7 @@ function SessionTree({
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
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 now = Date.now()
|
||||
return (
|
||||
@@ -292,7 +295,7 @@ export function WorkspaceBrowser({
|
||||
setRenameError(null)
|
||||
}
|
||||
const confirmRename = () => {
|
||||
if (renameBlocked || renameTarget === null) return
|
||||
if (renameBlocked) return
|
||||
setRenaming(true)
|
||||
setRenameError(null)
|
||||
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
|
||||
@@ -419,24 +422,24 @@ export function WorkspaceBrowser({
|
||||
{wide && (groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} query={query} />
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@@ -481,7 +484,7 @@ export function WorkspaceBrowser({
|
||||
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={css.deleteAction!}
|
||||
className={css.deleteAction}
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function WorkspaceCreateFlow({
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...workspaces.map(workspace => ({
|
||||
id: workspace.workspaceId as string,
|
||||
id: workspace.workspaceId,
|
||||
label: workspace.title,
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
disabled: pickingFolder,
|
||||
@@ -161,8 +161,8 @@ export function WorkspaceCreateFlow({
|
||||
title={folderConflict ? 'A workspace with this name already exists' : 'Couldn’t open folder'}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
|
||||
<Button variant="primary" className={css.modalAction!} onClick={openLocalFolder}>Choose again</Button>
|
||||
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
|
||||
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -179,10 +179,10 @@ export function WorkspaceCreateFlow({
|
||||
description="The name is used for both the workspace and its new folder."
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
className={css.modalAction}
|
||||
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
|
||||
@@ -59,9 +59,9 @@ export type WorkspaceBrowserProps =
|
||||
*/
|
||||
export type WorkspacePickerInjected = {
|
||||
/** Explicitly create or adopt a real Workspace before targeting a Session. */
|
||||
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** Ask the local Host to open its native single-directory picker. */
|
||||
pickDirectory(): Promise<string | null>
|
||||
pickDirectory: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,37 +192,37 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
||||
onDragStart={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
drag.start()
|
||||
}}
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
drag.start()
|
||||
}}
|
||||
onDragEnd={drag?.end}
|
||||
onDragOver={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
drag.hover(rowHalf(e))
|
||||
}}
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
drag.hover(rowHalf(e))
|
||||
}}
|
||||
onDrop={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
drag.drop(rowHalf(e))
|
||||
}}
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
drag.drop(rowHalf(e))
|
||||
}}
|
||||
>
|
||||
{row.hasChildren && !flat
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.title}>{row.title}</span>
|
||||
|
||||
@@ -16,7 +16,7 @@ function stubRect(row: HTMLElement): void {
|
||||
row.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
|
||||
x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
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. */
|
||||
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
||||
@@ -297,7 +299,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
|
||||
three.getBoundingClientRect = () => ({
|
||||
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
// Drop on the top half of "three": insert one before three.
|
||||
@@ -310,7 +312,7 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
one.getBoundingClientRect = () => ({
|
||||
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, 'drop', 105)
|
||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||
@@ -336,7 +338,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
fireDrag(two, 'drop', 155)
|
||||
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]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireEvent.dragEnd(one)
|
||||
@@ -382,7 +384,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'drop', 180)
|
||||
@@ -404,13 +406,13 @@ describe('WorkspaceBrowser', () => {
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
expect(input.value).toBe('Alpha')
|
||||
// Unchanged and blank names stay blocked.
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
// A duplicate of another workspace's title shows the inline conflict.
|
||||
fireEvent.change(input, { target: { value: ' Beta ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: 'Gamma' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
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('sessions will appear under Ungrouped')
|
||||
|
||||
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
|
||||
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
||||
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
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',
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}
|
||||
@@ -131,8 +133,8 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<string | null>((settle) => { resolve = settle })
|
||||
const b = mount([], vi.fn(), vi.fn(() => pending))
|
||||
chooseItem('Open local folder…')
|
||||
expect((screen.getByRole('menuitem', { name: 'Open local folder…' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((screen.getByRole('menuitem', { name: 'Create a new workspace' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { resolve(null); await pending })
|
||||
@@ -159,7 +161,7 @@ describe('WorkspacePicker', () => {
|
||||
chooseItem('Create a new workspace')
|
||||
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('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
|
||||
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -200,7 +200,8 @@ function standardKit(
|
||||
scope: SlotScope,
|
||||
info: SessionMaybeProvideInfo | undefined,
|
||||
): {
|
||||
kit: InjectedProps; actions: object | undefined
|
||||
kit: InjectedProps
|
||||
actions: object | undefined
|
||||
} {
|
||||
const kit: InjectedProps = {
|
||||
useSessions: observableHook(host.sessions.list),
|
||||
@@ -232,13 +233,13 @@ function standardKit(
|
||||
kit['renderSlot'] = boundRenderSlot(host, entry)
|
||||
// renderSlotChain rides the same declaration source: only entries whose
|
||||
// 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)
|
||||
}
|
||||
// SessionProvider standard seat: entries declaring a session-scope child
|
||||
// render the session area, so the framework hands them the self-wired
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -253,7 +254,9 @@ function standardKit(
|
||||
* composition point, one per scope branch).
|
||||
*/
|
||||
function SessionEntry({ entry, ownerProps, info }: {
|
||||
entry: StoredEntry; ownerProps: object; info: SessionProvideInfo
|
||||
entry: StoredEntry
|
||||
ownerProps: object
|
||||
info: SessionProvideInfo
|
||||
}) {
|
||||
const host = useHost()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
@@ -280,7 +283,9 @@ function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: obje
|
||||
}
|
||||
|
||||
function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
||||
slotKey: string; entry: StoredEntry; ownerProps: object
|
||||
slotKey: string
|
||||
entry: StoredEntry
|
||||
ownerProps: object
|
||||
}) {
|
||||
const info = useSessionMaybeProvideInfo()
|
||||
if (info.sessionId === undefined) return null
|
||||
@@ -292,12 +297,14 @@ function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
||||
}
|
||||
|
||||
function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
slotKey: string; ownerProps: object; opts?: (RenderOpts & ChainRenderOpts) | undefined
|
||||
slotKey: string
|
||||
ownerProps: object
|
||||
opts?: (RenderOpts & ChainRenderOpts) | undefined
|
||||
}) {
|
||||
const host = useHost()
|
||||
// Version tick drives entries() re-read; the host batches per microtask.
|
||||
useSyncExternalStore(
|
||||
(fn) => host.subscribe(slotKey, fn),
|
||||
fn => host.subscribe(slotKey, fn),
|
||||
() => host.getVersion(slotKey),
|
||||
)
|
||||
const sessionInfo = useSessionMaybeProvideInfo()
|
||||
@@ -321,12 +328,12 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
spec.scope === 'session'
|
||||
? <StrictSessionEntry slotKey={slotKey} entry={entry} ownerProps={owner} key={key} />
|
||||
: (
|
||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
||||
{spec.scope === 'session-maybe'
|
||||
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
|
||||
: <RootEntry entry={entry} ownerProps={owner} />}
|
||||
</SlotErrorBoundary>
|
||||
)
|
||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
||||
{spec.scope === 'session-maybe'
|
||||
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
|
||||
: <RootEntry entry={entry} ownerProps={owner} />}
|
||||
</SlotErrorBoundary>
|
||||
)
|
||||
)
|
||||
|
||||
if (spec.kind === 'single') {
|
||||
@@ -335,7 +342,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
return guarded(entry)
|
||||
}
|
||||
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}</>
|
||||
return guarded(entry)
|
||||
}
|
||||
@@ -388,13 +395,13 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
return elected ?? <>{opts?.fallback ?? null}</>
|
||||
}
|
||||
// list: registration order refined by explicit order, optional id filter.
|
||||
const withListOptions = entries.map((entry) => ({
|
||||
const withListOptions = entries.map(entry => ({
|
||||
entry,
|
||||
id: entry.options?.id,
|
||||
order: entry.options?.order ?? 0,
|
||||
id: entry.options.id,
|
||||
order: entry.options.order ?? 0,
|
||||
}))
|
||||
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}</>
|
||||
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
|
||||
}
|
||||
@@ -403,7 +410,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
function RootOutlet({ ownerProps }: { ownerProps: object }) {
|
||||
const host = useHost()
|
||||
useSyncExternalStore(
|
||||
(fn) => host.subscribe('root', fn),
|
||||
fn => host.subscribe('root', fn),
|
||||
() => host.getVersion('root'),
|
||||
)
|
||||
const entry = host.entriesOf('root')[0]
|
||||
|
||||
@@ -73,11 +73,14 @@ const absentSource: HostObservable<undefined> = {
|
||||
/** Bind a source that disappears with the current session to an optional selector hook. */
|
||||
export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
|
||||
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 {
|
||||
return observableHook(absentSource)(() => undefined)
|
||||
// The uSES subscription must still run (hook-order stability); the absent
|
||||
// source always snapshots undefined, returned explicitly.
|
||||
observableHook(absentSource)(() => undefined)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +90,7 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
|
||||
*/
|
||||
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
|
||||
const host = useHost()
|
||||
const id = observableHook(host.sessions.current)((s) => s)
|
||||
const id = observableHook(host.sessions.current)(s => s)
|
||||
return (
|
||||
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
|
||||
{children}
|
||||
@@ -112,7 +115,7 @@ export interface SessionProviderProps {
|
||||
*/
|
||||
export function SessionProvider({ empty, children }: SessionProviderProps) {
|
||||
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)
|
||||
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from
|
||||
// Keep equality local: this suite asserts the eq parameter contract without
|
||||
// adding a reverse dependency from web-react to runtime.
|
||||
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 }
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('bindSnapshotSelector', () => {
|
||||
const { source, set } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
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)
|
||||
const before = probe.renders
|
||||
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 useSelector = bindSnapshotSelector(source)
|
||||
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
|
||||
act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice
|
||||
expect(probe.renders).toBe(before)
|
||||
@@ -78,11 +78,11 @@ describe('bindSnapshotSelector', () => {
|
||||
const { source, set, stats } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
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
|
||||
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 }) })
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('bindSnapshotSelector', () => {
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
const view = render(
|
||||
<StrictMode>
|
||||
<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />
|
||||
<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />
|
||||
</StrictMode>,
|
||||
)
|
||||
expect(probe.value).toBe(1)
|
||||
@@ -112,7 +112,7 @@ describe('bindSnapshotSelector', () => {
|
||||
}
|
||||
const useSelector = bindSnapshotSelector(new MethodSource())
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
|
||||
function hostOver(core: SlotCore): SlotRendererHost {
|
||||
return {
|
||||
subscribe: (key, fn) => core.subscribe(key, fn),
|
||||
getVersion: (key) => core.getVersion(key),
|
||||
entriesOf: (key) => core.entries(key),
|
||||
specOf: (key) => core.specDynamic(key),
|
||||
isLive: (entry) => core.isLive(entry),
|
||||
getVersion: key => core.getVersion(key),
|
||||
entriesOf: key => core.entries(key),
|
||||
specOf: key => core.specDynamic(key),
|
||||
isLive: entry => core.isLive(entry),
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
@@ -61,7 +61,7 @@ function mountFrame(core: SlotCore, body: (renderSlot: FrameSlots['renderSlot'])
|
||||
describe('createSlotRenderer over the real SlotCore', () => {
|
||||
it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
|
||||
const core = new SlotCore()
|
||||
const { view } = mountFrame(core, (renderSlot) =>
|
||||
const { view } = mountFrame(core, renderSlot =>
|
||||
renderSlot('spec.single', {}, { fallback: <i>none</i> }))
|
||||
expect(view.container.textContent).toBe('none')
|
||||
let dispose = () => {}
|
||||
@@ -78,7 +78,7 @@ describe('createSlotRenderer over the real SlotCore', () => {
|
||||
const core = new SlotCore()
|
||||
const notified = vi.fn()
|
||||
core.subscribe('spec.list', notified)
|
||||
const { view } = mountFrame(core, (renderSlot) => renderSlot('spec.list', {}))
|
||||
const { view } = mountFrame(core, renderSlot => renderSlot('spec.list', {}))
|
||||
await act(async () => {
|
||||
core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
|
||||
core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)
|
||||
|
||||
@@ -33,7 +33,10 @@ const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry
|
||||
* but entry.store is typed to the full contract — the real defineStore lives
|
||||
* in runtime, which web-react tests must not import (dependency direction).
|
||||
*/
|
||||
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
|
||||
function miniStore<T extends object>(
|
||||
init: () => T,
|
||||
mutators: Record<string, (state: T, ...params: never[]) => T>,
|
||||
): StoreHandle<T, ActionsDecl<T>> {
|
||||
return {
|
||||
spec: { init, actions: {} },
|
||||
create: () => {
|
||||
@@ -96,10 +99,10 @@ function makeHost() {
|
||||
subs.set(key, set)
|
||||
return () => { set.delete(fn) }
|
||||
},
|
||||
getVersion: (key) => versions.get(key) ?? 0,
|
||||
entriesOf: (key) => entries.get(key) ?? [],
|
||||
specOf: (key) => specs.get(key),
|
||||
isLive: (entry) => live.has(entry),
|
||||
getVersion: key => versions.get(key) ?? 0,
|
||||
entriesOf: key => entries.get(key) ?? [],
|
||||
specOf: key => specs.get(key),
|
||||
isLive: entry => live.has(entry),
|
||||
storeOf: (entry, scopeKey) => {
|
||||
if (entry.store === undefined) return undefined
|
||||
let perScope = storeCache.get(entry)
|
||||
@@ -121,8 +124,8 @@ function makeHost() {
|
||||
sessions: {
|
||||
list,
|
||||
current,
|
||||
provideInfo: (id) => infos.get(id),
|
||||
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
|
||||
provideInfo: id => infos.get(id),
|
||||
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: {}, props: {} },
|
||||
},
|
||||
workspaces: { list: workspaces },
|
||||
@@ -145,7 +148,7 @@ function makeHost() {
|
||||
live.add(entry)
|
||||
bump(key)
|
||||
return () => {
|
||||
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
|
||||
entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
|
||||
live.delete(entry)
|
||||
bump(key)
|
||||
}
|
||||
@@ -187,7 +190,7 @@ const chainEntryOf = (partial: {
|
||||
priority?: number
|
||||
}): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
|
||||
component: partial.component,
|
||||
select: partial.select as StoredEntry['select'],
|
||||
select: partial.select,
|
||||
...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
|
||||
})
|
||||
|
||||
@@ -230,7 +233,7 @@ describe('child outlets and the renderSlot binding', () => {
|
||||
const h = makeHost()
|
||||
h.declare('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')
|
||||
let dispose = () => {}
|
||||
act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) })
|
||||
@@ -242,7 +245,7 @@ describe('child outlets and the renderSlot binding', () => {
|
||||
it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => {
|
||||
const h = makeHost()
|
||||
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
|
||||
// undefined): the outlet renders nothing, not even the fallback path's spec dispatch.
|
||||
expect(view.container.querySelector('main')!.textContent).toBe('')
|
||||
@@ -256,7 +259,7 @@ describe('child outlets and the renderSlot binding', () => {
|
||||
h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } })
|
||||
h.add('k.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 { view } = mountRoot(h, children, (renderSlot) => <>
|
||||
const { view } = mountRoot(h, children, renderSlot => <>
|
||||
<main>{renderSlot('k.list', {})}</main>
|
||||
<aside>{renderSlot('k.list', {}, { only: 'b' })}</aside>
|
||||
<nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav>
|
||||
@@ -291,7 +294,7 @@ describe('child outlets and the renderSlot binding', () => {
|
||||
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
|
||||
(renderSlot) => renderSlot('k.list', {}))
|
||||
renderSlot => renderSlot('k.list', {}))
|
||||
spy.mockRestore()
|
||||
expect(view.container.textContent).toBe('alive')
|
||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||
@@ -309,10 +312,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
}))
|
||||
h.add('k.chain', chainEntryOf({
|
||||
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 },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' }))
|
||||
renderSlotChain => renderSlotChain('k.chain', { tag: 'T' }))
|
||||
// The declining entry never mounts: the routing decision is select-layer only.
|
||||
expect(view.container.textContent).toBe('hit:T')
|
||||
expect(declinerBody).not.toHaveBeenCalled()
|
||||
@@ -327,10 +330,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
}))
|
||||
h.add('k.chain', chainEntryOf({
|
||||
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 { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
|
||||
<main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
|
||||
<aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
|
||||
</>)
|
||||
@@ -347,16 +350,16 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
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({
|
||||
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'
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { pick }))
|
||||
renderSlotChain => renderSlotChain('k.chain', { pick }))
|
||||
spy.mockRestore()
|
||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||
// Re-elect entry B: the entry-keyed boundary remounts fresh instead of
|
||||
@@ -372,9 +375,9 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
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>
|
||||
<aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
|
||||
</>)
|
||||
@@ -387,7 +390,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
const h = makeHost()
|
||||
h.declare('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')
|
||||
let dispose = () => {}
|
||||
act(() => {
|
||||
@@ -422,7 +425,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
priority: 1,
|
||||
}))
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', {}))
|
||||
renderSlotChain => renderSlotChain('k.chain', {}))
|
||||
expect(view.container.textContent).toBe('early')
|
||||
})
|
||||
|
||||
@@ -490,13 +493,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
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 Probe = fallbackProbe(mounted)
|
||||
let take = false
|
||||
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 input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
|
||||
|
||||
@@ -525,13 +528,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
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 Probe = fallbackProbe(mounted)
|
||||
let take = false
|
||||
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' } })
|
||||
expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull()
|
||||
|
||||
@@ -561,7 +564,7 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
||||
priority: 2,
|
||||
}))
|
||||
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(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
|
||||
spy.mockRestore()
|
||||
@@ -578,9 +581,9 @@ describe('standard-kit synthesis', () => {
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.add('k.single', {
|
||||
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')
|
||||
act(() => { h.list.set({ ids: ['a', 'b'] }) })
|
||||
expect(view.container.textContent).toBe('2')
|
||||
@@ -591,9 +594,9 @@ describe('standard-kit synthesis', () => {
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.add('k.single', {
|
||||
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')
|
||||
act(() => { h.workspaces.set({ ids: ['w1'] }) })
|
||||
expect(view.container.textContent).toBe('1')
|
||||
@@ -606,11 +609,11 @@ describe('standard-kit synthesis', () => {
|
||||
const seen: AnyProps[] = []
|
||||
h.add('k.session', {
|
||||
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
|
||||
},
|
||||
})
|
||||
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
|
||||
mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
|
||||
<SessionProvider empty={() => <i>empty</i>}>
|
||||
{() => renderSlot('k.session', {})}
|
||||
</SessionProvider>
|
||||
@@ -669,14 +672,14 @@ describe('standard-kit synthesis', () => {
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
h.add('k.session', { component: () => <b>x</b> })
|
||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
|
||||
(renderSlot) => renderSlot('k.session', {}))
|
||||
renderSlot => renderSlot('k.session', {}))
|
||||
expect(view.container.querySelector('b')).toBeNull()
|
||||
})
|
||||
|
||||
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
|
||||
const h = makeHost()
|
||||
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 = () => {}
|
||||
h.add('k.single', {
|
||||
component: ({ useStore, actions }: {
|
||||
@@ -684,11 +687,11 @@ describe('standard-kit synthesis', () => {
|
||||
actions: { inc: () => void }
|
||||
}) => {
|
||||
bump = actions.inc
|
||||
return <b>{useStore((s) => s.n)}</b>
|
||||
return <b>{useStore(s => s.n)}</b>
|
||||
},
|
||||
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')
|
||||
act(() => { bump() })
|
||||
expect(view.container.textContent).toBe('1')
|
||||
@@ -707,11 +710,11 @@ describe('standard-kit synthesis', () => {
|
||||
actions: { setDraft: (text: string) => void }
|
||||
}) => {
|
||||
setDraft = actions.setDraft
|
||||
return <b>{useStore((s) => s.draft) || '(blank)'}</b>
|
||||
return <b>{useStore(s => s.draft) || '(blank)'}</b>
|
||||
},
|
||||
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>
|
||||
))
|
||||
act(() => { h.current.set('s1') })
|
||||
@@ -730,7 +733,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
const inject = vi.fn(() => ({ tag: 'FROM-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')
|
||||
act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
@@ -745,9 +748,9 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
const inject = vi.fn((sessionId: string) => ({ sid: sessionId }))
|
||||
h.add('k.session', {
|
||||
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>
|
||||
))
|
||||
act(() => { h.current.set('s1') })
|
||||
@@ -767,22 +770,22 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
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 sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
|
||||
const seenRoot: AnyProps[] = []
|
||||
const seenSession: AnyProps[] = []
|
||||
h.add('k.single', {
|
||||
component: (props: object) => { seenRoot.push(props as AnyProps); return null },
|
||||
inject: rootInject as unknown as StoredEntry['inject'],
|
||||
inject: rootInject,
|
||||
store: handle,
|
||||
})
|
||||
h.add('k.session', {
|
||||
component: (props: object) => { seenSession.push(props as AnyProps); return null },
|
||||
inject: sessionInject as unknown as StoredEntry['inject'],
|
||||
inject: sessionInject,
|
||||
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', {})}
|
||||
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
||||
</>)
|
||||
@@ -807,7 +810,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
|
||||
(renderSlot) => <main>{renderSlot('k.list', {})}</main>)
|
||||
renderSlot => <main>{renderSlot('k.list', {})}</main>)
|
||||
spy.mockRestore()
|
||||
// The failing entry blacks out alone; the sibling and the tree above survive.
|
||||
expect(view.container.querySelector('main')).not.toBeNull()
|
||||
@@ -824,7 +827,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
inject: () => ({ fromInject: 'inject', shared: 'inject' }),
|
||||
})
|
||||
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)!
|
||||
expect(typeof props['useSessions']).toBe('function') // kit always present
|
||||
expect(typeof props['useWorkspaces']).toBe('function')
|
||||
|
||||
@@ -43,15 +43,15 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
const host: SlotRendererHost = {
|
||||
subscribe: () => () => {},
|
||||
getVersion: () => 0,
|
||||
entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries,
|
||||
specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
|
||||
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||
isLive: () => true,
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: observable<unknown>({ ids: [] }),
|
||||
current,
|
||||
provideInfo: (id) => infos.get(id),
|
||||
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
|
||||
provideInfo: id => infos.get(id),
|
||||
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
|
||||
},
|
||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||
@@ -78,7 +78,7 @@ describe('SessionProvider', () => {
|
||||
const h = makeHost({
|
||||
root: () => (
|
||||
<SessionProvider empty={() => <span>empty</span>}>
|
||||
{(id) => <div data-testid="body">{id}</div>}
|
||||
{id => <div data-testid="body">{id}</div>}
|
||||
</SessionProvider>
|
||||
),
|
||||
})
|
||||
@@ -93,7 +93,7 @@ describe('SessionProvider', () => {
|
||||
|
||||
it('renders null empty state when the empty prop is omitted', () => {
|
||||
const h = makeHost({
|
||||
root: () => <SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
|
||||
root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
|
||||
})
|
||||
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
expect(view.container.textContent).toBe('')
|
||||
@@ -110,7 +110,7 @@ describe('SessionProvider', () => {
|
||||
return <div>{id}</div>
|
||||
}
|
||||
const h = makeHost({
|
||||
root: () => <SessionProvider>{(id) => <Body id={id} />}</SessionProvider>,
|
||||
root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
|
||||
})
|
||||
h.addSession('s1')
|
||||
h.addSession('s2')
|
||||
@@ -127,7 +127,7 @@ describe('SessionProvider', () => {
|
||||
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
|
||||
const seen: Record<string, unknown>[] = []
|
||||
const h = makeHost({
|
||||
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
||||
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
||||
})
|
||||
h.addSession('s1')
|
||||
h.addSession('s2')
|
||||
@@ -135,7 +135,7 @@ describe('SessionProvider', () => {
|
||||
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
||||
// The bound hook reads the cell's bare source — asserting through it
|
||||
// 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
|
||||
},
|
||||
options: {},
|
||||
@@ -152,7 +152,7 @@ describe('SessionProvider', () => {
|
||||
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(
|
||||
<SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
|
||||
<SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
|
||||
)).toThrow(/outside the installed renderer tree/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -32,10 +32,10 @@ function makeHost() {
|
||||
subs.set(key, set)
|
||||
return () => { set.delete(fn) }
|
||||
},
|
||||
getVersion: (key) => versions.get(key) ?? 0,
|
||||
entriesOf: (key) => entries.get(key) ?? [],
|
||||
getVersion: key => versions.get(key) ?? 0,
|
||||
entriesOf: key => entries.get(key) ?? [],
|
||||
specOf: () => ({ kind: 'single', scope: 'root' }),
|
||||
isLive: (entry) => live.has(entry),
|
||||
isLive: entry => live.has(entry),
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
@@ -54,7 +54,7 @@ function makeHost() {
|
||||
live.add(entry)
|
||||
bump(key)
|
||||
return () => {
|
||||
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
|
||||
entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
|
||||
live.delete(entry)
|
||||
bump(key)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { useInvoke } from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const newProbe = (): Probe => ({ invoke: () => {}, pending: false, renders: 0 })
|
||||
|
||||
describe('useInvoke', () => {
|
||||
it('tracks pending across the action lifecycle', async () => {
|
||||
const d = deferred<void>()
|
||||
const d = deferred()
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => d.promise} probe={probe} />)
|
||||
expect(probe.pending).toBe(false)
|
||||
@@ -39,8 +39,8 @@ describe('useInvoke', () => {
|
||||
})
|
||||
|
||||
it('keeps pending true until the last concurrent call settles', async () => {
|
||||
const d1 = deferred<void>()
|
||||
const d2 = deferred<void>()
|
||||
const d1 = deferred()
|
||||
const d2 = deferred()
|
||||
const queue = [d1, d2]
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => queue.shift()!.promise} probe={probe} />)
|
||||
@@ -68,7 +68,7 @@ describe('useInvoke', () => {
|
||||
|
||||
it('resets pending and logs when the action rejects', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const d = deferred<void>()
|
||||
const d = deferred()
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => d.promise} probe={probe} />)
|
||||
act(() => { probe.invoke() })
|
||||
|
||||
@@ -42,18 +42,18 @@ export function AppRoot(props: AppRootProps) {
|
||||
<div className={css.wordmark}>HARNESS</div>
|
||||
{!loud
|
||||
? (
|
||||
<>
|
||||
<div className={css.spinner} />
|
||||
<div className={css.hint}>Loading plugins…</div>
|
||||
</>
|
||||
)
|
||||
<>
|
||||
<div className={css.spinner} />
|
||||
<div className={css.hint}>Loading plugins…</div>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<div className={css.failed}>
|
||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className={css.failed}>
|
||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { DocumentTitle } from './DocumentTitle.tsx'
|
||||
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
|
||||
@@ -26,7 +25,7 @@ export interface AssemblyDeps {
|
||||
*/
|
||||
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
|
||||
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')
|
||||
const useSessions = bindSnapshotSelector(sessions.list)
|
||||
const SessionDocumentTitle = (): ReactNode => {
|
||||
|
||||
@@ -150,8 +150,8 @@ export class AppWebEntry {
|
||||
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
|
||||
private async prefetchImmediateTier(): Promise<void> {
|
||||
await Promise.all(this.manifest.plugins
|
||||
.filter((row) => row.immediately)
|
||||
.map((row) => this.modules.prefetch(row.id).catch(() => {
|
||||
.filter(row => row.immediately)
|
||||
.map(row => this.modules.prefetch(row.id).catch(() => {
|
||||
// Import refetches and reports this loudly per entry; swallowing
|
||||
// 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
|
||||
// provide lives on the plugin face; see MODULES_ID for why the row loop
|
||||
// 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
|
||||
// activation order); creating concurrently lets non-prefetched bundle
|
||||
// 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]
|
||||
if (state === 'active') continue
|
||||
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'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
|
||||
@@ -46,13 +46,13 @@ export type LoaderStatus = Record<string, LoaderEntryState>
|
||||
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
|
||||
export interface KernelSignal<T> {
|
||||
/** Current value (stable reference between changes). */
|
||||
getSnapshot(): T
|
||||
getSnapshot: () => T
|
||||
/**
|
||||
* Subscribe to changes.
|
||||
* @param fn - change listener.
|
||||
* @returns the unsubscribe disposer.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
|
||||
/** Writable one-value signal (settled flag, boot failure report). */
|
||||
@@ -61,7 +61,7 @@ export interface KernelValueSignal<T> extends KernelSignal<T> {
|
||||
* Publish a new value and notify subscribers.
|
||||
* @param next - the new value.
|
||||
*/
|
||||
set(next: T): void
|
||||
set: (next: T) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +90,7 @@ export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
|
||||
* @param id - entry name.
|
||||
* @param state - projected fiber state.
|
||||
*/
|
||||
set(id: string, state: LoaderEntryState): void
|
||||
set: (id: string, state: LoaderEntryState) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user