refactor(client): move pending interactions out of Session state

This commit is contained in:
imccyu
2026-08-23 16:16:04 +08:00
parent 003fc024c2
commit f494caca45
47 changed files with 770 additions and 200 deletions
@@ -15,7 +15,6 @@ import type { ContextProvenanceView, KnownContextForm } from './context-provenan
import type {
ChatConversationViewNode, ConversationTimelineSnapshot, ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
@@ -445,8 +444,6 @@ export interface ConversationSnapshot {
turnEnds: ReadonlyMap<number, number>
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/** Legacy interaction carrier list; empty after Session interaction transport removal. */
pending: readonly PendingInteraction[]
/** Authoritative transient inbox snapshot, including queued and steering placements. */
queue: readonly QueuedMessage[]
running: boolean
@@ -4,7 +4,6 @@
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { PendingInteractionStatus } from './pending.ts'
/** Host list summary enriched with the latest Session Controller title projection. */
export interface TitledSessionSummary extends SessionSummary {
@@ -13,7 +12,7 @@ export interface TitledSessionSummary extends SessionSummary {
projectionValues?: Readonly<Partial<SessionProjectionMap>>
}
/** One flattened session-list row with lineage depth and live pending interaction. */
/** One flattened session-list row with lineage depth. */
export interface SessionListEntry {
sessionId: SessionId
title?: string
@@ -29,8 +28,6 @@ export interface SessionListEntry {
agentPreset?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** User interaction currently blocking this session, derived from live control frames. */
pendingInteraction?: PendingInteractionStatus
/** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
completed: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
@@ -42,13 +39,11 @@ export interface SessionListEntry {
* follows the established input order; this projection never re-sorts a
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @param pendingInteractions - current manager-owned interaction status by session.
* @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(
summaries: readonly TitledSessionSummary[],
pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
completed?: ReadonlySet<SessionId>,
): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
@@ -74,10 +69,8 @@ export function flattenLineage(
return
}
visited.add(s.sessionId)
const pendingInteraction = pendingInteractions?.get(s.sessionId)
out.push({
...s,
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
completed: completed?.has(s.sessionId) ?? false,
depth,
})
@@ -901,7 +901,7 @@ export class SessionManager {
...(projectionValues === undefined ? {} : { projectionValues }),
}
})
const fresh = flattenLineage(merged, undefined, this.completedNotifications)
const fresh = flattenLineage(merged, this.completedNotifications)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
@@ -22,10 +22,10 @@ export interface PendingQuestionItem {
/** Structured answer returned by the legacy question composer. */
export interface PendingQuestionAnswer {
readonly answers: readonly {
readonly id: string
readonly selected: readonly string[]
readonly custom?: string
answers: {
id: string
selected: string[]
custom?: string
}[]
}
@@ -32,7 +32,6 @@ import type { ConversationRuntime } from './conversation-assembler.ts'
import { SessionManager } from './manager.ts'
import type { SessionRemotes } from './remotes.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import type { PendingInteractionStatus } from './pending.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -54,8 +53,6 @@ export interface SessionSummary {
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
running: boolean
/** User interaction currently blocking this session (sidebar amber-dot state). */
pendingInteraction?: PendingInteractionStatus
/** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
completed?: boolean
/**
@@ -707,9 +704,6 @@ export class SessionRuntime implements ISessions {
...(entry.completed ? { completed: true } : {}),
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.pendingInteraction === undefined
? {}
: { pendingInteraction: entry.pendingInteraction }),
...(entry.projectionValues === undefined
? {}
: { projectionValues: entry.projectionValues }),
@@ -35,7 +35,6 @@ import type {
ChatSnapshot, ComposerPhase, ConversationSnapshot, OpenState, PromptError,
} from './conversation.ts'
import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { Notifier } from './notifier.ts'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionRemotes } from './remotes.ts'
@@ -46,7 +45,6 @@ import { SessionQueueMirror } from './queue-mirror.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
const EMPTY_PENDING: readonly PendingInteraction[] = []
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
@@ -664,7 +662,6 @@ export class Session implements SessionFace {
turnEnds: legacy.turnEnds,
partial: legacy.partial,
runningCalls: legacy.runningCalls,
pending: EMPTY_PENDING,
queue: this.queueMirror.snapshot(),
running: this.running,
subagent: this.address === undefined
@@ -54,7 +54,7 @@ describe('flattenLineage', () => {
})
it('projects the completion-reminder set into rows (absent = false)', () => {
const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId]))
const out = flattenLineage([s('a', 10), s('b', 20)], new Set(['b' as SessionId]))
expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
@@ -929,11 +929,9 @@ describe('reference stability (the memo contract)', () => {
const after = session.getSnapshot()
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
expect(after.chat.nodes.get(settledKey)).toBe(settledNode)
await follow(api, ev.toolResult(11, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()
expect(resolved.pending).toBe(after.pending)
expect(resolved.chat.nodes.get(settledKey)).toBe(settledNode)
await follow(api, ev.assistant(12, 1, '完成'))
expect(session.getSnapshot()).not.toBe(resolved)
@@ -2,7 +2,7 @@
import type { Context } from '@deepseek-ai/cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import {
resolveWorkspacePath, type ISessions, type SessionId,
PendingWait, resolveWorkspacePath, type ISessions, type SessionId,
} from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration
// goes through the service, never a value import (client bundle purity gate).
@@ -39,6 +39,7 @@ import { en, NS, zh, type ConversationKey } from './locales.ts'
import { registerConversationNodes } from './conversation-nodes/register.ts'
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
import { PendingInteractionPresenter } from './pending-interactions.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -105,8 +106,8 @@ function concreteConversation(ctx: Context): ConversationController {
}
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
function selectApproval({ pendingInteraction }: ComposerChainProps): ApprovalWait | null {
return pendingInteraction?.kind === 'approval' ? pendingInteraction : null
}
/** Mounts the conversation plugin.
@@ -174,6 +175,7 @@ export function apply(ctx: Context): void {
// here, and the bar reads its own session's store. It cannot flow the other
// way: this package must not import the plugins that would know.
const composerBlocks = new ComposerBlockRegistry()
const pendingInteractions = new PendingInteractionPresenter()
// The input machine feeds every session-scope slot
// component through the standard provide channel — the 'input' hook plus
@@ -211,7 +213,10 @@ export function apply(ctx: Context): void {
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
},
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
hooks: {
composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),
sessionPendingInteraction: pendingInteractions.forSession(sessionId),
},
selectWorkspace: async (workspaceId) => {
const nextId = await workspaces.connectWorkspace(workspaceId)
if (sessionId !== undefined && nextId !== sessionId) {
@@ -434,7 +439,35 @@ export function apply(ctx: Context): void {
// registers itself as `conversation` and lives on its own child fiber.
// Presentation registrants depend directly on their slot declarations;
// this service remains only where conversation actions are required.
ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks })
ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks, pendingInteractions })
let nextApprovalKey = 0
ctx.remote.$on('approval/request', function (request, next) {
const sessionId = sessions.scopeOf(this)
if (sessionId === undefined) return next()
nextApprovalKey += 1
const interactionId = `remote-${String(nextApprovalKey)}`
const completion = Promise.withResolvers<Awaited<ReturnType<typeof next>>>()
const wait = new PendingWait('approval', interactionId, sessionId, {
approvalId: interactionId,
toolName: request.toolName,
...(request.callId === undefined ? {} : { callId: request.callId }),
...(request.reason === undefined ? {} : { reason: request.reason }),
}, (response) => {
if (response.result.ok) completion.resolve(response.result.value.outcome)
return Promise.resolve({ ok: true, value: { accepted: true } })
})
const remove = pendingInteractions.present(wait, 'approval', 0)
const abort = (): void => {
completion.reject(request.signal?.reason ?? new Error('approval request was aborted'))
}
request.signal?.addEventListener('abort', abort, { once: true })
if (request.signal?.aborted === true) abort()
return completion.promise.finally(() => {
request.signal?.removeEventListener('abort', abort)
remove()
})
})
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
@@ -481,7 +481,11 @@ export interface ConversationInjected {
* plugin raised one; the reason is the blocker's own localized copy, which
* the root renders as the inert composer's placeholder.
*/
hooks: { composerBlock: ObservableSnapshot<ComposerBlock | undefined> }
hooks: {
composerBlock: ObservableSnapshot<ComposerBlock | undefined>
/** Effective Remote Event interaction for the current Session. */
sessionPendingInteraction: ObservableSnapshot<readonly PendingInteraction[]>
}
}
/** Business callbacks injected into the strict Session body seat. */
@@ -618,7 +622,8 @@ export type ComposerBarProps =
* with zero owner changes.
*/
export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
/** Effective domain-owned interaction selected for this Session. */
pendingInteraction: PendingInteraction | undefined
/** Current conversation facts for feature-owned takeover selectors. */
session: ConversationSnapshot | undefined
}
@@ -17,6 +17,7 @@ export type {} from './conversation-nodes/turn-tail.ts'
export { apply, inject } from './apply.ts'
export { ConversationController } from './service.ts'
export type { IConversation } from './service.ts'
export type { PendingInteractionPresentation } from './pending-interactions.ts'
export type { DraftAttachmentId } from './input/contract.ts'
export type {
@@ -0,0 +1,119 @@
/** Presentation-only pending interactions received through Remote Events. */
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import {
createSnapshotStore,
type ObservableSnapshot,
type PendingInteraction,
type PendingInteractionStatus,
} from '@deepseek-ai/dsh-client-runtime/client'
const EMPTY_INTERACTIONS: readonly PendingInteraction[] = []
const ABSENT_INTERACTIONS: ObservableSnapshot<readonly PendingInteraction[]> = {
getSnapshot: () => EMPTY_INTERACTIONS,
subscribe: () => () => {},
}
interface PendingEntry {
readonly interaction: PendingInteraction
readonly status: PendingInteractionStatus
readonly precedence: number
}
interface PendingPresentationSnapshot {
readonly interactions: ReadonlyMap<SessionId, readonly PendingInteraction[]>
readonly statuses: ReadonlyMap<SessionId, PendingInteractionStatus>
}
/** Presentation sources shared by the composer and Session navigation. */
export interface PendingInteractionPresentation {
/** Effective pending-interaction status by Session. */
readonly statuses: ObservableSnapshot<ReadonlyMap<SessionId, PendingInteractionStatus>>
/**
* Resolve the effective composer interaction for one Session.
* @param sessionId - current Session identity, or absence outside a Session scope.
* @returns an identity-stable observable source.
*/
forSession(sessionId: SessionId | undefined): ObservableSnapshot<readonly PendingInteraction[]>
/**
* Publish one domain-owned interaction until its disposer runs.
* @param interaction - answerable presentation object.
* @param status - sidebar presentation kind.
* @param precedence - deterministic cross-domain priority; larger values win.
* @returns idempotent removal function.
*/
present(
interaction: PendingInteraction,
status: PendingInteractionStatus,
precedence: number,
): () => void
}
/** Aggregate domain-owned Remote Event waits without putting them on Session state. */
export class PendingInteractionPresenter implements PendingInteractionPresentation {
private readonly entries = new Map<string, PendingEntry>()
private readonly sources = new Map<SessionId, ObservableSnapshot<readonly PendingInteraction[]>>()
private readonly state = createSnapshotStore<PendingPresentationSnapshot>({
interactions: new Map(),
statuses: new Map(),
})
/** Effective pending-interaction status by Session. */
readonly statuses: ObservableSnapshot<ReadonlyMap<SessionId, PendingInteractionStatus>> = {
getSnapshot: () => this.state.getSnapshot().statuses,
subscribe: listener => this.state.subscribe(listener),
}
/** @inheritdoc */
forSession(sessionId: SessionId | undefined): ObservableSnapshot<readonly PendingInteraction[]> {
if (sessionId === undefined) return ABSENT_INTERACTIONS
let source = this.sources.get(sessionId)
if (source === undefined) {
source = {
getSnapshot: () => this.state.getSnapshot().interactions.get(sessionId) ?? EMPTY_INTERACTIONS,
subscribe: listener => this.state.subscribe(listener),
}
this.sources.set(sessionId, source)
}
return source
}
/** @inheritdoc */
present(
interaction: PendingInteraction,
status: PendingInteractionStatus,
precedence: number,
): () => void {
if (this.entries.has(interaction.key)) {
throw new Error(`ui-conversation: duplicate pending interaction key '${interaction.key}'`)
}
const entry = { interaction, status, precedence }
this.entries.set(interaction.key, entry)
this.publish()
let active = true
return () => {
if (!active) return
active = false
interaction.markSettled()
this.entries.delete(interaction.key)
this.publish()
}
}
private publish(): void {
const selected = new Map<SessionId, PendingEntry>()
for (const entry of this.entries.values()) {
const previous = selected.get(entry.interaction.sessionId)
if (previous === undefined || entry.precedence >= previous.precedence) {
selected.set(entry.interaction.sessionId, entry)
}
}
this.state.set({
interactions: new Map(
[...selected].map(([sessionId, entry]) => [sessionId, [entry.interaction]] as const),
),
statuses: new Map(
[...selected].map(([sessionId, entry]) => [sessionId, entry.status] as const),
),
})
}
}
@@ -21,6 +21,7 @@ import type { QueueAction, QueueItemId } from './contract/queue.ts'
import type { ComposerBlocks } from './input/blocks.ts'
import type { DraftAttachmentId, SessionInputResolver } from './input/contract.ts'
import type { InputSubmitMode } from './contract/composer-submission.ts'
import type { PendingInteractionPresentation } from './pending-interactions.ts'
/**
* The outward conversation face (`ctx.conversation`): the scope-addressed
@@ -35,6 +36,8 @@ export interface IConversation {
* cannot import makes a session's input inert with its own reason.
*/
readonly blocks: ComposerBlocks
/** Presentation-only Remote Event waits used by composer and navigation UI. */
readonly pendingInteractions: PendingInteractionPresentation
/**
* Send a prompt into the caller scope's session (queued turn).
* @param text - prompt text, sent verbatim as one text block.
@@ -95,6 +98,8 @@ export class ConversationController extends Service implements IConversation {
readonly input: SessionInputResolver
/** The per-session composer-block registry. */
readonly blocks: ComposerBlocks
/** Presentation-only pending Remote Event waits. */
readonly pendingInteractions: PendingInteractionPresentation
private readonly draftAttachments = new Map<DraftAttachmentId, ComposerAttachment>()
private readonly imageUrls = new Map<string, ImageUrlEntry>()
private readonly imageGenerations = new Map<SessionId, number>()
@@ -108,10 +113,15 @@ export class ConversationController extends Service implements IConversation {
* constructed by the plugin apply (the same instances the slot inject
* factories close over).
*/
constructor(ctx: Context, config: { input: SessionInputResolver; blocks: ComposerBlocks }) {
constructor(ctx: Context, config: {
input: SessionInputResolver
blocks: ComposerBlocks
pendingInteractions: PendingInteractionPresentation
}) {
super(ctx, 'conversation')
this.input = config.input
this.blocks = config.blocks
this.pendingInteractions = config.pendingInteractions
ctx.effect(() => () => {
this.disposed = true
for (const url of this.createdImageUrls) revokePreview(url)
@@ -14,11 +14,12 @@ export type ConversationRootProps = ConversationSlotProps
export function ConversationRoot({
sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock,
useSessionPendingInteraction,
renderSlot, renderSlotChain, selectWorkspace, t,
}: ConversationRootProps) {
const openState = useSession(s => s.openState)
const composerPhase = useSession(s => s.composerPhase)
const pending = useSession(s => s.pending) ?? []
const pendingInteraction = useSessionPendingInteraction(interactions => interactions[0])
const session = useSession(s => s)
const inputState = useInput(s => s)
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
@@ -169,7 +170,7 @@ export function ConversationRoot({
const phase = settling ? 'settling' : hero ? 'hero' : 'active'
const composer = renderSlotChain(
'conversation.composer',
{ interactions: pending, session },
{ pendingInteraction, session },
{ fallback: composerBar, overlay: true },
)
@@ -4,12 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, 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, ConversationSessionHeaderInjected,
ConversationSessionInjected, DetailsInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { PendingApproval } from '../src/client/contract/slots.ts'
import type { createChatStore } from '../src/client/stores.ts'
// The service reads its initial locale from the browser; these specs assert
@@ -20,6 +21,12 @@ const ROOT = 'root-1' as SessionId
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
type ChatActions = ChatInstance['actions']
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
type ApprovalListener = (
this: ClientContext,
request: { toolName: string; callId?: string; reason?: string; signal?: AbortSignal },
next: () => Promise<ApprovalOutcome>,
) => Promise<ApprovalOutcome>
/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */
function sessionFakeFor() {
@@ -34,8 +41,13 @@ function sessionFakeFor() {
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
let approvalListener: ApprovalListener | undefined
const remoteOn = vi.fn((event: string, listener: ApprovalListener) => {
expect(event).toBe('approval/request')
approvalListener = listener
return () => { approvalListener = undefined }
})
runtime.provide('remote', { $on: remoteOn } as never)
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
@@ -110,11 +122,77 @@ async function bench() {
return {
runtime, feature, slots: runtime.slots, entryOf,
conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
sessionFake, layoutFake,
sessionFake, layoutFake, remoteOn,
invokeApproval(
owner: ClientContext,
request: Parameters<ApprovalListener>[0],
next: Parameters<ApprovalListener>[1],
): Promise<ApprovalOutcome> {
if (approvalListener === undefined) throw new Error('approval listener was not installed')
return approvalListener.call(owner, request, next)
},
}
}
describe('conversation slot inject API', () => {
it('presents a scoped approval until its Remote Event waterfall resolves', async () => {
const b = await bench()
const scope = b.runtime.sessions.scope(ROOT)
if (scope === undefined) throw new Error('Session scope was not created')
const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
const result = b.invokeApproval(scope, {
toolName: 'bash', callId: 'call-1', reason: 'needs access',
}, next)
const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
const wait = source.getSnapshot()[0]
if (wait === undefined || wait.kind !== 'approval') {
throw new Error('approval wait was not presented')
}
expect(b.remoteOn).toHaveBeenCalledOnce()
expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot().get(ROOT))
.toBe('approval')
await new PendingApproval(wait).answer('allowed-once')
await expect(result).resolves.toBe('allowed-once')
expect(next).not.toHaveBeenCalled()
expect(source.getSnapshot()).toEqual([])
expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
await b.runtime.dispose()
})
it('removes a scoped approval when its Remote Event lifetime aborts', async () => {
const b = await bench()
const scope = b.runtime.sessions.scope(ROOT)
if (scope === undefined) throw new Error('Session scope was not created')
const controller = new AbortController()
const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener')
const reason = new DOMException('aborted by Host', 'AbortError')
const result = b.invokeApproval(scope, {
toolName: 'bash', signal: controller.signal,
}, () => Promise.resolve('unavailable'))
const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
expect(source.getSnapshot()).toHaveLength(1)
controller.abort(reason)
await expect(result).rejects.toBe(reason)
expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function))
expect(source.getSnapshot()).toEqual([])
await b.runtime.dispose()
})
it('delegates an approval without a Session-scoped Client Context', async () => {
const b = await bench()
const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
await expect(b.invokeApproval(b.runtime.ctx, { toolName: 'bash' }, next))
.resolves.toBe('unavailable')
expect(next).toHaveBeenCalledOnce()
expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
await b.runtime.dispose()
})
it('assembles the thin API side-effect-free', async () => {
const b = await bench()
const { injected } = b.conversationApi(ROOT)
@@ -43,7 +43,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -10,7 +10,7 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import {
createSnapshotStore, EMPTY_CONVERSATION_VIEWS, PendingWait,
createSnapshotStore, EMPTY_CONVERSATION_VIEWS,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
@@ -47,7 +47,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [],
turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -1338,20 +1338,6 @@ describe('ChatView', () => {
expect(lv.getByText('载入历史…')).toBeTruthy()
})
it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {
const h = makeHarness({
pending: [
new PendingWait('approval', 'r1', SID,
{ approvalId: 'ap1', toolName: 'bash' }, vi.fn()),
new PendingWait('question', 'r2', SID,
{ questions: [{ id: 'q1', question: '选择' }] }, vi.fn()),
],
})
const view = render(<h.ChatView {...h.props} />)
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', () => {
// Settled success: the bare command name is the title, the outcome text
// the summary — neither the dispatched `/` nor its arguments reach the row
@@ -52,7 +52,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -40,7 +40,7 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
return {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
...overrides,
@@ -31,7 +31,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
})
@@ -130,7 +130,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
})
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingInteractionPresenter } from '../src/client/pending-interactions.ts'
const sid = (value: string) => value as SessionId
function approval(id: string, sessionId = sid('session')): PendingWait<'approval'> {
return new PendingWait(
'approval', id, sessionId, { approvalId: id, toolName: 'bash' },
() => Promise.resolve({ ok: true, value: { accepted: true } }),
)
}
function question(id: string, sessionId = sid('session')): PendingWait<'question'> {
return new PendingWait(
'question', id, sessionId, { questions: [{ id: 'choice', question: 'Choose?' }] },
() => Promise.resolve({ ok: true, value: { accepted: true } }),
)
}
describe('PendingInteractionPresenter', () => {
it('publishes one effective interaction and status per Session by precedence', () => {
const presenter = new PendingInteractionPresenter()
const source = presenter.forSession(sid('session'))
const notifyInteraction = vi.fn()
const notifyStatuses = vi.fn()
source.subscribe(notifyInteraction)
presenter.statuses.subscribe(notifyStatuses)
const approvalWait = approval('approval')
const questionWait = question('question')
const removeApproval = presenter.present(approvalWait, 'approval', 0)
expect(source.getSnapshot()).toEqual([approvalWait])
expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('approval')
const removeQuestion = presenter.present(questionWait, 'question', 1)
expect(source.getSnapshot()).toEqual([questionWait])
expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('question')
removeQuestion()
expect(source.getSnapshot()).toEqual([approvalWait])
removeApproval()
expect(source.getSnapshot()).toEqual([])
expect(presenter.statuses.getSnapshot()).toEqual(new Map())
expect(notifyInteraction).toHaveBeenCalledTimes(4)
expect(notifyStatuses).toHaveBeenCalledTimes(4)
})
it('uses publication order to replace an equal-precedence interaction', () => {
const presenter = new PendingInteractionPresenter()
const source = presenter.forSession(sid('session'))
const first = question('first')
const second = question('second')
const removeFirst = presenter.present(first, 'question', 1)
const removeSecond = presenter.present(second, 'plan-review', 1)
expect(source.getSnapshot()).toEqual([second])
expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('plan-review')
removeSecond()
expect(source.getSnapshot()).toEqual([first])
removeFirst()
})
it('isolates Sessions, rejects duplicate keys, and removes idempotently', () => {
const presenter = new PendingInteractionPresenter()
const first = approval('same', sid('first'))
const secondSession = approval('second', sid('second'))
const removeFirst = presenter.present(first, 'approval', 0)
const removeSecond = presenter.present(secondSession, 'approval', 0)
expect(presenter.forSession(sid('first')).getSnapshot()).toEqual([first])
expect(presenter.forSession(sid('second')).getSnapshot()).toEqual([secondSession])
expect(() => presenter.present(approval('same', sid('first')), 'approval', 0))
.toThrow("duplicate pending interaction key 'a:same'")
removeFirst()
removeFirst()
expect(() => first.respond({
ok: true,
value: { sessionId: sid('first'), approvalId: 'same', outcome: 'rejected' },
})).toThrow('already settled')
expect(presenter.forSession(sid('first')).getSnapshot()).toEqual([])
expect(presenter.forSession(sid('second')).getSnapshot()).toEqual([secondSession])
removeSecond()
})
it('returns stable empty sources for absent and known Sessions', () => {
const presenter = new PendingInteractionPresenter()
const absent = presenter.forSession(undefined)
const first = presenter.forSession(sid('first'))
expect(presenter.forSession(undefined)).toBe(absent)
expect(absent.getSnapshot()).toEqual([])
const dispose = absent.subscribe(() => {})
dispose()
expect(presenter.forSession(sid('first'))).toBe(first)
expect(first.getSnapshot()).toEqual([])
})
})
@@ -37,7 +37,7 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -10,6 +10,7 @@ import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-run
import type { QueuedMessage, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { PendingInteractionPresenter } from '../src/client/pending-interactions.ts'
import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
import { zh } from '../src/client/locales.ts'
@@ -29,6 +30,7 @@ async function bench(readAttachment?: SessionFace['readAttachment']) {
const fiber = runtime.ctx.plugin(ConversationController, {
input: hub,
blocks: new ComposerBlockRegistry(),
pendingInteractions: new PendingInteractionPresenter(),
})
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationController
@@ -140,6 +142,7 @@ describe('ConversationController', () => {
await bare.plugin(ConversationController, {
input: new InputHub(bare, makeTranslate(zh, {})),
blocks: new ComposerBlockRegistry(),
pendingInteractions: new PendingInteractionPresenter(),
}).await()
const orphan = bare.get('conversation') as ConversationController
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
@@ -72,7 +72,7 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
return {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
...overrides,
@@ -256,6 +256,7 @@ function mount(
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useProjection: (() => undefined),
useSessionPendingInteraction: selector => selector([]),
useComposerBlock: select => select(options.composerBlock),
useInput,
inputActions,
@@ -475,13 +476,6 @@ describe('ConversationRoot resident composer', () => {
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('keeps the Chat fallback selected by id when a view is inserted before it', () => {
const viewTabs: ViewTab[] = [
{ id: 'chat', label: 'Chat' },
@@ -118,7 +118,7 @@ describe('apply', () => {
subagent: ConversationSnapshot['subagent'] | undefined,
running = false,
): ComposerChainProps => ({
interactions: [],
pendingInteraction: undefined,
session: subagent === undefined
? undefined
: ({ subagent, running } as unknown as ConversationSnapshot),
@@ -76,7 +76,7 @@ function snapshotWith(
chat: toolChatSnapshot(nestedNodes, nestedRunningCalls),
nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null,
runningCalls: nestedRunningCalls,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -351,7 +351,7 @@ describe('DetailsPanel diff Output section', () => {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS,
chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
@@ -310,7 +310,7 @@ describe('DetailsPanel Output section (read)', () => {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS,
chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
@@ -413,7 +413,7 @@ describe('DetailsPanel Output section (search)', () => {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS,
chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
@@ -482,7 +482,7 @@ describe('DetailsPanel Output section', () => {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS,
chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
@@ -240,7 +240,7 @@ describe('DetailsPanel web Output section', () => {
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS,
chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
@@ -97,7 +97,6 @@ function historySnapshot(
turnEnds: new Map(),
partial: trajectory.partial,
runningCalls: trajectory.runningCalls,
pending: [],
queue: [],
running: false,
subagent: null,
@@ -32,7 +32,9 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
@@ -13,10 +13,12 @@
* choice lives inside this entry see QuestionComposer.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { QuestionWait } from './contract/slots.ts'
import { planReviewOf, type QuestionWait } from './contract/slots.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
@@ -37,11 +39,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'question'
/** Required services: the slot registry and the question composer's copy. */
export const inject = ['slots', 'locale']
export const inject = ['slots', 'sessions', 'remote', 'conversation', 'locale']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
return interactions.find((i): i is QuestionWait => i.kind === 'question') ?? null
function selectQuestion({ pendingInteraction }: ComposerChainProps): QuestionWait | null {
return pendingInteraction?.kind === 'question' ? pendingInteraction : null
}
/**
@@ -57,4 +59,45 @@ export function apply(ctx: ClientContext): void {
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
QuestionComposer,
))
let nextQuestionKey = 0
ctx.remote.$on('user-questions/request', function (request, next) {
const sessionId = ctx.sessions.scopeOf(this)
if (sessionId === undefined) return next()
nextQuestionKey += 1
const interactionId = `remote-${String(nextQuestionKey)}`
const completion = Promise.withResolvers<Awaited<ReturnType<typeof next>>>()
const wait = new PendingWait('question', interactionId, sessionId, {
questions: request.questions,
}, (response) => {
if (response.result.ok) {
completion.resolve(response.result.value.answer)
} else {
const error = new Error(response.result.error.message) as Error & { code: string }
error.name = 'UserQuestionError'
error.code = response.result.error.code === 'cancelled'
? 'ASK_CANCELLED'
: response.result.error.code
completion.reject(error)
}
return Promise.resolve({ ok: true, value: { accepted: true } })
})
const status = planReviewOf(request.questions) === undefined ? 'question' : 'plan-review'
const remove = ctx.conversation.pendingInteractions.present(
wait,
status,
status === 'plan-review' ? 2 : 1,
)
const signal = request.signal
if (signal === undefined) return completion.promise.finally(remove)
const abort = (): void => {
completion.reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) abort()
return completion.promise.finally(() => {
signal.removeEventListener('abort', abort)
remove()
})
})
}
@@ -1,74 +1,214 @@
/**
* apply wiring on a real cordis Context + SlotRegistry: QuestionComposer
* registered as the `question` entry of the conversation-declared composer
* slot with ZERO business face (data and verbs ride the dispatched carrier),
* declaration-aware activation, and fiber-teardown unregistration. Component and
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
* no renderer machinery here.
*/
/** Scoped Remote Event wiring for the browser question consumer. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { PendingWait, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { PendingQuestion } from '../src/client/contract/slots.ts'
import { apply, inject } from '../src/client/index.ts'
async function bench() {
const SESSION_ID = 'session-question' as SessionId
const ANSWER = { answers: [{ id: 'mode', selected: ['Fast'] }] }
const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }]
type QuestionRequest = {
questions: typeof QUESTIONS
signal?: AbortSignal
}
type QuestionNext = () => Promise<typeof ANSWER>
type QuestionListener = (
this: Context,
request: QuestionRequest,
next: QuestionNext,
) => Promise<typeof ANSWER>
async function bench(declare = true) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const slots = ctx.get('slots') as SlotRegistry
// The composer slot exists only while its declaring entry is live.
slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
if (declare) {
slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
}
ctx.provide('locale', new LocaleRuntime(ctx))
return { ctx, slots }
const owner = ctx.extend()
const scopeOf = vi.fn((candidate: Context) => candidate === owner ? SESSION_ID : undefined)
ctx.provide('sessions', { scopeOf } as never)
let presented: PendingWait<'question'> | undefined
const remove = vi.fn(() => {
presented?.markSettled()
presented = undefined
})
const present = vi.fn((wait: PendingWait<'question'>) => {
presented = wait
return remove
})
ctx.provide('conversation', {
pendingInteractions: {
present,
statuses: { getSnapshot: () => new Map(), subscribe: () => () => {} },
forSession: () => ({ getSnapshot: () => [], subscribe: () => () => {} }),
},
} as never)
let listener: QuestionListener | undefined
const on = vi.fn((event: string, value: QuestionListener) => {
expect(event).toBe('user-questions/request')
listener = value
return () => { listener = undefined }
})
ctx.provide('remote', { $on: on } as never)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return {
ctx,
slots,
owner,
scopeOf,
present,
remove,
on,
fiber,
presented: () => presented,
invoke(request: QuestionRequest, next: QuestionNext, target = owner): Promise<typeof ANSWER> {
if (listener === undefined) throw new Error('question listener was not installed')
return listener.call(target, request, next)
},
}
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'sessions', 'remote', 'conversation', 'locale'])
})
it('waits until a live entry declares the composer slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
ctx.provide('locale', new LocaleRuntime(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.slots.entries('conversation.composer')).toHaveLength(0)
ctx.slots.register(
it('installs the Remote Event listener and waits for the composer declaration', async () => {
const b = await bench(false)
expect(b.on).toHaveBeenCalledOnce()
expect(b.slots.entries('conversation.composer')).toHaveLength(0)
b.slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
await Promise.resolve()
expect(ctx.slots.entries('conversation.composer')).toHaveLength(1)
expect(b.slots.entries('conversation.composer')).toHaveLength(1)
})
it('registers the question entry: routing selector, no inject face', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = slots.entries('conversation.composer')[0]!
it('delegates a request whose Client Context has no Session', async () => {
const b = await bench()
const next = vi.fn(async () => ANSWER)
await expect(b.invoke({ questions: QUESTIONS }, next, b.ctx)).resolves.toBe(ANSWER)
expect(next).toHaveBeenCalledOnce()
expect(b.present).not.toHaveBeenCalled()
})
it('publishes one scoped wait and returns its structured answer', async () => {
const b = await bench()
const next = vi.fn(async () => ANSWER)
const result = b.invoke({ questions: QUESTIONS }, next)
const wait = b.presented()
if (wait === undefined) throw new Error('question wait was not presented')
const entry = b.slots.entries('conversation.composer')[0]!
const select = entry.select as (
owner: { pendingInteraction: PendingWait<'question'> | undefined },
) => PendingWait<'question'> | null
expect(entry.component).toBe(QuestionComposer)
// The whole behavior surface rides the matched carrier: no business face;
// copy rides the standard locale seat.
expect(entry.inject).toBeUndefined()
expect(entry.locale).toBe('question')
// The selector narrows the chain currency: question wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const question = { kind: 'question' }
expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
expect(select({ interactions: [] })).toBeNull()
expect(select({ pendingInteraction: undefined })).toBeNull()
expect(select({ pendingInteraction: wait })).toBe(wait)
expect(b.present).toHaveBeenCalledWith(wait, 'question', 1)
await new PendingQuestion(wait).answer(ANSWER)
await expect(result).resolves.toBe(ANSWER)
expect(next).not.toHaveBeenCalled()
expect(b.remove).toHaveBeenCalledOnce()
expect(b.presented()).toBeUndefined()
})
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.composer')).toHaveLength(0)
it('uses plan-review precedence and preserves ASK_CANCELLED', async () => {
const b = await bench()
const questions = [{
id: 'plan',
question: 'Approve?',
detail: '# Plan',
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
intent: { kind: 'plan-review' as const, approve: 'Approve' },
}]
const result = b.invoke({ questions }, async () => ANSWER)
const wait = b.presented()
if (wait === undefined) throw new Error('plan review wait was not presented')
expect(b.present).toHaveBeenCalledWith(wait, 'plan-review', 2)
const rejection = expect(result).rejects.toMatchObject({
name: 'UserQuestionError',
code: 'ASK_CANCELLED',
})
await new PendingQuestion(wait).cancel()
await rejection
expect(b.remove).toHaveBeenCalledOnce()
})
it('preserves a non-cancellation question rejection', async () => {
const b = await bench()
const result = b.invoke({ questions: QUESTIONS }, async () => ANSWER)
const wait = b.presented()
if (wait === undefined) throw new Error('question wait was not presented')
const rejection = expect(result).rejects.toMatchObject({
name: 'UserQuestionError',
code: 'provider-failed',
message: 'provider failed',
})
await wait.respond({
ok: false,
error: { code: 'provider-failed', message: 'provider failed', details: {} },
})
await rejection
expect(b.remove).toHaveBeenCalledOnce()
})
it('removes an aborted request and its signal listener', async () => {
const b = await bench()
const controller = new AbortController()
const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener')
const reason = new DOMException('aborted by Host', 'AbortError')
const result = b.invoke({ questions: QUESTIONS, signal: controller.signal }, async () => ANSWER)
expect(b.presented()).toBeDefined()
controller.abort(reason)
await expect(result).rejects.toBe(reason)
expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function))
expect(b.remove).toHaveBeenCalledOnce()
expect(b.presented()).toBeUndefined()
})
it('removes a request whose signal was already aborted', async () => {
const b = await bench()
const controller = new AbortController()
controller.abort()
const result = b.invoke({ questions: QUESTIONS, signal: controller.signal }, async () => ANSWER)
await expect(result).rejects.toBe(controller.signal.reason)
expect(b.remove).toHaveBeenCalledOnce()
expect(b.presented()).toBeUndefined()
})
it('teardown unregisters the stable composer entry', async () => {
const b = await bench()
expect(b.slots.entries('conversation.composer')).toHaveLength(1)
await b.fiber.dispose()
expect(b.slots.entries('conversation.composer')).toHaveLength(0)
})
})
@@ -113,7 +113,7 @@ describe('planReviewOf', () => {
describe('PlanReviewPanel', () => {
it('renders the plan under a review strip, with none of the quiz affordances', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
expect(screen.getByText(zh['plan.header'])).toBeTruthy()
@@ -132,7 +132,7 @@ describe('PlanReviewPanel', () => {
it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
const approve = screen.getByRole('button', { name: zh['plan.approve'] })
expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
@@ -147,7 +147,7 @@ describe('PlanReviewPanel', () => {
it('answers with the asker\'s decline label', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
@@ -155,7 +155,7 @@ describe('PlanReviewPanel', () => {
it('dismisses the request so the composer returns for a plain message', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(respond).toHaveBeenCalledWith({
@@ -172,7 +172,7 @@ describe('PlanReviewPanel', () => {
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
@@ -182,7 +182,7 @@ describe('PlanReviewPanel', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object, options: [{ label: 'Approve' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
@@ -196,7 +196,7 @@ describe('PlanReviewPanel', () => {
value: { accepted: false as const, reason: 'not-pending' as const },
})),
)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
const failure = await screen.findByText('question response rejected: not-pending')
@@ -212,7 +212,7 @@ describe('PlanReviewPanel', () => {
// anything, and the panel must still show the user something.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercises non-Error rejections
const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(await screen.findByText('socket gone')).toBeTruthy()
@@ -220,7 +220,7 @@ describe('PlanReviewPanel', () => {
it('carries the same decision surface in English', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByText('Plan review')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
@@ -79,7 +79,7 @@ function answeredEnvelope(id: string, answers: object[]) {
describe('QuestionComposer', () => {
it('collects single, custom, and multi-select answers before one batch submit', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
expect(screen.getByText('偏好')).toBeTruthy()
expect(screen.getByText('1 / 3')).toBeTruthy()
@@ -141,7 +141,7 @@ describe('QuestionComposer', () => {
},
vi.fn(),
)
const view = render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
const view = render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
expect(screen.getByRole('heading', { level: 1, name: '实施计划' })).toBeTruthy()
expect(view.container.querySelector('strong')?.textContent).toBe('先验证')
@@ -151,7 +151,7 @@ describe('QuestionComposer', () => {
it('skips individual questions without discarding earlier answers', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
@@ -169,7 +169,7 @@ describe('QuestionComposer', () => {
it('keeps IME Enter inside the custom input until composition finishes', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
const custom = screen.getByPlaceholderText('输入你的答案')
@@ -189,7 +189,7 @@ describe('QuestionComposer', () => {
it('shows the inline custom input, reports missing answers, and supports pager navigation', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
@@ -211,7 +211,7 @@ describe('QuestionComposer', () => {
it('answers over multiple lines: both fields grow with the draft and keep Shift+Enter a newline', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
// Both question shapes answer into a textarea, so the engine soft-wraps a
// long answer and Shift+Enter breaks the line natively.
@@ -251,7 +251,7 @@ describe('QuestionComposer', () => {
.mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } })
.mockRejectedValueOnce(new Error('第二次取消失败'))
const { carrier } = wait('question-1', respond)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
// Receipt rejection surfaces through the domain face's thrown message.
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
@@ -267,12 +267,12 @@ describe('QuestionComposer', () => {
.mockRejectedValueOnce(new Error('网络中断'))
.mockRejectedValueOnce('字符串错误')
const first = wait('first', respond)
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
const view = render(<QuestionComposer matched={first.carrier} pendingInteraction={first.carrier} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
const second = wait('second', respond)
view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
view.rerender(<QuestionComposer matched={second.carrier} pendingInteraction={second.carrier} {...kit} />)
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
@@ -297,7 +297,7 @@ describe('QuestionComposer', () => {
const respond = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const carrier = new PendingWait(
'question', interactionId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
@@ -305,12 +305,12 @@ describe('QuestionComposer', () => {
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
const first = wait('same-id')
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
const view = render(<QuestionComposer matched={first.carrier} pendingInteraction={first.carrier} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
// Replay mints a NEW carrier for the same request; same key = no remount.
const replayed = wait('same-id')
view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
view.rerender(<QuestionComposer matched={replayed.carrier} pendingInteraction={replayed.carrier} {...kit} />)
expect(screen.getByText('2 / 3')).toBeTruthy()
})
})
@@ -351,7 +351,7 @@ describe('PendingQuestion domain face', () => {
it('collapses the card to the header strip and expands it back', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
// Expanded: the option list is visible.
expect(screen.getByRole('radiogroup')).toBeTruthy()
// Collapse: options leave the tree; the title and minimize toggle stay.
@@ -367,7 +367,7 @@ describe('PendingQuestion domain face', () => {
it('keeps the collapse toggle out of the cancel path and preserves drafts across collapse', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
// Single-select auto-advances to the second question; collapse and expand
// must not lose either the picked option or the current position.
@@ -215,7 +215,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }):
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'forkSession'
'useSessions' | 'usePendingInteractions' | 'startSession' | 'open' | 'forkSession'
| 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
> & {
/** Host account home for POSIX hover-path abbreviation. */
@@ -249,13 +249,14 @@ type SessionTreeProps = Pick<
/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
function SessionTree({
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
useSessions, usePendingInteractions, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
insertWorkspaceBefore, insertSessionBefore, orderBy,
groupExpansion, setGroupExpanded,
sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const pendingInteractions = usePendingInteractions(s => s)
const current = list.current
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
// Transient drag marker state; the selected mode owns the resulting order.
@@ -321,13 +322,13 @@ function SessionTree({
[sessionOrderByAccount, ungroupedSessionIds],
)
const groups = useMemo(
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, {
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, pendingInteractions, {
expandedGroups,
...(sessionOrderByAccount[UNGROUPED_KEY] === undefined
? {}
: { ungroupedOrder: sessionOrderByAccount[UNGROUPED_KEY] }),
}),
[list, orderedWorkspaces, archivedSessionIds, expandedGroups, sessionOrderByAccount],
[list, orderedWorkspaces, archivedSessionIds, pendingInteractions, expandedGroups, sessionOrderByAccount],
)
const now = Date.now()
const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
@@ -547,11 +548,12 @@ function SessionTree({
/** The flat "In one list" body: every session is one draggable top-level row. */
function FlatList({
useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
useSessions, usePendingInteractions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t,
}: Pick<
SessionTreeProps,
| 'useSessions'
| 'usePendingInteractions'
| 'open'
| 'forkSession'
| 'onSessionRename'
@@ -565,9 +567,10 @@ function FlatList({
| 't'
>) {
const list = useSessions(s => s)
const pendingInteractions = usePendingInteractions(s => s)
const baseRows = useMemo(
() => deriveFlat(list, archivedSessionIds),
[list, archivedSessionIds],
() => deriveFlat(list, archivedSessionIds, pendingInteractions),
[list, archivedSessionIds, pendingInteractions],
)
const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows])
const previousOrderBy = useRef(orderBy)
@@ -675,6 +678,7 @@ interface RemoteSearchState {
/** Flat search body: local metadata matches plus the current Host result page. */
function SearchResults({
useSessions,
usePendingInteractions,
open,
workspaces,
archivedSessionIds,
@@ -682,7 +686,7 @@ function SearchResults({
remote,
resultLimit,
t,
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
}: Pick<SessionTreeProps, 'useSessions' | 'usePendingInteractions' | 'open' | 't'> & {
workspaces: readonly WorkspaceView[]
archivedSessionIds: readonly SessionNode['id'][]
query: string
@@ -690,12 +694,15 @@ function SearchResults({
resultLimit: number
}) {
const list = useSessions(s => s)
const pendingInteractions = usePendingInteractions(s => s)
const currentRemote = remote.query === query
? remote
: { query, status: 'loading' as const, items: [], hasMore: false }
const results = useMemo(
() => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit),
[list, workspaces, query, archivedSessionIds, currentRemote, resultLimit],
() => deriveSearchResults(
list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit,
),
[list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit],
)
const pending = currentRemote.status === 'loading'
const failed = currentRemote.status === 'error'
@@ -745,6 +752,7 @@ export function WorkspaceBrowser({
wide,
expandSidebar,
useSessions,
usePendingInteractions,
useWorkspaces,
useStore,
actions,
@@ -1145,6 +1153,7 @@ export function WorkspaceBrowser({
? (
<SearchResults
useSessions={useSessions}
usePendingInteractions={usePendingInteractions}
open={open}
workspaces={workspaces}
archivedSessionIds={archivedSessionIds}
@@ -1157,7 +1166,8 @@ export function WorkspaceBrowser({
: groupBy === 'flat'
? (
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
useSessions={useSessions} usePendingInteractions={usePendingInteractions}
open={open} forkSession={forkSession}
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
archivedSessionIds={archivedSessionIds}
orderBy={orderBy}
@@ -1171,6 +1181,7 @@ export function WorkspaceBrowser({
: (
<SessionTree
useSessions={useSessions}
usePendingInteractions={usePendingInteractions}
onSessionRename={onSessionRename}
onSessionArchive={onSessionArchive}
forkSession={forkSession}
@@ -29,7 +29,7 @@ import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRu
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
PendingInteractionStatus, SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
@@ -91,6 +91,8 @@ export type WorkspaceBrowserInjected = {
hooks: DirectoryPickingInjected['hooks'] & {
/** Current generation's Host description, bound by the slot renderer. */
hostDescription: HostDescriptionSource
/** Effective pending Remote Event interaction by Session. */
pendingInteractions: HostObservable<ReadonlyMap<SessionId, PendingInteractionStatus>>
}
/**
* Start a New Session in a Workspace: reuse-or-create its blank session and
@@ -43,7 +43,7 @@ const NS = 'workspace'
* provides a waitable service. apply therefore depends on each slot
* declaration through `slots.inject()` instead of assuming order.
*/
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection']
export const inject = ['slots', 'sessions', 'workspaces', 'conversation', 'locale', 'connection']
/**
* Register the browser and picker once their slot declarations are on the
@@ -102,7 +102,11 @@ export function apply(ctx: ClientContext): void {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
hooks: { directoryFlow: browserFlowSource, hostDescription },
hooks: {
directoryFlow: browserFlowSource,
hostDescription,
pendingInteractions: ctx.conversation.pendingInteractions.statuses,
},
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
@@ -9,6 +9,8 @@ import {
type WorkspaceId, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
type PendingInteractions = ReadonlyMap<SessionId, PendingInteractionStatus>
/** Group key for Sessions outside every Workspace. */
export const UNGROUPED_KEY = ''
@@ -22,7 +24,7 @@ export interface SessionNode {
title: string
/** The provisional blank session (renderer shows the localized New Session title). */
blank: boolean
/** The runtime Session list reports an interaction awaiting this user. */
/** A Remote Event interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
/** Running descendants connected through uninterrupted subagent-origin lineage. */
@@ -59,7 +61,7 @@ export interface SearchResultNode {
id: SessionId
title: string
workspace: string
/** The runtime Session list reports an interaction awaiting this user. */
/** A Remote Event interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
running: boolean
/** Running descendants connected through uninterrupted subagent-origin lineage. */
@@ -214,7 +216,9 @@ function groupByWorkspace(
function sessionNode(
s: SessionSummary,
descendants: ReadonlyMap<SessionId, SubagentDescendantSummary>,
pendingInteractions: PendingInteractions,
): SessionNode {
const pendingInteraction = pendingInteractions.get(s.id)
return {
id: s.id,
title: sessionTitle(s),
@@ -223,7 +227,7 @@ function sessionNode(
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
completed: s.completed === true,
updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
}
}
@@ -238,6 +242,7 @@ function sessionNode(
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param archivedSessionIds - registry-global archive set.
* @param pendingInteractions - pending Remote Event presentation by Session.
* @param view - local expansion arrays.
* @returns group sections in render order.
*/
@@ -245,6 +250,7 @@ export function deriveGroups(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[],
pendingInteractions: PendingInteractions,
view: TreeView,
): GroupNode[] {
const archived = new Set(archivedSessionIds)
@@ -266,7 +272,9 @@ export function deriveGroups(
sessionCount: g.sessions.length,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? g.sessions.map(session => sessionNode(session, descendants)) : [],
sessions: expanded
? g.sessions.map(session => sessionNode(session, descendants, pendingInteractions))
: [],
})
}
return groups
@@ -279,11 +287,13 @@ export function deriveGroups(
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot.
* @param archivedSessionIds - registry-global archive set.
* @param pendingInteractions - pending Remote Event presentation by Session.
* @returns flat rows in render order.
*/
export function deriveFlat(
list: SessionListState,
archivedSessionIds: readonly SessionId[],
pendingInteractions: PendingInteractions,
): SessionNode[] {
const archived = new Set(archivedSessionIds)
const descendants = indexSubagentDescendants(list.byId)
@@ -294,7 +304,7 @@ export function deriveFlat(
rows.push(s)
}
rows.sort(byRecency)
return rows.map(session => sessionNode(session, descendants))
return rows.map(session => sessionNode(session, descendants, pendingInteractions))
}
/** Relative-time bucket of a session row's trailing label. */
@@ -314,6 +324,7 @@ export interface RelativeTime {
* @param workspaces - Workspace membership and display labels.
* @param query - caller text; surrounding whitespace is ignored.
* @param archivedSessionIds - registry-global archive set (members never match).
* @param pendingInteractions - pending Remote Event presentation by Session.
* @param content - ranked Host content-search page.
* @param limit - protocol-owned maximum merged row count.
* @returns bounded deduplicated flat rows and a refine-query hint bit.
@@ -323,6 +334,7 @@ export function deriveSearchResults(
workspaces: readonly WorkspaceView[],
query: string,
archivedSessionIds: readonly SessionId[],
pendingInteractions: PendingInteractions,
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
limit: number,
): SearchResultSet {
@@ -375,15 +387,16 @@ export function deriveSearchResults(
return {
items: ordered.slice(0, limit).map((summary) => {
const match = contentBySession.get(summary.id)
const pendingInteraction = pendingInteractions.get(summary.id)
return {
id: summary.id,
title: sessionTitle(summary),
workspace: labelOf(summary),
running: summary.running,
runningSubagentCount: descendants.get(summary.id)?.runningCount ?? 0,
...(summary.pendingInteraction === undefined
...(pendingInteraction === undefined
? {}
: { pendingInteraction: summary.pendingInteraction }),
: { pendingInteraction }),
completed: summary.completed === true,
...match === undefined ? {} : { snippet: match.snippet },
}
@@ -34,6 +34,11 @@ async function bench() {
ctx.provide('connection', {
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
} as never)
ctx.provide('conversation', {
pendingInteractions: {
statuses: { getSnapshot: () => new Map(), subscribe: () => () => {} },
},
} as never)
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
@@ -56,7 +61,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void {
describe('ui-workspace apply', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'conversation', 'locale', 'connection'])
})
it('registers browser and pickers for declarations arriving before or after apply', async () => {
@@ -31,9 +31,17 @@ beforeEach(() => { localStorage.clear() })
/** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */
async function createRuntime(): Promise<SlotTestRuntime> {
const runtime = await SlotTestRuntime.create()
const noPendingInteractions = new Map()
runtime.provide('connection', {
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
})
runtime.provide('conversation', {
pendingInteractions: {
statuses: { getSnapshot: () => noPendingInteractions, subscribe: () => () => {} },
forSession: () => ({ getSnapshot: () => [], subscribe: () => () => {} }),
present: () => () => {},
},
})
const locale = new LocaleRuntime(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
PendingInteractionStatus, SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime,
@@ -29,28 +29,36 @@ const view = (expandedGroups: readonly string[] = [], ungroupedOrder?: readonly
...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
})
const noArchive: readonly SessionId[] = []
const noPending: ReadonlyMap<SessionId, PendingInteractionStatus> = new Map()
const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
describe('deriveGroups', () => {
it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
const sessions = list(summary('newer', 20), summary('older', 10))
const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
const groups = deriveGroups(sessions, workspaces, noArchive, view(['first']))
const groups = deriveGroups(sessions, workspaces, noArchive, noPending, view(['first']))
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
it('projects pending-interaction state into grouped and flat rows', () => {
const awaiting = { ...summary('awaiting', 10), pendingInteraction: 'plan-review' as const, running: true }
const awaiting = { ...summary('awaiting', 10), running: true }
const sessions = list(awaiting)
const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project']))
const pending = new Map([[awaiting.id, 'plan-review' as const]])
const grouped = deriveGroups(
sessions, [workspace('project', ['awaiting'])], noArchive, pending, view(['project']),
)
expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
expect(deriveFlat(sessions, noArchive, pending)[0]).toMatchObject({
pendingInteraction: 'plan-review', running: true,
})
})
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))
const groups = deriveGroups(
sessions, [workspace('first', ['owned'])], noArchive, noPending, view([UNGROUPED_KEY]),
)
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
})
@@ -61,6 +69,7 @@ describe('deriveGroups', () => {
sessions,
[],
noArchive,
noPending,
view([UNGROUPED_KEY], ['two', 'stale', 'two']),
)
expect(groups[0]!.sessions.map(session => session.id)).toEqual([
@@ -77,7 +86,8 @@ describe('deriveGroups', () => {
current: currentBlank.id,
}
const groups = deriveGroups(
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']),
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])],
noArchive, noPending, view(['first']),
)
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
@@ -88,7 +98,10 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
expect(groups[0]!.sessionCount).toBe(2)
// A non-current blank stray never surfaces an Ungrouped bucket either.
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view())
const strayGroups = deriveGroups(
list({ ...summary('stray', 2), blank: true }), [workspace('first', [])],
noArchive, noPending, view(),
)
expect(strayGroups.map(group => group.key)).toEqual(['first'])
})
@@ -97,14 +110,17 @@ describe('deriveGroups', () => {
const plain = summary('plain', 2)
const sessions = list(done, plain)
const groups = deriveGroups(
sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']),
sessions, [workspace('first', ['done', 'plain'])], noArchive, noPending, view(['first']),
)
const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
expect(doneNode.completed).toBe(true)
expect(plainNode.completed).toBe(false)
expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true)
const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10)
expect(deriveFlat(sessions, noArchive, noPending).find(node => node.id === done.id)!.completed).toBe(true)
const search = deriveSearchResults(
sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
noPending, { items: [], hasMore: false }, 10,
)
expect(search.items[0]?.completed).toBe(true)
})
@@ -125,6 +141,7 @@ describe('deriveGroups', () => {
sessions,
[workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])],
noArchive,
noPending,
view(['first']),
)
@@ -132,11 +149,11 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessionCount).toBe(2)
expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
expect(deriveFlat(sessions, noArchive).map(node => [node.id, node.runningSubagentCount])).toEqual([
expect(deriveFlat(sessions, noArchive, noPending).map(node => [node.id, node.runningSubagentCount])).toEqual([
[fork.id, 1], [parent.id, 2],
])
expect(deriveSearchResults(
sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, noPending,
{ items: [], hasMore: false }, 10,
).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 })
})
@@ -155,6 +172,7 @@ describe('deriveGroups', () => {
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
noArchive,
noPending,
{ expandedGroups: [UNGROUPED_KEY] },
)
@@ -165,7 +183,9 @@ describe('deriveGroups', () => {
])
// Equal timestamps use ids as a deterministic tiebreak in either input order.
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]!
expect(deriveGroups(
list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noPending, view([UNGROUPED_KEY]),
)[0]!
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
})
@@ -175,7 +195,9 @@ describe('deriveGroups', () => {
ids: [sid('present')],
byId: { [sid('present')]: summary('present', 1) },
}
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project']))
const groups = deriveGroups(
partial, [workspace('project', ['missing', 'present'])], noArchive, noPending, view(['project']),
)
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
})
@@ -185,7 +207,8 @@ describe('deriveGroups', () => {
const looseGone = summary('loose-gone', 3, '/other')
const sessions = list(kept, gone, looseGone)
const groups = deriveGroups(
sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]),
sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'),
noPending, view(['first', UNGROUPED_KEY]),
)
// The archived member drops from its group AND the archived stray never
// surfaces an Ungrouped bucket; counts follow the visible rows.
@@ -198,9 +221,13 @@ describe('deriveGroups', () => {
const owned = summary('owned', 1)
const loose = summary('loose', 2)
const ws = workspace('project', ['owned'])
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view())
const ownedGroups = deriveGroups(
{ ...list(owned, loose), current: owned.id }, [ws], noArchive, noPending, view(),
)
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view())
const looseGroups = deriveGroups(
{ ...list(owned, loose), current: loose.id }, [ws], noArchive, noPending, view(),
)
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
})
})
@@ -211,7 +238,7 @@ describe('deriveFlat', () => {
const child = { ...summary('child', 30), parentId: parent.id }
const tieB = summary('tie-b', 20)
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive)
const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noPending)
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
})
@@ -222,13 +249,14 @@ describe('deriveFlat', () => {
const rows = deriveFlat(
{ ...list(parent, fork, subagent), current: subagent.id },
noArchive,
noPending,
)
expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
})
it('tolerates ids whose summary has not landed yet', () => {
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')])
expect(deriveFlat(partial, noArchive, noPending).map(row => row.id)).toEqual([sid('present')])
})
it('shows only the current blank session and excludes blanks from search', () => {
@@ -238,7 +266,7 @@ describe('deriveFlat', () => {
...list(summary('real', 1), currentBlank, staleBlank),
current: currentBlank.id,
}
const rows = deriveFlat(sessions, noArchive)
const rows = deriveFlat(sessions, noArchive, noPending)
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
expect(rows.map(row => row.blank)).toEqual([true, false])
@@ -247,7 +275,7 @@ describe('deriveFlat', () => {
it('hides archived sessions in flat mode', () => {
const kept = summary('kept', 1)
const gone = summary('gone', 2)
expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id])
expect(deriveFlat(list(kept, gone), archived('gone'), noPending).map(row => row.id)).toEqual([kept.id])
})
})
@@ -262,6 +290,7 @@ describe('deriveSearchResults archive filtering', () => {
[],
'needle',
archived('gone'),
noPending,
{ items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
10,
)
@@ -273,7 +302,7 @@ describe('deriveSearchResults', () => {
it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
const titleHit = summary('title-hit', 30, '/projects/a')
titleHit.displayTitle = 'Needle title'
titleHit.pendingInteraction = 'plan-review'
const pending = new Map([[titleHit.id, 'plan-review' as const]])
const workspaceHit = summary('workspace-hit', 20, '/projects/b')
workspaceHit.displayTitle = 'Ordinary title'
const contentHit = summary('content-hit', 10, '/projects/c')
@@ -287,6 +316,7 @@ describe('deriveSearchResults', () => {
],
' NEEDLE ',
noArchive,
pending,
{
items: [
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
@@ -347,6 +377,7 @@ describe('deriveSearchResults', () => {
[workspace('first', ['opaque-current', 'new session stale'])],
'new session',
noArchive,
noPending,
{
items: [
{ sessionId: staleBlank.id, snippet: 'stale body' },
@@ -370,6 +401,7 @@ describe('deriveSearchResults', () => {
[],
'needle',
noArchive,
noPending,
{ items: [], hasMore: false },
3,
)
@@ -381,12 +413,13 @@ describe('deriveSearchResults', () => {
[],
'needle',
noArchive,
noPending,
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
3,
)
expect(backendMore.items).toHaveLength(1)
expect(backendMore.hasMore).toBe(true)
expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3))
expect(deriveSearchResults(list(), [], ' ', noArchive, noPending, { items: [], hasMore: true }, 3))
.toEqual({ items: [], hasMore: false })
})
})
@@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView,
PendingInteractionStatus, SessionId, SessionListState, SessionSummary, WorkspaceId,
WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -64,6 +65,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
wide: true,
expandSidebar: vi.fn(),
useSessions: hook(sessionState([])),
usePendingInteractions: hook(new Map<SessionId, PendingInteractionStatus>()),
useWorkspaces: hook(workspaceState([])),
useStore: bindSnapshotSelector(store),
actions: store.actions,
@@ -55,7 +55,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
turnEnds: new Map(),
partial: null,
runningCalls: [],
pending: [],
queue: [],
running: false,
subagent: null,