From f494caca45b95bf402e3810e2999041bbc2d5da8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:59:28 +0800 Subject: [PATCH] refactor(client): move pending interactions out of Session state --- .../src/client/sessions/conversation.ts | 3 - .../runtime/src/client/sessions/lineage.ts | 9 +- .../runtime/src/client/sessions/manager.ts | 2 +- .../runtime/src/client/sessions/pending.ts | 8 +- .../runtime/src/client/sessions/service.ts | 6 - .../runtime/src/client/sessions/session.ts | 3 - .../runtime/tests/lineage.client.spec.ts | 2 +- .../runtime/tests/session.client.spec.ts | 2 - .../ui-conversation/src/client/apply.ts | 43 +++- .../src/client/contract/slots.ts | 9 +- .../ui-conversation/src/client/index.ts | 1 + .../src/client/pending-interactions.ts | 119 +++++++++ .../ui-conversation/src/client/service.ts | 12 +- .../src/client/skeleton/ConversationRoot.tsx | 5 +- .../tests/apply-inject.client.spec.tsx | 86 ++++++- .../tests/chat-stats.client.spec.tsx | 2 +- .../tests/chat-view.client.spec.tsx | 18 +- .../tests/gate-branch-tails.client.spec.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 2 +- .../tests/input-matrix.client.spec.tsx | 2 +- .../tests/input-scenarios.client.spec.tsx | 2 +- .../tests/pending-interactions.client.spec.ts | 100 ++++++++ .../tests/queue-dock.client.spec.tsx | 2 +- .../service-orchestration.client.spec.ts | 3 + .../tests/skeleton.client.spec.tsx | 10 +- .../tests/browser-plugin.client.spec.ts | 2 +- .../tests/chat-code-subcalls.client.spec.tsx | 2 +- .../ui-tool/tests/diff-card.client.spec.tsx | 2 +- .../ui-tool/tests/read-card.client.spec.tsx | 2 +- .../ui-tool/tests/search-card.client.spec.tsx | 2 +- .../tests/terminal-card.client.spec.tsx | 2 +- .../ui-tool/tests/web-card.client.spec.tsx | 2 +- .../ui-trajectory/tests/views.client.spec.tsx | 1 - .../client/ui-user-questions/package.json | 2 + .../ui-user-questions/src/client/index.ts | 51 +++- .../tests/browser-plugin.client.spec.ts | 230 ++++++++++++++---- .../tests/plan-review-panel.client.spec.tsx | 18 +- .../user-questions-composer.client.spec.tsx | 28 +-- .../src/client/WorkspaceBrowser.tsx | 33 ++- .../ui-workspace/src/client/contract/slots.ts | 4 +- .../client/ui-workspace/src/client/index.ts | 8 +- .../client/ui-workspace/src/client/tree.ts | 27 +- .../ui-workspace/tests/apply.client.spec.ts | 7 +- .../tests/rename-assembly.client.spec.tsx | 8 + .../ui-workspace/tests/tree.client.spec.ts | 81 ++++-- .../tests/workspace-browser.client.spec.tsx | 4 +- .../client-runtime/src/fixtures.ts | 1 - 47 files changed, 770 insertions(+), 200 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/pending-interactions.ts create mode 100644 packages/client/ui-conversation/tests/pending-interactions.client.spec.ts diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 8e20118ed4..ece94a7c9f 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -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 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 diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index d83ce1229e..5b703baff8 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -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> } -/** 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> - /** 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, completed?: ReadonlySet, ): SessionListEntry[] { const byId = new Map() @@ -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, }) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b8a3f91e2d..ad111dd391 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -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 ( diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts index 6acbca094b..46ace229d7 100644 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ b/packages/client/runtime/src/client/sessions/pending.ts @@ -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 }[] } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index dd2036449b..41feedd958 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -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 }), diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index bff1f91f5d..6a373a7e7a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -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 diff --git a/packages/client/runtime/tests/lineage.client.spec.ts b/packages/client/runtime/tests/lineage.client.spec.ts index 01ecacd1e2..d0c657c46b 100644 --- a/packages/client/runtime/tests/lineage.client.spec.ts +++ b/packages/client/runtime/tests/lineage.client.spec.ts @@ -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) diff --git a/packages/client/runtime/tests/session.client.spec.ts b/packages/client/runtime/tests/session.client.spec.ts index 13a29270c9..fae04f68bd 100644 --- a/packages/client/runtime/tests/session.client.spec.ts +++ b/packages/client/runtime/tests/session.client.spec.ts @@ -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) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7920104d68..409635e016 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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>>() + 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) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 020c31a25e..8957e0e9ed 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 } + hooks: { + composerBlock: ObservableSnapshot + /** Effective Remote Event interaction for the current Session. */ + sessionPendingInteraction: ObservableSnapshot + } } /** 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 } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 78732229b0..628c6650ee 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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 { diff --git a/packages/client/ui-conversation/src/client/pending-interactions.ts b/packages/client/ui-conversation/src/client/pending-interactions.ts new file mode 100644 index 0000000000..5f2b5bda7f --- /dev/null +++ b/packages/client/ui-conversation/src/client/pending-interactions.ts @@ -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 = { + getSnapshot: () => EMPTY_INTERACTIONS, + subscribe: () => () => {}, +} + +interface PendingEntry { + readonly interaction: PendingInteraction + readonly status: PendingInteractionStatus + readonly precedence: number +} + +interface PendingPresentationSnapshot { + readonly interactions: ReadonlyMap + readonly statuses: ReadonlyMap +} + +/** Presentation sources shared by the composer and Session navigation. */ +export interface PendingInteractionPresentation { + /** Effective pending-interaction status by Session. */ + readonly statuses: ObservableSnapshot> + /** + * 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 + /** + * 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() + private readonly sources = new Map>() + private readonly state = createSnapshotStore({ + interactions: new Map(), + statuses: new Map(), + }) + + /** Effective pending-interaction status by Session. */ + readonly statuses: ObservableSnapshot> = { + getSnapshot: () => this.state.getSnapshot().statuses, + subscribe: listener => this.state.subscribe(listener), + } + + /** @inheritdoc */ + forSession(sessionId: SessionId | undefined): ObservableSnapshot { + 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() + 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), + ), + }) + } +} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 866ae53b61..e6fb86fd2b 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -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() private readonly imageUrls = new Map() private readonly imageGenerations = new Map() @@ -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) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 07655d7dba..1cbe99ac82 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -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 }, ) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 90b4d804cf..09ed9346a0 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -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['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, +) => Promise /** 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[0], + next: Parameters[1], + ): Promise { + 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('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('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) diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index 5592c24730..ecaec0d2a4 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -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, } } diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index f7e05f61f6..ab2fdcfbca 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -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() - 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 diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx index 6b7cc0280b..b23e74a258 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx @@ -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, } } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index c81531afe6..7f1c0a8f38 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -40,7 +40,7 @@ function snapshotOf(overrides: Partial = {}): 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, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 05d2e6b528..bdaae95846 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -31,7 +31,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled const session = createSnapshotStore({ 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, }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index b87a4785e9..2961ae8e17 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -130,7 +130,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const sessionStore = createSnapshotStore({ 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, }) diff --git a/packages/client/ui-conversation/tests/pending-interactions.client.spec.ts b/packages/client/ui-conversation/tests/pending-interactions.client.spec.ts new file mode 100644 index 0000000000..eba1236739 --- /dev/null +++ b/packages/client/ui-conversation/tests/pending-interactions.client.spec.ts @@ -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([]) + }) +}) diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 861e8db7d6..aa80eb86f2 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -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, } } diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 38522771c0..0ef1e38061 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -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/) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index ea80451b1d..dfd62d704f 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -72,7 +72,7 @@ function conversationSnapshot(overrides: Partial = {}): 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' }, diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index f0c9e9853c..f9ca6838ab 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -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), diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index 687720fbef..4957ed2417 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -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, } diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index e4cf03c044..d123a7ef70 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -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, } diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 400b8eecdd..42b42e6ba7 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -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, } diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index b2064e4b3f..40e557194c 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -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, } diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 4d6eeeacd8..c56eee83c4 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -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, } diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index f9712c091e..ca19c75ea6 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -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, } diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 3df145b757..1bc74af54d 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -97,7 +97,6 @@ function historySnapshot( turnEnds: new Map(), partial: trajectory.partial, runningCalls: trajectory.runningCalls, - pending: [], queue: [], running: false, subagent: null, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 884c005d82..6b671b32ec 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -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" diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 20f3c37f12..e107a28750 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -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>>() + 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() + }) + }) } diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index 121df27298..dbf42c9972 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -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 +type QuestionListener = ( + this: Context, + request: QuestionRequest, + next: QuestionNext, +) => Promise + +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 { + 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) }) }) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 5a1f248a8c..9c48979c8d 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -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() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() expect(screen.getByText('Plan review')).toBeTruthy() expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy() diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index ef7b7b851b..f97affab19 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -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() + render() expect(screen.getByText('偏好')).toBeTruthy() expect(screen.getByText('1 / 3')).toBeTruthy() @@ -141,7 +141,7 @@ describe('QuestionComposer', () => { }, vi.fn(), ) - const view = render() + const view = render() 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() + render() 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() + render() 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() + render() 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() + render() // 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() + render() // 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() + const view = render() fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() const second = wait('second', respond) - view.rerender() + view.rerender() 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() + render() 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() + const view = render() 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() + view.rerender() 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() + render() // 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() + render() 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. diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 08f22ed400..7d6bbe9c34 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -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([]) // 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): 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 & { +}: Pick & { 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({ ? ( > } /** * Start a New Session in a Workspace: reuse-or-create its blank session and diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 93269c22c1..0dbc17c975 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -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), diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 23649a24f7..d51fbf6d53 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -9,6 +9,8 @@ import { type WorkspaceId, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' +type PendingInteractions = ReadonlyMap + /** 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, + 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 }, } diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 3ad62e289f..eb649cea96 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -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 () => { diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index c2e201469f..cc2bc10f49 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -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 { 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) diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index f2e069de43..7aee3671c9 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -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 = 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 }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 820d2bd66b..10a3aa8453 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -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 = {}) { wide: true, expandSidebar: vi.fn(), useSessions: hook(sessionState([])), + usePendingInteractions: hook(new Map()), useWorkspaces: hook(workspaceState([])), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/test-support/client-runtime/src/fixtures.ts b/packages/test-support/client-runtime/src/fixtures.ts index 396e3a0267..f8b0a43f77 100644 --- a/packages/test-support/client-runtime/src/fixtures.ts +++ b/packages/test-support/client-runtime/src/fixtures.ts @@ -55,7 +55,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, subagent: null,