Merge remote-tracking branch 'origin/master' into mergebot/pr883
# Conflicts: # packages/client/tsdown.client.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/PendingCard.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.tsx # packages/client/ui-conversation/src/client/toolviews/todo-row.module.css # packages/client/ui-conversation/src/client/toolviews/todo-row.tsx # packages/client/ui-conversation/tests/chat-apply.spec.tsx # packages/client/ui-conversation/tests/coverage-tails.spec.tsx # packages/client/ui-conversation/tests/todo-panel.spec.tsx # packages/client/ui-primitives/src/icons/index.tsx # packages/client/ui-primitives/tests/icons.spec.tsx # packages/client/ui-question/README.i18n.yaml # packages/client/ui-question/src/client/QuestionComposer.module.css # packages/client/ui-question/src/client/QuestionComposer.tsx
This commit is contained in:
@@ -1,143 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply inject factories exercised end to end against the terminal thin
|
||||
// shape: the conversation surface (views triple, send choreography incl.
|
||||
// optimistic clear + failure restore THROUGH the declared store actions,
|
||||
// openDetails = select action + layout orchestration, sessions.open
|
||||
// navigation), and the closeDetails details surface. Complements
|
||||
// chat-apply.spec.tsx (registration)
|
||||
// and selection-survival.spec.ts (store axis). History opening is NOT an
|
||||
// inject concern anymore — the runtime sessions service opens on watch
|
||||
// (sessions-service.spec.ts owns that behavior).
|
||||
// shape: the strict session surface (views triple, draft mirror), the
|
||||
// provide-channel input face (machine-sink submit choreography incl.
|
||||
// optimistic clear + failure restore), the resident surface (selectWorkspace
|
||||
// draft carrying), the composer-bar stop face, openDetails = select action +
|
||||
// layout orchestration, and the closeDetails details surface. Complements
|
||||
// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
|
||||
// axis). History opening is NOT an inject concern — the runtime sessions
|
||||
// service opens on watch (sessions-service.spec.ts owns that behavior).
|
||||
//
|
||||
// The inject surfaces are read off the ledger entries deliberately (typed at
|
||||
// this spec's own contract): these cases pin factory choreography the UI
|
||||
// guards would mask. Rendering-path acceptance lives in
|
||||
// chat-toolview-slot.spec.tsx.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
|
||||
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
type ChatActions = ChatInstance['actions']
|
||||
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | 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)
|
||||
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
|
||||
})()
|
||||
/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */
|
||||
function sessionFakeFor() {
|
||||
return {
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
|
||||
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
} satisfies SessionBehaviorOverrides
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
|
||||
current: ROOT,
|
||||
phase: 'ready',
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const sessionFake = sessionFakeFor()
|
||||
await runtime.sessions.add({
|
||||
id: ROOT,
|
||||
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
|
||||
session: sessionFake,
|
||||
})
|
||||
const sessionFake = {
|
||||
sessionId: ROOT,
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn(() => Promise.resolve()),
|
||||
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
// Observable face (the input machine's queue read face rides it).
|
||||
getSnapshot: () => ({ queue: [] }),
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
if (scoped === undefined) {
|
||||
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>
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
|
||||
scope: (id: SessionId) => mint(id),
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
|
||||
scopeOf,
|
||||
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
|
||||
open: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
const workspaceStore = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const workspacesFake = {
|
||||
list: workspaceStore,
|
||||
connectWorkspace: vi.fn(async () => ROOT),
|
||||
}
|
||||
ctx.provide('workspaces', workspacesFake)
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('layout', layoutFake)
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
runtime.provide('layout', layoutFake)
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
|
||||
// The AppFrame role: the three conversation-package slots must be declared
|
||||
// by a live entry before apply can contribute into them (the stand-in
|
||||
// consumes renderSlot to satisfy the declare-means-render check).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
// The AppFrame role: the conversation-package slots must be declared by a
|
||||
// live entry before apply can contribute into them.
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
|
||||
// Reach the render-side entry view (inject + store handle) the way the
|
||||
// renderer does: through the host face.
|
||||
let host: SlotRendererHost | undefined
|
||||
slots.install({ renderRoot: (h) => { host = h; return null } })
|
||||
slots.renderSlot('root', {})
|
||||
const hostFace = host!
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]!
|
||||
// The host face (store resolution) exists only inside the installed
|
||||
// renderer, so materialize it the way the shell does.
|
||||
runtime.renderRoot()
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
||||
runtime.slots.entries(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session')
|
||||
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
||||
const instance = runtime.storeOf('conversation.session', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
@@ -153,27 +85,28 @@ async function bench() {
|
||||
/** Same resolution for the chat entry riding the view ring. */
|
||||
const chatViewSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.view')
|
||||
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
||||
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
/** Materialize the input provide contribution the way the runtime does. */
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
||||
const state = contribution.hooks!['input'] as {
|
||||
const info = runtime.sessions.provideInfo(id)!
|
||||
const state = info.hooks['input'] as {
|
||||
getSnapshot: () => { draft: string }
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
const actions = contribution.props!['inputActions'] as {
|
||||
const actions = info.props['inputActions'] as {
|
||||
setDraft: (text: string) => void
|
||||
submit: (mode?: 'queue' | 'steer') => void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
return {
|
||||
ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
|
||||
runtime, feature, slots: runtime.slots, entryOf,
|
||||
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
sessionFake, layoutFake,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +122,7 @@ describe('conversation slot inject surface', () => {
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
|
||||
@@ -207,14 +141,14 @@ describe('conversation slot inject surface', () => {
|
||||
await Promise.resolve()
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
// Failure: restored (draft still empty when the rejection lands).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
|
||||
actions.setDraft('retry me')
|
||||
actions.submit('queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(state.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
// Failure landing after new typing: no clobber (restore fills empty only).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
|
||||
actions.submit('queue')
|
||||
actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
@@ -228,23 +162,25 @@ describe('conversation slot inject surface', () => {
|
||||
expect(mirrored).toEqual(['mirrored text'])
|
||||
unbind()
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
||||
b.composerSurface(ROOT).stop()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
|
||||
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.composer.bar')
|
||||
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/)
|
||||
// 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/)
|
||||
// Unknown session: the keyboard face's binding resolution answers nothing.
|
||||
expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
|
||||
// A scope whose service tree lost 'conversation' (the feature fiber
|
||||
// unloaded while a retained inject closure re-runs): fails loud too.
|
||||
const stop = injectFn(ROOT).stop
|
||||
await b.feature.dispose()
|
||||
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
@@ -257,6 +193,17 @@ describe('conversation slot inject surface', () => {
|
||||
// writes land where the skeleton and details read.
|
||||
const conv = b.conversationSurface(ROOT)
|
||||
expect(conv.instance).toBe(instance)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewSurface(ROOT)
|
||||
injected.openFile('src/a.ts')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
|
||||
@@ -264,23 +211,73 @@ describe('conversation slot inject surface', () => {
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const resident = b.residentSurface(ROOT)
|
||||
injected.open(ROOT)
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
// Same-session connect (the picked workspace resolves to this session):
|
||||
// no draft movement, plain re-open.
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
||||
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2)
|
||||
})
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
|
||||
expect(state.getSnapshot().draft).toBe('carry me')
|
||||
// Cross-session connect: the draft MOVES — the old machine empties, the
|
||||
// new session's machine receives the text, then navigation lands there.
|
||||
const OTHER = 'other-1' as SessionId
|
||||
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
||||
await b.runtime.sessions.add({ id: OTHER }, { current: false })
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
|
||||
void resident.selectWorkspace('workspace-2' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
||||
})
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => {
|
||||
const b = await bench()
|
||||
// No-session resident (hero before any session): connect resolves and
|
||||
// navigation proceeds without any draft choreography.
|
||||
const noSession = b.residentSurface(undefined)
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
void noSession.selectWorkspace('workspace-0' as never)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
})
|
||||
|
||||
// Cross-session connect with an EMPTY draft: no move, no clearing.
|
||||
const OTHER = 'b9-other' as SessionId
|
||||
await b.runtime.sessions.add({ id: OTHER }, { current: false })
|
||||
const resident = b.residentSurface(ROOT)
|
||||
const { state } = b.inputSurface(ROOT)
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
|
||||
void resident.selectWorkspace('workspace-3' as never)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
||||
})
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('')
|
||||
|
||||
// Connect failure: the rejection propagates to the caller (the view owns
|
||||
// the rollback) and no further navigation happens.
|
||||
const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline')))
|
||||
await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline')
|
||||
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('scopedConversation fails loud when the session resolves no scope', async () => {
|
||||
const b = await bench()
|
||||
// The chat-view inject resolves the scoped conversation service at inject
|
||||
// time: an unlisted session hits the scope() === undefined throw directly.
|
||||
const entry = b.entryOf('conversation.view')
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown
|
||||
expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
||||
@@ -303,6 +300,7 @@ describe('conversation slot inject surface', () => {
|
||||
off()
|
||||
off2()
|
||||
unsub()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -315,9 +313,9 @@ describe('details inject surface', () => {
|
||||
injected.closeDetails()
|
||||
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
|
||||
// The shared handle: details resolves the SAME instance conversation writes.
|
||||
const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT)
|
||||
const details = b.hostFace.storeOf(entry, ROOT)
|
||||
const conv = b.runtime.storeOf('conversation.session', ROOT)
|
||||
const details = b.runtime.storeOf('details', ROOT)
|
||||
expect(details).toBe(conv)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -28,10 +28,10 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
|
||||
const runningCall = (argsRaw: string) =>
|
||||
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
|
||||
|
||||
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
|
||||
function rowProps(block: unknown): ToolRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'ask_user_question', block,
|
||||
openDetails,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
@@ -113,11 +113,10 @@ describe('AskQuestionRow', () => {
|
||||
expect(screen.getByText('ask_user_question · c1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('row click opens details', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([])), openDetails)} />)
|
||||
fireEvent.click(screen.getByText('Ask question'))
|
||||
expect(openDetails).toHaveBeenCalledTimes(1)
|
||||
it('leading toggle expands the raw args body', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([])))} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
|
||||
@@ -1,88 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply wiring: the conversation service provided, the chat view registered
|
||||
// as the first 'conversation.view' ring entry declaring the keyed toolview
|
||||
// hole, the three slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all session
|
||||
// entries, and the bash sample mounts through the load-order seam as a keyed
|
||||
// entry. Full-chain rendering belongs to the machinery spec
|
||||
// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the
|
||||
// assembly surface.
|
||||
// hole, the slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all strict
|
||||
// session entries, and the bash sample + todo row mount through the
|
||||
// load-order seam as keyed entries. Full-chain rendering belongs to the
|
||||
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
|
||||
// stops at the assembly surface.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const CHILD = 'child-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
|
||||
await runtime.sessions.add(
|
||||
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
|
||||
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
scope: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
provide: vi.fn(() => () => {}),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
ctx.provide('workspaces', {
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
// Declared by ui-layout's root entry in production; a stand-in root
|
||||
// occupant declares them here so the contributions land (it consumes
|
||||
// renderSlot to satisfy the declare-means-render check).
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
// Declared by ui-layout's root entry in production; the test root declares
|
||||
// them here so the contributions land.
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
return { ctx, fiber, slots }
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
return { runtime, feature, slots: runtime.slots }
|
||||
}
|
||||
|
||||
/** First stored entry for a key (inject/store live directly on StoredEntry). */
|
||||
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
|
||||
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
|
||||
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
|
||||
}
|
||||
|
||||
describe('apply wiring', () => {
|
||||
it('provides the conversation service', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.ctx.get('conversation')).toBeDefined()
|
||||
expect(b.runtime.ctx.get('conversation')).toBeDefined()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
|
||||
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[0]?.options.label).toBe('Chat')
|
||||
@@ -90,11 +57,11 @@ describe('apply wiring', () => {
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('occupies the slots + the ring; session entries share one store handle', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const conversation = renderEntryOf(b.slots, 'conversation')
|
||||
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
|
||||
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
||||
@@ -110,21 +77,23 @@ describe('apply wiring', () => {
|
||||
// The hero workspace picker hole rides the conversation entry's children
|
||||
// declaration (the empty-state occupant is gone).
|
||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
// Every registrant plugin's 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', 'ask_user_question'])
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
await b.fiber.dispose()
|
||||
await b.feature.dispose()
|
||||
expect(b.slots.entries('conversation')).toHaveLength(0)
|
||||
// The declared ring collapses with its declaring entry, and the chat
|
||||
// entry's keyed hole (with the sample's registration) collapses with it.
|
||||
@@ -132,6 +101,7 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.ctx.get('conversation')).toBeUndefined()
|
||||
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
|
||||
// user IconActions, StatsLine no-cache join,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
// renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import {
|
||||
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
|
||||
} from '../src/client/chat/message-chrome.ts'
|
||||
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
|
||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
|
||||
it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
} as never}
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
|
||||
@@ -51,9 +55,10 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||||
} as never}
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
@@ -73,9 +78,10 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'quiet' }] as never,
|
||||
} as never}
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
@@ -113,14 +119,57 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('small branch tails', () => {
|
||||
it('PendingCard approval reason renders when present', () => {
|
||||
const view = render(
|
||||
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
|
||||
)
|
||||
expect(view.getByText('careful')).toBeTruthy()
|
||||
describe('formatMessageClock', () => {
|
||||
const now = new Date(2026, 6, 29, 10, 0).getTime()
|
||||
|
||||
it('keeps HH:mm on the same calendar day', () => {
|
||||
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24')
|
||||
})
|
||||
|
||||
it('prefixes month and day across days in the same year', () => {
|
||||
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24')
|
||||
})
|
||||
|
||||
it('prefixes year, month, and day across years', () => {
|
||||
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05')
|
||||
})
|
||||
|
||||
it('arms the next local midnight from an in-day instant', () => {
|
||||
const noon = new Date(2026, 6, 29, 12, 0).getTime()
|
||||
expect(startOfLocalDay(noon)).toBe(new Date(2026, 6, 29).getTime())
|
||||
expect(msUntilNextLocalMidnight(noon)).toBe(12 * 3_600_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCalendarDay boundary refresh', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('widens a same-day user clock after local midnight', () => {
|
||||
const dayStart = new Date(2026, 6, 29, 23, 50).getTime()
|
||||
vi.setSystemTime(dayStart)
|
||||
const time = new Date(2026, 6, 29, 14, 24).getTime()
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'night bubble' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(msUntilNextLocalMidnight(dayStart) + 1)
|
||||
})
|
||||
expect(screen.getByText('7月29日 14:24')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('small branch tails', () => {
|
||||
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
|
||||
@@ -128,6 +177,35 @@ describe('small branch tails', () => {
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const settled = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
/>,
|
||||
)
|
||||
expect(settled.getByText('14:24')).toBeTruthy()
|
||||
expect(settled.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
fireEvent.click(settled.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||
settled.unmount()
|
||||
|
||||
const streaming = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
)
|
||||
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// cacheHitPct is null only when input+cacheRead are both zero (pure
|
||||
// output accounting) — any input makes it a real 0%.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// always-visible nested rows through the SAME keyed toolview hole — the bash
|
||||
// sub-call lands in the bash sample plugin's registration exactly like a
|
||||
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
|
||||
// and a sub-row click opens details for the sub-callId. Running parents
|
||||
// and a file sub-row click opens the host path. Running parents
|
||||
// (runningCalls) nest their so-far dispatches the same way.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
@@ -56,7 +57,7 @@ function snapshotWith(
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
@@ -78,7 +79,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
@@ -114,16 +115,18 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
open: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
ctx.provide('workspaces', {
|
||||
const workspaces = {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
@@ -136,7 +139,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, layout }
|
||||
return { ctx, slots, fiber, session, layout, workspaces }
|
||||
}
|
||||
|
||||
function mountApp(slots: SlotsService) {
|
||||
@@ -220,15 +223,21 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(nested).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a sub-row click opens details for the sub-callId', async () => {
|
||||
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
|
||||
const parent = 'call-64'
|
||||
const dispatches = new Map([[parent, [
|
||||
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
|
||||
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('notes/demo.txt').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
|
||||
})
|
||||
view.getByText('List notes').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
|
||||
// StatsLine (composer.dock entry): totals derivation + the RFC
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: the
|
||||
// canonical sub-agent differential decided INSIDE the component off the
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -27,7 +27,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -123,8 +123,8 @@ describe('bash sample row', () => {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, blank: false, updatedAt: 0 },
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
@@ -133,10 +133,9 @@ describe('bash sample row', () => {
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
store?: ReturnType<typeof listStore>
|
||||
openDetails?: () => void
|
||||
}): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openDetails: over?.openDetails ?? vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
sessionId,
|
||||
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
|
||||
} as unknown as ToolRowProps)
|
||||
@@ -159,7 +158,7 @@ describe('bash sample row', () => {
|
||||
const orphan = 'late-child' as SessionId
|
||||
store.update((d) => {
|
||||
d.ids.push(orphan)
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, blank: false, updatedAt: 0 }
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, blank: false, updatedAt: 0 }
|
||||
})
|
||||
const view = render(<BashRow {...rowProps(orphan, { store })} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
@@ -169,21 +168,17 @@ describe('bash sample row', () => {
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
|
||||
const openGlobal = vi.fn()
|
||||
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
|
||||
it('summarizes as Bash · description on both arms without row click targets', () => {
|
||||
const global = render(<BashRow {...rowProps(ROOT)} />)
|
||||
// Two renders share document.body: query inside each container.
|
||||
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
|
||||
expect(globalRow.textContent).toContain('Bash')
|
||||
expect(globalRow.textContent).toContain('Build')
|
||||
fireEvent.click(globalRow)
|
||||
expect(openGlobal).toHaveBeenCalledTimes(1)
|
||||
const openScoped = vi.fn()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
|
||||
expect(globalRow.getAttribute('data-clickable')).toBeNull()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD)} />)
|
||||
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
|
||||
expect(scopedRow.textContent).toContain('Bash')
|
||||
expect(scopedRow.textContent).toContain('Build')
|
||||
fireEvent.click(scopedRow)
|
||||
expect(openScoped).toHaveBeenCalledTimes(1)
|
||||
expect(scopedRow.getAttribute('data-clickable')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
@@ -64,6 +64,22 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
|
||||
})
|
||||
|
||||
it('exposes filePath for path/file_path args and skips URL-only reads', () => {
|
||||
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
|
||||
.toBeUndefined()
|
||||
expect(toolRowModel('bash', running()).filePath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolveToolPath joins relative paths under cwd and passes absolute through', () => {
|
||||
expect(resolveToolPath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
|
||||
expect(resolveToolPath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
|
||||
expect(resolveToolPath(undefined, 'src/a.ts')).toBe('src/a.ts')
|
||||
expect(resolveToolPath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
|
||||
})
|
||||
|
||||
it('displays workspace-rooted paths relative to the session cwd', () => {
|
||||
const cwd = '/Users/u/ws/'
|
||||
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
|
||||
@@ -81,6 +97,11 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
|
||||
it('a code row with an empty program falls back to the args JSON envelope', () => {
|
||||
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
@@ -149,13 +170,50 @@ describe('ToolRow', () => {
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
|
||||
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('a non-expandable expandOnRowClick row exposes no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
|
||||
)
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(open).toHaveBeenCalledWith('src/a.ts')
|
||||
// Only the path link is a button — no args-expand affordance on file rows.
|
||||
expect(view.container.querySelectorAll('button')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
})
|
||||
|
||||
it('a single-file path disables expand even when onOpenFile is absent', () => {
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
|
||||
)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
fireEvent.click(view.getByText('作文.md'))
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
})
|
||||
|
||||
it('non-file rows do not open anything when the summary is clicked', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenFile={open} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -180,7 +238,7 @@ describe('ThinkRow', () => {
|
||||
|
||||
describe('GenericToolCard', () => {
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName, block, openDetails: vi.fn(),
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(),
|
||||
})
|
||||
|
||||
it('renders the classified variant row from the frozen slice', () => {
|
||||
@@ -225,10 +283,15 @@ describe('GenericToolCard', () => {
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click reaches openDetails', () => {
|
||||
const p = props('bash', result())
|
||||
const view = render(<GenericToolCard {...p} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(p.openDetails).toHaveBeenCalledTimes(1)
|
||||
it('file-path summary click reaches openFile; bash summary does not', () => {
|
||||
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
|
||||
const fileView = render(<GenericToolCard {...file} />)
|
||||
fireEvent.click(fileView.getByText('src/x.ts'))
|
||||
expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
|
||||
|
||||
const bash = props('bash', result())
|
||||
const bashView = render(<GenericToolCard {...bash} />)
|
||||
fireEvent.click(bashView.getByText('List files'))
|
||||
expect(bash.openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
|
||||
// cordis Context + SlotsService ledger + the web-react renderer + this
|
||||
// package's own apply — no outlet twins. Proves the keyed
|
||||
// SlotTestRuntime (cordis Context + SlotsService ledger + the web-react
|
||||
// renderer) + this package's own apply — no outlet twins. Proves the keyed
|
||||
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
|
||||
// entryKey (the bash sample lands through its plugin), unregistered tools
|
||||
// fall back to GenericToolCard at the render site, live registration/unload
|
||||
@@ -10,23 +10,17 @@
|
||||
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
|
||||
// semantics until the service (and with it the hole declaration) is present.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { cleanup, fireEvent } from '@testing-library/react'
|
||||
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
|
||||
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear between cases.
|
||||
beforeEach(() => {
|
||||
@@ -40,115 +34,39 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
return {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
|
||||
function AppRoot({ renderSlot }: AppRootProps) {
|
||||
return <>{renderSlot('conversation', {})}</>
|
||||
}
|
||||
|
||||
const LAYOUT_CHILDREN = {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
|
||||
* fakes at the service seams only (external boundaries), the package apply on
|
||||
* its own fiber, and the test AppFrame occupying 'root'.
|
||||
* Real-stack bench: SlotTestRuntime with the session/layout doubles at the
|
||||
* service seams only (external boundaries), the package apply on its own
|
||||
* fiber, and the test AppFrame occupying 'root'.
|
||||
*/
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
// Identity-stable provide bundle: the renderer caches hooks per source and
|
||||
// inject results per bundle, both by object identity. Registered providers
|
||||
// (the package's input contribution) materialize into it lazily, once.
|
||||
const providers: ((binding: object) => { hooks?: object; props?: object })[] = []
|
||||
let info: { sessionId: SessionId; hooks: object; props: object } | undefined
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} }
|
||||
const bindingOf = (id: SessionId) => ({
|
||||
sessionId: id,
|
||||
ctx: actxFake,
|
||||
runtime.provide('layout', layout)
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S' },
|
||||
snapshot: { nodes },
|
||||
session: {
|
||||
sessionId: id,
|
||||
loadOlder: vi.fn(),
|
||||
prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })),
|
||||
// Observable face for the input machine's queue read face.
|
||||
getSnapshot: () => session.getSnapshot(),
|
||||
subscribe: (fn: () => void) => session.subscribe(fn),
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
},
|
||||
})
|
||||
const provideInfo = (id: string) => {
|
||||
if (id !== SID) return undefined
|
||||
if (info === undefined) {
|
||||
const hooks: Record<string, unknown> = { session }
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const provider of providers) {
|
||||
const c = provider(bindingOf(SID))
|
||||
Object.assign(hooks, c.hooks ?? {})
|
||||
Object.assign(props, c.props ?? {})
|
||||
}
|
||||
info = { sessionId: SID, hooks, props }
|
||||
}
|
||||
return info
|
||||
}
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
binding: bindingOf,
|
||||
scope: () => actxFake,
|
||||
provideInfo,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => provideInfo(SID),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
scopeOf: () => SID,
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, list, layout }
|
||||
}
|
||||
|
||||
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
|
||||
function mountApp(slots: SlotsService) {
|
||||
return render(<>{slots.renderSlot('root', {})}</>)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
return { runtime, slots: runtime.slots, feature, layout }
|
||||
}
|
||||
|
||||
describe('keyed toolview hole through the real machinery', () => {
|
||||
@@ -157,7 +75,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
toolResult(3, 'c1', 'bash'),
|
||||
toolResult(4, 'c2', 'mystery', '{"n":1}'),
|
||||
])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
// bash: the sample plugin's keyed registration took the row (root
|
||||
// session → global arm, decided inside the component off useSessions).
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
@@ -165,6 +83,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
expect(view.getByText('Build')).toBeTruthy()
|
||||
// mystery: no registration under that key → render-site fallback.
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
|
||||
@@ -174,7 +93,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
|
||||
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
|
||||
])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
|
||||
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
|
||||
const mounted = view.container.querySelector('[data-variant="code"]')
|
||||
@@ -184,31 +103,46 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
|
||||
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
|
||||
const view = b.runtime.renderRoot()
|
||||
view.getByText('src/a.ts').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('bash summary clicks do not open details or host paths', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'bash')])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
view.getByText('Build').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
|
||||
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
let dispose = (): void => {}
|
||||
await act(async () => {
|
||||
dispose = b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'mystery' },
|
||||
() => <div data-testid="mystery-row" />)
|
||||
})
|
||||
dispose = b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'mystery' },
|
||||
() => <div data-testid="mystery-row" />)
|
||||
await b.runtime.flush()
|
||||
// Per-key version tick: the row flipped without a remount of the view.
|
||||
expect(view.getByTestId('mystery-row')).toBeTruthy()
|
||||
expect(view.queryByText('Tool call')).toBeNull()
|
||||
await act(async () => { dispose() })
|
||||
dispose()
|
||||
await b.runtime.flush()
|
||||
expect(view.queryByTestId('mystery-row')).toBeNull()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('a duplicate key registration fails loud at load', async () => {
|
||||
@@ -217,6 +151,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
{ name: 'conversation.chat.toolview', key: 'bash' },
|
||||
() => null,
|
||||
)).toThrow(/key "bash"/)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('the inject channel feeds (sessionId) => I into the row component', async () => {
|
||||
@@ -234,65 +169,34 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
|
||||
<button data-testid="probe-row" onClick={poke}>{mark}</button>
|
||||
))
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
const row = view.getByTestId('probe-row')
|
||||
expect(row.textContent).toBe(`for:${SID}`)
|
||||
row.click()
|
||||
expect(poked).toEqual([SID])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}),
|
||||
binding: () => undefined,
|
||||
scope: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, AppRoot)
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
// semantics hold it — apply must not run while 'conversation' is absent.
|
||||
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
|
||||
// fiber's isConstructor branch.)
|
||||
// Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount()
|
||||
// would fail loud on the missing service). (Plain arrow, not vi.fn: mock
|
||||
// functions carry a prototype and trip the fiber's isConstructor branch.)
|
||||
let applyRuns = 0
|
||||
const registrantApply = (registrantCtx: Context): void => {
|
||||
const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
|
||||
applyRuns += 1
|
||||
registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
|
||||
}
|
||||
const late = ctx.plugin({
|
||||
const late = runtime.ctx.plugin({
|
||||
name: 'late-registrant',
|
||||
inject: ['slots', 'conversation'],
|
||||
apply: registrantApply,
|
||||
@@ -303,11 +207,11 @@ describe('registrant load-order seam', () => {
|
||||
// Mounting the package resolves the seam: service present ⟹ the chat
|
||||
// entry (and its hole declaration) is already on the ledger, so the
|
||||
// suspended registrant lands without an undeclared-slot throw.
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
await late.await()
|
||||
expect(applyRuns).toBe(1)
|
||||
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
.toEqual(expect.arrayContaining(['bash', 'late']))
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ 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,
|
||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
@@ -30,7 +30,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,11 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
|
||||
const user = (seq: number, text: string): UserMessageNode => ({
|
||||
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
|
||||
kind: 'user',
|
||||
seq,
|
||||
time: seq * 1000,
|
||||
content: [{ type: 'text', text }] as never,
|
||||
source: null,
|
||||
})
|
||||
const assistant = (seq: number, text: string): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
|
||||
@@ -88,6 +92,7 @@ function emptyWorkspaces() {
|
||||
function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const { set, source } = makeSource(init)
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
@@ -105,6 +110,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined),
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: { setDraft: () => {}, submit: () => {} },
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
@@ -112,10 +118,11 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
renderSlot,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -281,16 +288,31 @@ describe('ChatView', () => {
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
|
||||
it('clicking a bash summary does not open details; selection still marks data-selected', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
||||
expect(h.openDetails).not.toHaveBeenCalled()
|
||||
expect(h.openFile).not.toHaveBeenCalled()
|
||||
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
||||
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||||
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clicking a file-tool path summary opens the host file, not details', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
|
||||
call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
|
||||
callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
|
||||
}],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
|
||||
expect(h.openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('running calls render as a live tool group with the running state', () => {
|
||||
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -349,6 +371,42 @@ describe('ChatView', () => {
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
|
||||
// re-render from setAtBottom must not force scrollTop to scrollHeight.
|
||||
scroller.scrollTop = 690 // distance-to-bottom = 10
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(scroller.scrollTop).toBe(690)
|
||||
})
|
||||
|
||||
it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
// Open jump uses the host, not the local .scroll node.
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('paging button loads older and shows its busy label', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -370,12 +428,54 @@ describe('ChatView', () => {
|
||||
expect(lv.getByText('载入历史…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pending interactions render placeholder cards', () => {
|
||||
it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {
|
||||
const h = makeHarness({
|
||||
pending: [new PendingWait('approval', RpcId('r1'), SID,
|
||||
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
|
||||
pending: [
|
||||
new PendingWait('approval', RpcId('r1'), SID,
|
||||
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()),
|
||||
new PendingWait('question', RpcId('r2'), SID,
|
||||
{ questions: [{ id: 'q1', question: '选择' }] }, vi.fn()),
|
||||
],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText(/等待审批/)).toBeTruthy()
|
||||
expect(view.queryByText(/等待回答/)).toBeNull()
|
||||
expect(view.queryByText(/等待审批/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
|
||||
const command = (over: Partial<CommandNode>): CommandNode => ({
|
||||
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
|
||||
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
...over,
|
||||
})
|
||||
// Settled success: the command line is the title, the outcome text the summary.
|
||||
const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
|
||||
const view = render(<settled.ChatView {...settled.props} />)
|
||||
expect(view.getByText('/plan')).toBeTruthy()
|
||||
expect(view.getByText('已进入 plan mode')).toBeTruthy()
|
||||
|
||||
// Error outcome flips the row state; a text-less error gets the default copy.
|
||||
const failed = makeHarness({
|
||||
nodes: [command({ seq: 6, commandId: 'cmd-2' as CommandNode['commandId'], outcome: { kind: 'error' } })],
|
||||
})
|
||||
const fv = render(<failed.ChatView {...failed.props} />)
|
||||
expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(fv.getByText('命令失败')).toBeTruthy()
|
||||
|
||||
// Still executing: running state with the executing copy.
|
||||
const executing = makeHarness({
|
||||
nodes: [command({ seq: 7, commandId: 'cmd-3' as CommandNode['commandId'], outcome: null })],
|
||||
})
|
||||
const xv = render(<executing.ChatView {...executing.props} />)
|
||||
expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(xv.getByText('执行中…')).toBeTruthy()
|
||||
|
||||
// Cross-window soft-fall (run page truncated): generic title, outcome preserved.
|
||||
const orphan = makeHarness({
|
||||
nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })],
|
||||
})
|
||||
const ov = render(<orphan.ChatView {...orphan.props} />)
|
||||
expect(ov.getByText('命令')).toBeTruthy()
|
||||
expect(ov.getByText('已完成')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('tails', () => {
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolRowOwnerProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
@@ -84,12 +84,12 @@ describe('tails', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ describe('render branch tails', () => {
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
@@ -111,6 +112,7 @@ describe('render branch tails', () => {
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
|
||||
@@ -21,7 +21,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
@@ -30,15 +30,19 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
|
||||
|
||||
interface BenchOptions {
|
||||
planEntry?: React.ReactNode
|
||||
/** The `plan` projection value the standard-kit useProjection serves. */
|
||||
plan?: { active: boolean; pending: boolean }
|
||||
modelEntry?: React.ReactNode
|
||||
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
|
||||
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
|
||||
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
|
||||
draft?: string
|
||||
running?: boolean
|
||||
disabled?: boolean
|
||||
promptError?: ConversationSnapshot['promptError']
|
||||
variant?: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
translateHint?: (key: string) => string
|
||||
accessory?: React.ReactNode
|
||||
overlay?: React.ReactNode
|
||||
leftItems?: React.ReactNode
|
||||
@@ -88,12 +92,20 @@ function bench(over?: BenchOptions) {
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
||||
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
stop,
|
||||
command: () => Promise.resolve(true),
|
||||
// Mirrors the en 'command.hint' locale entries the production apply wires in.
|
||||
translateHint: over?.translateHint ?? ((key: string) => ({
|
||||
'placeholder.default': 'Message the agent',
|
||||
'placeholder.plan': 'describe your task to generate plan',
|
||||
} as Record<string, string>)[key] ?? key),
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
|
||||
@@ -221,6 +233,56 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
|
||||
expect(wheeled).toBe(false) // preventDefault
|
||||
expect(host.scrollTop).toBe(70)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
|
||||
let scrollTop = 150
|
||||
Object.defineProperty(textarea, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
})
|
||||
try {
|
||||
// Mid-draft: both directions stay local — host must not move.
|
||||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
|
||||
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
|
||||
expect(host.scrollTop).toBe(40)
|
||||
// At the bottom edge, further down-scroll forwards to the host.
|
||||
scrollTop = 300
|
||||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
|
||||
expect(host.scrollTop).toBe(70)
|
||||
// At the top edge, further up-scroll forwards to the host.
|
||||
scrollTop = 0
|
||||
host.scrollTop = 70
|
||||
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
|
||||
expect(host.scrollTop).toBe(50)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
const { textarea } = bench({ disabled: true })
|
||||
expect(textarea.placeholder).toBe('Session unavailable')
|
||||
@@ -229,6 +291,20 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
const custom = bench({ placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
|
||||
const active = bench({ plan: { active: true, pending: false } })
|
||||
expect(active.textarea.placeholder).toBe('describe your task to generate plan')
|
||||
// /plan just ran: pending entry already reads as the plan target.
|
||||
const entering = bench({ plan: { active: false, pending: true } })
|
||||
expect(entering.textarea.placeholder).toBe('describe your task to generate plan')
|
||||
// Pending exit: target is default again.
|
||||
const leaving = bench({ plan: { active: true, pending: true } })
|
||||
expect(leaving.textarea.placeholder).toBe('Message the agent')
|
||||
// Owner placeholder outranks the plan swap.
|
||||
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('machine pending lock', () => {
|
||||
@@ -249,7 +325,6 @@ describe('machine pending lock', () => {
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
expect(textarea.readOnly).toBe(true)
|
||||
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
|
||||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -273,6 +348,19 @@ describe('decorations', () => {
|
||||
expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
|
||||
const dict: Record<string, string> = { goal: '输入目标,智能体将持续执行' }
|
||||
const { view, shell } = bench({ translateHint: key => dict[key] ?? key })
|
||||
act(() => {
|
||||
shell.setDraft('/goal ')
|
||||
shell.beginCommand(
|
||||
{ token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
||||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||||
)
|
||||
})
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
||||
})
|
||||
|
||||
it('an inserted reference renders as a chip at its placeholder offset', () => {
|
||||
const { view, shell } = bench()
|
||||
act(() => {
|
||||
@@ -351,16 +439,41 @@ describe('strips and variants', () => {
|
||||
})
|
||||
|
||||
describe('placeholder chrome and control seats', () => {
|
||||
it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
expect(view.getByLabelText('Add attachment')).toBeTruthy()
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
|
||||
// Capability absent (no projection value): the chip renders nothing.
|
||||
expect(view.queryByLabelText(/^Access mode/)).toBeNull()
|
||||
// Both seats dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
|
||||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||||
expect(view.queryByLabelText('Model')).toBeNull()
|
||||
})
|
||||
|
||||
it('the Access chip renders the projection value and submits /permission on pick', async () => {
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view } = bench({ permissions })
|
||||
const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Workspace Write')
|
||||
fireEvent.click(trigger)
|
||||
const items = view.getAllByRole('menuitem')
|
||||
expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
|
||||
fireEvent.click(items[1]!)
|
||||
// Optimistic pick + disable until admission resolves (command stub resolves true).
|
||||
const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
|
||||
expect(busy.textContent).toBe('Danger Full Access')
|
||||
expect(busy.disabled).toBe(true)
|
||||
await act(async () => {})
|
||||
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||||
const { view, slotCalls } = bench({
|
||||
disabled: true,
|
||||
@@ -376,12 +489,13 @@ describe('placeholder chrome and control seats', () => {
|
||||
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)', () => {
|
||||
const { view } = bench({ disabled: true })
|
||||
it('disabled locks the Access chip and attach control (running does not)', () => {
|
||||
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
|
||||
const { view } = bench({ disabled: true, permissions })
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true)
|
||||
cleanup()
|
||||
const live = bench({ running: true })
|
||||
expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
|
||||
const live = bench({ running: true, permissions })
|
||||
expect((live.view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -260,10 +260,10 @@ describe('input-machine: insert-ref and the occurrence table', () => {
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
|
||||
m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) })
|
||||
expect(m.state.draft).toBe(`${P} and ${P}`)
|
||||
expect(m.state.draft).toBe(`${P} and ${P} `)
|
||||
expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
|
||||
// Delete the first chip whole; the second survives with its own identity.
|
||||
m.dispatch({ type: 'draft-changed', draft: ` and ${P}`, editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
m.dispatch({ type: 'draft-changed', draft: ` and ${P} `, editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
|
||||
})
|
||||
|
||||
@@ -273,7 +273,7 @@ describe('input-machine: insert-ref and the occurrence table', () => {
|
||||
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
|
||||
expect(m.state.draft).toBe(`/goal ask ${P}`)
|
||||
expect(m.state.draft).toBe(`/goal ask ${P} `)
|
||||
expect(m.state.phase).toBe('claimed')
|
||||
expect(m.state.occurrences).toHaveLength(1)
|
||||
})
|
||||
@@ -350,10 +350,10 @@ describe('input-machine: newline transaction (F1)', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
|
||||
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
|
||||
expect(m.state.draft).toBe(`ab\n ${P}`)
|
||||
expect(m.state.draft).toBe(`ab\n ${P} `)
|
||||
expect(m.state.occurrences[0]?.offset).toBe(4)
|
||||
m.dispatch({ type: 'undo' })
|
||||
expect(m.state.draft).toBe(`ab ${P}`)
|
||||
expect(m.state.draft).toBe(`ab ${P} `)
|
||||
})
|
||||
|
||||
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
|
||||
@@ -410,7 +410,7 @@ describe('input-machine: consume-token guards', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
|
||||
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
|
||||
expect(m.state.draft).toBe(P)
|
||||
expect(m.state.draft).toBe(`${P} `)
|
||||
expect(m.state.occurrences[0]?.offset).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -490,7 +490,7 @@ describe('input-machine: undo / redo', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
expect(m.state.occurrences).toEqual([])
|
||||
m.dispatch({ type: 'undo' })
|
||||
expect(m.state.draft).toBe(P)
|
||||
expect(m.state.draft).toBe(`${P} `)
|
||||
expect(m.state.occurrences).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -555,9 +555,9 @@ describe('input-machine: paste plane', () => {
|
||||
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 })
|
||||
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') })
|
||||
expect(m.state.draft).toBe(`${P} ${P}`)
|
||||
expect(m.state.draft).toBe(`${P} ${P} `)
|
||||
expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 3 })
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 4 })
|
||||
})
|
||||
|
||||
it('a stale span CAS drops one upgrade without ending the attempt', () => {
|
||||
@@ -634,8 +634,8 @@ describe('input-machine: projectClipboard', () => {
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
|
||||
m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) })
|
||||
expect(m.state.draft).toBe(`use ${P} then ${P}`)
|
||||
expect(projectClipboard(m.state)).toBe('use /alpha then /beta')
|
||||
expect(m.state.draft).toBe(`use ${P} then ${P} `)
|
||||
expect(projectClipboard(m.state)).toBe('use /alpha then /beta ')
|
||||
})
|
||||
|
||||
it('is the identity on a chip-free draft', () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
@@ -39,6 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -46,6 +47,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
translateHint: (key: string) => key,
|
||||
variant: 'composer',
|
||||
}
|
||||
return render(<InputBar {...props} />)
|
||||
@@ -123,13 +126,12 @@ describe('matrix row: claimed', () => {
|
||||
describe('matrix row: submitting', () => {
|
||||
it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => {
|
||||
const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
|
||||
const { view, textarea, shell, sink, claim } = bench({ submit })
|
||||
const { textarea, shell, sink, claim } = bench({ submit })
|
||||
claim()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
expect(shell.snapshot.claim).toBeDefined()
|
||||
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) })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
|
||||
@@ -110,7 +110,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
@@ -125,6 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -132,6 +133,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
translateHint: (key: string) => key,
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
|
||||
@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ function kitFor(snapshot: ConversationSnapshot) {
|
||||
sessionId: SID,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as never,
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
||||
session: snapshot,
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Exercises selection persistence through the real SlotsService store axis;
|
||||
* component stubs cannot prove per-session identity or disposal.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
|
||||
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
|
||||
|
||||
interface Bench {
|
||||
slots: SlotsService
|
||||
chat: ReturnType<typeof createChatStore>
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}),
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
})
|
||||
// Service self-registers as ctx 'slots' (cordis Service constructor).
|
||||
const slots = new SlotsService(ctx)
|
||||
const chat = createChatStore()
|
||||
// The apply.ts shape: one shared handle across both session-slot
|
||||
// registrations. 'conversation'/'details' must first exist in the ledger —
|
||||
// register a root occupant declaring them (the AppFrame role; the stand-in
|
||||
// consumes renderSlot to satisfy the declare-means-render check).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// apply.ts mounts the shared chat handle only under session-scope slots
|
||||
// (the session-maybe 'conversation' shell carries no store).
|
||||
slots.register({ name: 'conversation.session', store: chat }, () => null)
|
||||
slots.register({ name: 'details', store: chat }, () => null)
|
||||
return { slots, chat }
|
||||
}
|
||||
|
||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
||||
function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) {
|
||||
const host = renderHost(b)
|
||||
const entry = host.entriesOf(slot)[0]!
|
||||
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
}
|
||||
|
||||
/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
|
||||
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
|
||||
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
|
||||
if (captured._host === undefined) {
|
||||
b.slots.install({
|
||||
renderRoot: (host) => {
|
||||
captured._host = host
|
||||
return null
|
||||
},
|
||||
})
|
||||
b.slots.renderSlot('root', {})
|
||||
}
|
||||
return captured._host!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('selection survives on the store seat', () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
|
||||
const b = bench()
|
||||
|
||||
const conv = storeFor(b, 'conversation.session', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
conv.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
// Identity, not just value: the shared handle resolves one instance per scope key.
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', () => {
|
||||
const b = bench()
|
||||
|
||||
const one = storeFor(b, 'conversation.session', sid('s1'))
|
||||
const two = storeFor(b, 'conversation.session', sid('s2'))
|
||||
expect(two).not.toBe(one)
|
||||
one.actions.select({ turnSeq: 1, callId: 'a' })
|
||||
two.actions.select({ turnSeq: 9, callId: 'z' })
|
||||
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
})
|
||||
|
||||
it('a list-projection update keeps instance identity and the selection value', () => {
|
||||
const b = bench()
|
||||
const id = sid('s1')
|
||||
const projection = createSnapshotStore({ displayTitle: 's1' })
|
||||
|
||||
const store = storeFor(b, 'conversation.session', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
projection.set({ displayTitle: 'proj-a' })
|
||||
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
|
||||
|
||||
const after = storeFor(b, 'conversation.session', id)
|
||||
expect(after).toBe(store)
|
||||
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
||||
})
|
||||
|
||||
it('session death buries the instance and its persisted draft', () => {
|
||||
const b = bench()
|
||||
|
||||
const doomed = storeFor(b, 'conversation.session', sid('s1'))
|
||||
doomed.actions.setDraft('to be buried')
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
||||
|
||||
// SessionsService calls this public slot lifecycle seam when the scope dies.
|
||||
b.slots.pruneStoreScope(sid('s1'))
|
||||
|
||||
// Persisted residue is gone with the session...
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
const reborn = storeFor(b, 'conversation.session', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Exercises selection persistence through the real SlotsService store axis;
|
||||
* component stubs cannot prove per-session identity or disposal.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const chat = createChatStore()
|
||||
// The apply.ts shape: one shared handle across both strict-session slot
|
||||
// registrations ('conversation.session'/'details'); the session-maybe
|
||||
// 'conversation' shell carries no store by design. The slots must first
|
||||
// exist in the ledger — the test root declares them (the AppFrame role).
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
runtime.slots.register({ name: 'conversation.session', store: chat }, () => null)
|
||||
runtime.slots.register({ name: 'details', store: chat }, () => null)
|
||||
runtime.renderRoot() // materializes the host face storeOf resolves through
|
||||
return { runtime, chat }
|
||||
}
|
||||
|
||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
||||
function storeFor(b: Awaited<ReturnType<typeof bench>>, slot: 'conversation.session' | 'details', sessionId: SessionId) {
|
||||
return b.runtime.storeOf(slot, sessionId) as ChatInstance
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('selection survives on the store seat', () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
|
||||
const b = await bench()
|
||||
|
||||
const conv = storeFor(b, 'conversation.session', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
conv.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
// Identity, not just value: the shared handle resolves one instance per scope key.
|
||||
expect(details).toBe(conv)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
|
||||
const b = await bench()
|
||||
|
||||
const one = storeFor(b, 'conversation.session', sid('s1'))
|
||||
const two = storeFor(b, 'conversation.session', sid('s2'))
|
||||
expect(two).not.toBe(one)
|
||||
one.actions.select({ turnSeq: 1, callId: 'a' })
|
||||
two.actions.select({ turnSeq: 9, callId: 'z' })
|
||||
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('a list-projection update keeps instance identity and the selection value', async () => {
|
||||
const b = await bench()
|
||||
const id = sid('s1')
|
||||
|
||||
const store = storeFor(b, 'conversation.session', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
// A projection churn elsewhere (list rows re-projected) must not touch
|
||||
// store identity: drive the runtime's own list observable.
|
||||
await b.runtime.sessions.add({ id, summary: { displayTitle: 'proj-a' } })
|
||||
expect(b.runtime.sessions.list.getSnapshot().byId[id]?.displayTitle).toBe('proj-a')
|
||||
|
||||
const after = storeFor(b, 'conversation.session', id)
|
||||
expect(after).toBe(store)
|
||||
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('session death buries the instance and its persisted draft', async () => {
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.add({ id: 's1' })
|
||||
|
||||
const doomed = storeFor(b, 'conversation.session', sid('s1'))
|
||||
doomed.actions.setDraft('to be buried')
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
||||
|
||||
// TestSessions.remove drives the same public slot lifecycle seam the
|
||||
// production SessionsService calls when the scope dies (pruneStoreScope).
|
||||
await b.runtime.sessions.remove('s1')
|
||||
|
||||
// Persisted residue is gone with the session...
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
const reborn = storeFor(b, 'conversation.session', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,39 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
// ConversationService scope addressing over the runtime's real scope tag:
|
||||
// TestSessions mints tagged scopes through the production createScope, so the
|
||||
// service's scopeOf/binding path runs against production resolution (no local
|
||||
// tag probe).
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const reads: (string | symbol)[] = []
|
||||
const proxy = new Proxy(new Context(), {
|
||||
get(target, property, receiver): unknown {
|
||||
reads.push(property)
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(proxy)
|
||||
return reads.find((value): value is symbol => typeof value === 'symbol')!
|
||||
})()
|
||||
|
||||
async function bench(withSessions = true) {
|
||||
const ctx = new Context()
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const loadOlder = vi.fn(() => Promise.resolve())
|
||||
const sessions = {
|
||||
binding: (sessionId: SessionId) => ({
|
||||
sessionId, session: { prompt, cancel, loadOlder },
|
||||
}),
|
||||
scopeOf,
|
||||
} as unknown as SessionsService
|
||||
if (withSessions) ctx.provide('sessions', sessions)
|
||||
await ctx.plugin(ConversationService).await()
|
||||
const root = ctx.get('conversation') as ConversationService
|
||||
const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService
|
||||
return { root, scoped, prompt, cancel, loadOlder }
|
||||
await runtime.sessions.add({
|
||||
id: 's1',
|
||||
session: { prompt, cancel, loadOlder },
|
||||
})
|
||||
// config.input is required (the apply shares its hub with the inject
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
const fiber = runtime.ctx.plugin(ConversationService, {
|
||||
input: new InputHub(runtime.ctx),
|
||||
})
|
||||
await fiber.await()
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
|
||||
return { runtime, root, scoped, prompt, cancel, loadOlder }
|
||||
}
|
||||
|
||||
describe('ConversationService', () => {
|
||||
@@ -45,6 +38,7 @@ describe('ConversationService', () => {
|
||||
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
expect(b.loadOlder).toHaveBeenCalledOnce()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('folds Session business failures into callback rejections', async () => {
|
||||
@@ -53,12 +47,21 @@ describe('ConversationService', () => {
|
||||
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
|
||||
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
|
||||
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails loudly from the root scope or without SessionsService', async () => {
|
||||
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
const missing = await bench(false)
|
||||
await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
|
||||
await b.runtime.sessions.remove('s1')
|
||||
await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
|
||||
await b.runtime.dispose()
|
||||
// No SessionsService at all: a bare context (the runtime always provides one).
|
||||
const bare = new Context()
|
||||
await bare.plugin(ConversationService, {
|
||||
input: new InputHub(bare),
|
||||
}).await()
|
||||
const orphan = bare.get('conversation') as ConversationService
|
||||
await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,9 @@ import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import type {
|
||||
ComposerBarOwnerProps,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
|
||||
/** Machine-backed wiring over a sink spy. */
|
||||
function fakeWiring() {
|
||||
@@ -48,7 +50,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
@@ -59,13 +61,15 @@ function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
|
||||
overlayTakeover = false,
|
||||
) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
byId: {
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 },
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
|
||||
},
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
@@ -93,14 +97,23 @@ function mount(
|
||||
useSession={useSession}
|
||||
useSessions={props.useSessions}
|
||||
useWorkspaces={props.useWorkspaces}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
|
||||
views={{
|
||||
list: () => [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
],
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
{...owner}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -115,12 +128,15 @@ function mount(
|
||||
useSession={useSession}
|
||||
useSessions={props.useSessions}
|
||||
useWorkspaces={props.useWorkspaces}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
translateHint={(key: string) => key}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
@@ -128,13 +144,25 @@ function mount(
|
||||
}
|
||||
return <div data-testid={`view-${opts?.only ?? key}`} />
|
||||
}) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
|
||||
const renderSlotChain = ((_key, _owner, opts) => (
|
||||
overlayTakeover
|
||||
? (
|
||||
<>
|
||||
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
|
||||
{opts?.fallback ?? null}
|
||||
</div>
|
||||
<div data-testid="composer-takeover">TAKEOVER</div>
|
||||
</>
|
||||
)
|
||||
: (opts?.fallback ?? null)
|
||||
)) as ConversationRootProps['renderSlotChain']
|
||||
const props: ConversationRootProps = {
|
||||
sessionId: SID,
|
||||
SessionProvider: ({ children }) => children(SID),
|
||||
useSession,
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useProjection: (() => undefined),
|
||||
useInput,
|
||||
inputActions,
|
||||
renderSlot,
|
||||
@@ -162,6 +190,30 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
const textarea = b.view.container.querySelector('textarea')
|
||||
expect(host).not.toBeNull()
|
||||
expect(seat).not.toBeNull()
|
||||
expect(header).not.toBeNull()
|
||||
// Header is column chrome above the scrollport; the seat sticks inside it.
|
||||
expect(host?.contains(header)).toBe(false)
|
||||
expect(host?.contains(seat)).toBe(true)
|
||||
expect(seat?.contains(textarea)).toBe(true)
|
||||
})
|
||||
|
||||
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, true)
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const takeover = b.view.getByTestId('composer-takeover')
|
||||
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
|
||||
expect(seat?.contains(takeover)).toBe(true)
|
||||
expect(seat?.contains(fallback)).toBe(true)
|
||||
})
|
||||
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
@@ -170,13 +222,19 @@ describe('ConversationRoot resident composer', () => {
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent.
|
||||
// Hero chrome present, view ring absent; scroll host already wraps the
|
||||
// resident composer so the blank → active flip does not remount it.
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-less
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
|
||||
// for blank sessions): hero typing reaches the chat store.
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect(host?.contains(box)).toBe(true)
|
||||
fireEvent.change(box, { target: { value: 'draft in hero' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
|
||||
// Picker: open through the chip; a pick switches to the other
|
||||
@@ -189,20 +247,31 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const before = b.view.getByRole('textbox')
|
||||
fireEvent.change(before, { target: { value: 'kept across flip' } })
|
||||
// First message landed: content exists, phase leaves blank.
|
||||
// First message landed: content exists, phase leaves blank. Composer
|
||||
// already sat in the Session scrollport during hero, so the textarea
|
||||
// node and InputHub draft both survive.
|
||||
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
|
||||
b.rerender()
|
||||
const after = b.view.getByRole('textbox')
|
||||
const after = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(after).toBe(before)
|
||||
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText("Let's start building")).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps pending takeover interaction accessible outside the Chat view', () => {
|
||||
const b = mount(conversationSnapshot({ pending: [{} as never] }))
|
||||
act(() => { b.chat.actions.setView('trajectory') })
|
||||
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rolls the pending workspace label back when switching fails', async () => {
|
||||
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
||||
const b = mount(
|
||||
|
||||
600
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
600
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
// @vitest-environment jsdom
|
||||
// The terminal render intent on the web side: the pure terminalCardModel
|
||||
// derivation over callView/resultView, and both conversation render sites that
|
||||
// consume it — the chat tool row's expanded body (GenericToolCard / BashRow)
|
||||
// and the details panel's Output section.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/**
|
||||
* Match an output line with its interior whitespace intact: the column
|
||||
* alignment this card exists to preserve is exactly what the default
|
||||
* whitespace-collapsing matcher would hide.
|
||||
*/
|
||||
const RAW = { normalizer: (text: string) => text }
|
||||
|
||||
/** The rendered card's run-state dot state, so a render site cannot silently drop it. */
|
||||
function runStateOf(container: HTMLElement): string | null {
|
||||
return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null
|
||||
}
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const ARGS = '{"command":"ls -la","description":"List files"}'
|
||||
|
||||
/** The bash tool's own call view for a foreground command. */
|
||||
const callTerminal = (over?: Partial<Extract<ToolCallView, { card: 'terminal' }>>): ToolCallView => ({
|
||||
card: 'terminal', title: 'ls -la', description: 'List files', ...over,
|
||||
})
|
||||
|
||||
/** The bash tool's own result view for a settled foreground command. */
|
||||
const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
|
||||
card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over,
|
||||
})
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
|
||||
callView: callTerminal(), resultView: resultTerminal(), ...over,
|
||||
})
|
||||
|
||||
describe('terminalCardModel', () => {
|
||||
it('derives a running card from the call view alone', () => {
|
||||
expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
|
||||
description: 'List files',
|
||||
card: {
|
||||
command: 'ls -la', cwd: '/projects/app', output: undefined,
|
||||
exitCode: undefined, signal: undefined, running: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a settled card from both sides, carrying the exit status', () => {
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/projects/app' }),
|
||||
resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
|
||||
}))).toEqual({
|
||||
description: 'List files',
|
||||
card: {
|
||||
command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
|
||||
exitCode: 2, signal: undefined, running: false,
|
||||
},
|
||||
})
|
||||
expect(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
}))?.card.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('takes the result view\'s replacement title over the pending one', () => {
|
||||
// The presentation contract defines a result title as REPLACING the pending
|
||||
// title, so a tool that rewrites it at settle time must win here.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ title: 'pnpm run check' }),
|
||||
resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
|
||||
}))?.card.command).toBe('pnpm run check --filter web')
|
||||
// Without one, the call's title is what the card keeps.
|
||||
expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
|
||||
})
|
||||
|
||||
it('resolves the cwd against the session workspace the way the bridge must', () => {
|
||||
// Omitted workdir — the common bash call — IS the session workspace.
|
||||
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
// A relative workdir joins under it.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'packages/ui' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
|
||||
// An absolute one is used as-is.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/srv/other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
// With no session cwd there is nothing to resolve against: a relative path
|
||||
// stays as authored and an omitted one stays absent (a bare `$` prompt).
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'packages/ui' }),
|
||||
}))?.card.cwd).toBe('packages/ui')
|
||||
expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
|
||||
// The running arm resolves identically.
|
||||
expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
})
|
||||
|
||||
it('normalizes a relative workdir so the label names the directory actually used', () => {
|
||||
// The bash executor resolves the workdir before running, so `..` against
|
||||
// /w/app runs in /w — the card must say `w`, not `..`.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '.' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../sibling' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/sibling')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: './nested/../other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app/other')
|
||||
// A `..` that would climb past the root is dropped, as a filesystem does.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../../..' }),
|
||||
}), '/w')?.card.cwd).toBe('/')
|
||||
// An absolute path carrying segments normalizes too.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/srv/./app/../other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
// A Windows path keeps its separators.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
|
||||
}), '/w')?.card.cwd).toBe('C:\\ws')
|
||||
// Without a session cwd a relative `..` has nothing to resolve against, so
|
||||
// it survives as authored rather than being silently dropped.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../elsewhere' }),
|
||||
}))?.card.cwd).toBe('../elsewhere')
|
||||
})
|
||||
|
||||
it('keeps a UNC server and share as an unpoppable root', () => {
|
||||
// Windows cannot climb above a share, so `..` from the share root stays put.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
|
||||
// Below the share it pops normally, keeping the UNC separators.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
// Several `..` cannot escape the root either.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../../..' }),
|
||||
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
})
|
||||
|
||||
it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
|
||||
// A truncated call carries no cwd anywhere: the result view has none, and
|
||||
// the original call may have used an explicit workdir. Falling back to the
|
||||
// session workspace here would name a directory the card cannot know.
|
||||
expect(terminalCardModel(settled({
|
||||
call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}), '/w/app')?.card.cwd).toBeUndefined()
|
||||
// A present call view that omits its cwd still means the workspace.
|
||||
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
})
|
||||
|
||||
it('carries the call view\'s description, which the contract renders above the card', () => {
|
||||
expect(terminalCardModel(settled())?.description).toBe('List files')
|
||||
expect(terminalCardModel(running())?.description).toBe('List files')
|
||||
// A presenter that supplies none, and a window-truncated call side, both
|
||||
// leave it absent so the row keeps its args-derived summary.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: { card: 'terminal', title: 'ls' },
|
||||
}))?.description).toBeUndefined()
|
||||
expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a window-truncated call side falls back to the result title, then to an empty command', () => {
|
||||
// Truncation drops both the call head and its view (conversation.ts).
|
||||
const truncated = { call: null, callView: null }
|
||||
expect(terminalCardModel(settled({
|
||||
...truncated, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
|
||||
expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined })
|
||||
})
|
||||
|
||||
it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
|
||||
expect(terminalCardModel(running({ callView: null }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
|
||||
expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
|
||||
// A generic result settles a terminal call as a generic card (the bash
|
||||
// tool's own execution-error and background paths).
|
||||
expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
|
||||
expect(terminalCardModel(running({ callView: future }))).toBeNull()
|
||||
expect(terminalCardModel(settled({
|
||||
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
|
||||
}))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat row terminal body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: the one-line summary row only, no output.
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The args JSON body the generic path would have shown is gone.
|
||||
expect(view.queryByText(/"command"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('the cap collapses a long output inside the row, expandable in place', () => {
|
||||
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('… 其余 3 行')).toBeTruthy()
|
||||
expect(view.queryByText('line-5')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
|
||||
expect(view.getByText('line-5')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a multi-line command as one prompt row per line', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ title: 'ls -la\necho done' }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
|
||||
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
|
||||
// Still one dot for the call, on the first row.
|
||||
expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('the fallback row shows the presenter description, not the args summary', () => {
|
||||
// Any terminal-declaring tool without its own keyed row lands here, so the
|
||||
// contract's above-card description has to win at this render site as well.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the presenter description visible once the terminal card is expanded', () => {
|
||||
// The contract puts the description ABOVE the card. The collapsed summary is
|
||||
// hidden while a row is open, so an expanded terminal row has to draw it
|
||||
// itself or the description would only ever be visible collapsed.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call expands to the prompt line with no output yet', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
// The card states its own run state: a running command reads as running
|
||||
// even though it has no output yet to distinguish it from an empty settle.
|
||||
expect(runStateOf(view.container)).toBe('ongoing')
|
||||
})
|
||||
|
||||
it('a non-terminal call keeps the args-JSON text body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: null, resultView: null,
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a terminal call with no args still expands, through its terminal body alone', () => {
|
||||
// Empty args make the text body null; the terminal material carries the row.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: '' },
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
const view = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
// The card's controls are the row's only interactions: a bash row is not a
|
||||
// path link and no longer a details-panel target, so nothing here navigates.
|
||||
expect(view.container.querySelector('[data-clickable]')).toBeNull()
|
||||
expect(view.getByText('复制')).toBeTruthy()
|
||||
})
|
||||
|
||||
// The row's leading StateDot and the card's run-state dot describe the same
|
||||
// command, so a running row whose card claimed 'done' would be a contradiction
|
||||
// the reader sees on one line.
|
||||
it('agrees with the summary row about the run state', () => {
|
||||
const runningView = render(<BashRow {...rowProps(running())} />)
|
||||
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
|
||||
expect(runStateOf(runningView.container)).toBe('ongoing')
|
||||
cleanup()
|
||||
const settledView = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
|
||||
expect(runStateOf(settledView.container)).toBe('done')
|
||||
})
|
||||
|
||||
it('shows the terminal presenter\'s description instead of the args summary', () => {
|
||||
// `terminal_send`-style presenters author a description the args do not
|
||||
// repeat; the contract puts it above the card, which is this row's summary.
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the args-derived summary when the presenter authored no description', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'terminal', title: 'ls -la' },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal bash call (background start) renders the summary row alone', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel Output section', () => {
|
||||
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(sessions)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
|
||||
|
||||
// The panel never unmounts between selections, so per-call view state has to
|
||||
// be keyed off the selected call or it leaks into the next one.
|
||||
it('resets the card\'s expand state when the selected call changes', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
}), target)
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
|
||||
expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
|
||||
// A second call, selected without unmounting the panel, starts collapsed.
|
||||
cleanup()
|
||||
const second = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
|
||||
})],
|
||||
}), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
|
||||
expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the presenter description above the card', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
|
||||
}), target)
|
||||
const description = view.getByText('Terminal 3')
|
||||
const card = view.container.querySelector('[data-terminal]')
|
||||
expect(card).not.toBeNull()
|
||||
// Above, not below: document order is what places it as the card's heading.
|
||||
expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('resolves the prompt cwd against the session workspace', () => {
|
||||
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
|
||||
// No workdir in the call view: the prompt label is the workspace basename.
|
||||
expect(view.getByText('app')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the terminal card at full height, keeping the JSON Input section', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
}), target)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The panel takes the primitive's own default cap (16), not the row's.
|
||||
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
|
||||
expect(view.getByText('row-0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running()] }), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('运行中…')).toBeNull()
|
||||
expect(runStateOf(view.container)).toBe('ongoing')
|
||||
})
|
||||
|
||||
it('a running non-terminal call keeps the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal result keeps the flattened pre with its error styling', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
})],
|
||||
}), target)
|
||||
const pre = view.container.querySelector('pre[data-error]')
|
||||
expect(pre?.textContent).toBe('permission denied')
|
||||
})
|
||||
|
||||
// The panel resolves a sub-dispatch through the same material as a native
|
||||
// call, so a sub-call that DID carry terminal views would render the card.
|
||||
// The shipped wire cannot produce that yet: `session.ts` folds
|
||||
// `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
|
||||
// the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
|
||||
// pins the resolution path with views injected directly, and the arm below
|
||||
// pins what the shipped path actually shows today.
|
||||
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
|
||||
const view = mount(snapshot({
|
||||
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
|
||||
}), target)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
|
||||
const view = mount(snapshot({
|
||||
codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
|
||||
}), target)
|
||||
// No terminal card: the generic path renders the result text in the Output
|
||||
// section's <pre> (the Input section has its own, hence the scoping).
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
const output = view.getByText('Output').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
|
||||
})
|
||||
|
||||
it('a running run_code sub-dispatch resolves through the running material', () => {
|
||||
const view = mount(snapshot({
|
||||
// The leading non-matching sub-call exercises the scan's skip.
|
||||
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
|
||||
}), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
|
||||
}), target)
|
||||
expect(view.getByText('c1')).toBeTruthy()
|
||||
expect(view.queryByText('Input')).toBeNull()
|
||||
expect(view.getByText('Output')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scans past other nodes and other calls before reporting the call out of window', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] },
|
||||
settled({ callId: 'elsewhere' }),
|
||||
],
|
||||
runningCalls: [running({ callId: 'also-elsewhere' })],
|
||||
}), target)
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('no selection at all renders the guidance line and the default title', () => {
|
||||
const view = mount(snapshot(), null)
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a step selection without a callId renders the guidance line too', () => {
|
||||
const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 })
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the close button reaches closeDetails', () => {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
const closeDetails = vi.fn()
|
||||
const snap = snapshot()
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
|
||||
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
|
||||
expect(closeDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
|
||||
const nonText = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
content: [{ type: 'reasoning', text: 'why' }],
|
||||
})],
|
||||
}), target)
|
||||
// Scope to the Output section: the Input section's CodeBlock renders a
|
||||
// <pre> of its own, and it comes first in document order.
|
||||
expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
|
||||
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
|
||||
cleanup()
|
||||
const empty = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, content: [], isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
})],
|
||||
}), target)
|
||||
expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -31,11 +31,18 @@ describe('TodoPanel', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows progress, one row per item with its status glyph', () => {
|
||||
it('starts collapsed with the progress summary visible', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('To-dos')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands to show one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const items = screen.getAllByRole('listitem')
|
||||
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
|
||||
expect(screen.getByText('搭骨架')).toBeTruthy()
|
||||
@@ -44,8 +51,9 @@ describe('TodoPanel', () => {
|
||||
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('collapse hides the list; expand restores; header keeps the count summary', () => {
|
||||
it('collapse hides an expanded list; expand restores; header keeps the count summary', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const header = screen.getByRole('button', { expanded: true })
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
@@ -58,26 +66,29 @@ describe('TodoPanel', () => {
|
||||
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: true }))
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
/** Dock props stub: the adapter reads useSession only; the rest of the owner share is unused. */
|
||||
function dockProps(store: ReturnType<typeof createSnapshotStore<{ todos: readonly TodoItem[] }>>): TodoDockProps {
|
||||
return { useSession: bindSnapshotSelector(store) } as unknown as TodoDockProps
|
||||
/** Dock props stub: the adapter reads the 'todos' projection only; the rest of the owner share is unused. */
|
||||
function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
|
||||
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
||||
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
||||
return { useProjection } as unknown as TodoDockProps
|
||||
}
|
||||
|
||||
describe('TodoDock', () => {
|
||||
it('selects the plan off the session snapshot and follows later writes', () => {
|
||||
const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos: [] })
|
||||
it('reads the host-computed todos projection and follows pushed updates', () => {
|
||||
const store = createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>({ value: undefined })
|
||||
render(<TodoDock {...dockProps(store)} />)
|
||||
// Capability absent (no baseline/frame yet) renders nothing.
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ todos: LIST }) })
|
||||
act(() => { store.set({ value: LIST }) })
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
// A rollback to the empty list retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ todos: [] }) })
|
||||
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ value: null }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -96,10 +107,10 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
|
||||
function rowProps(block: unknown): ToolRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'todo_write', block,
|
||||
openDetails,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
@@ -139,22 +150,17 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('todo_write · not json')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back when parsed args carry no todos array, and click opens details', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
|
||||
it('falls back when parsed args carry no todos array', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
|
||||
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('更新任务清单'))
|
||||
expect(openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leading toggle expands the raw args body without opening details', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
|
||||
it('leading toggle expands the raw args body', () => {
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
// The expanded body is the pretty-printed args, not the tool output.
|
||||
expect(screen.getByText(/搭骨架/)).toBeTruthy()
|
||||
expect(openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -49,6 +49,8 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
|
||||
const chatProps = (props: ChatViewSlotProps): ReactNode => {
|
||||
// @ts-expect-error openDetails takes a SelectionTarget, not a string
|
||||
props.openDetails('nope')
|
||||
// @ts-expect-error openFile takes a path string, not a SelectionTarget
|
||||
props.openFile({ turnSeq: 1, callId: 'c' })
|
||||
return null
|
||||
}
|
||||
void chatProps
|
||||
|
||||
Reference in New Issue
Block a user