Quellcode durchsuchen

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

imccyu vor 1 Monat
Ursprung
Commit
f494caca45
47 geänderte Dateien mit 770 neuen und 200 gelöschten Zeilen
  1. 0 3
      packages/client/runtime/src/client/sessions/conversation.ts
  2. 1 8
      packages/client/runtime/src/client/sessions/lineage.ts
  3. 1 1
      packages/client/runtime/src/client/sessions/manager.ts
  4. 4 4
      packages/client/runtime/src/client/sessions/pending.ts
  5. 0 6
      packages/client/runtime/src/client/sessions/service.ts
  6. 0 3
      packages/client/runtime/src/client/sessions/session.ts
  7. 1 1
      packages/client/runtime/tests/lineage.client.spec.ts
  8. 0 2
      packages/client/runtime/tests/session.client.spec.ts
  9. 38 5
      packages/client/ui-conversation/src/client/apply.ts
  10. 7 2
      packages/client/ui-conversation/src/client/contract/slots.ts
  11. 1 0
      packages/client/ui-conversation/src/client/index.ts
  12. 119 0
      packages/client/ui-conversation/src/client/pending-interactions.ts
  13. 11 1
      packages/client/ui-conversation/src/client/service.ts
  14. 3 2
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  15. 82 4
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  16. 1 1
      packages/client/ui-conversation/tests/chat-stats.client.spec.tsx
  17. 2 16
      packages/client/ui-conversation/tests/chat-view.client.spec.tsx
  18. 1 1
      packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx
  19. 1 1
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  20. 1 1
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  21. 1 1
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  22. 100 0
      packages/client/ui-conversation/tests/pending-interactions.client.spec.ts
  23. 1 1
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  24. 3 0
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  25. 2 8
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  26. 1 1
      packages/client/ui-subagent/tests/browser-plugin.client.spec.ts
  27. 1 1
      packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
  28. 1 1
      packages/client/ui-tool/tests/diff-card.client.spec.tsx
  29. 1 1
      packages/client/ui-tool/tests/read-card.client.spec.tsx
  30. 1 1
      packages/client/ui-tool/tests/search-card.client.spec.tsx
  31. 1 1
      packages/client/ui-tool/tests/terminal-card.client.spec.tsx
  32. 1 1
      packages/client/ui-tool/tests/web-card.client.spec.tsx
  33. 0 1
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  34. 2 0
      packages/client/ui-user-questions/package.json
  35. 47 4
      packages/client/ui-user-questions/src/client/index.ts
  36. 185 45
      packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts
  37. 9 9
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  38. 14 14
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  39. 22 11
      packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
  40. 3 1
      packages/client/ui-workspace/src/client/contract/slots.ts
  41. 6 2
      packages/client/ui-workspace/src/client/index.ts
  42. 20 7
      packages/client/ui-workspace/src/client/tree.ts
  43. 6 1
      packages/client/ui-workspace/tests/apply.client.spec.ts
  44. 8 0
      packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx
  45. 57 24
      packages/client/ui-workspace/tests/tree.client.spec.ts
  46. 3 1
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  47. 0 1
      packages/test-support/client-runtime/src/fixtures.ts

+ 0 - 3
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<number, number>
   partial: PartialAssistant | null
   runningCalls: readonly RunningToolCall[]
-  /** Legacy interaction carrier list; empty after Session interaction transport removal. */
-  pending: readonly PendingInteraction[]
   /** Authoritative transient inbox snapshot, including queued and steering placements. */
   queue: readonly QueuedMessage[]
   running: boolean

+ 1 - 8
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<Partial<SessionProjectionMap>>
 }
 
-/** One flattened session-list row with lineage depth and live pending interaction. */
+/** One flattened session-list row with lineage depth. */
 export interface SessionListEntry {
   sessionId: SessionId
   title?: string
@@ -29,8 +28,6 @@ export interface SessionListEntry {
   agentPreset?: string
   /** Current host-computed projection values for list consumers. */
   projectionValues?: Readonly<Partial<SessionProjectionMap>>
-  /** User interaction currently blocking this session, derived from live control frames. */
-  pendingInteraction?: PendingInteractionStatus
   /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
   completed: boolean
   /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
@@ -42,13 +39,11 @@ export interface SessionListEntry {
  * follows the established input order; this projection never re-sorts a
  * hydrated list from mutable timestamps.
  * @param summaries - the host's session.list items.
- * @param pendingInteractions - current manager-owned interaction status by session.
  * @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
  * @returns display rows in render order.
  */
 export function flattenLineage(
   summaries: readonly TitledSessionSummary[],
-  pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
   completed?: ReadonlySet<SessionId>,
 ): SessionListEntry[] {
   const byId = new Map<SessionId, TitledSessionSummary>()
@@ -74,10 +69,8 @@ export function flattenLineage(
       return
     }
     visited.add(s.sessionId)
-    const pendingInteraction = pendingInteractions?.get(s.sessionId)
     out.push({
       ...s,
-      ...(pendingInteraction === undefined ? {} : { pendingInteraction }),
       completed: completed?.has(s.sessionId) ?? false,
       depth,
     })

+ 1 - 1
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 (

+ 4 - 4
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
   }[]
 }
 

+ 0 - 6
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 }),

+ 0 - 3
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

+ 1 - 1
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)

+ 0 - 2
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)

+ 38 - 5
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<Awaited<ReturnType<typeof next>>>()
+    const wait = new PendingWait('approval', interactionId, sessionId, {
+      approvalId: interactionId,
+      toolName: request.toolName,
+      ...(request.callId === undefined ? {} : { callId: request.callId }),
+      ...(request.reason === undefined ? {} : { reason: request.reason }),
+    }, (response) => {
+      if (response.result.ok) completion.resolve(response.result.value.outcome)
+      return Promise.resolve({ ok: true, value: { accepted: true } })
+    })
+    const remove = pendingInteractions.present(wait, 'approval', 0)
+    const abort = (): void => {
+      completion.reject(request.signal?.reason ?? new Error('approval request was aborted'))
+    }
+    request.signal?.addEventListener('abort', abort, { once: true })
+    if (request.signal?.aborted === true) abort()
+    return completion.promise.finally(() => {
+      request.signal?.removeEventListener('abort', abort)
+      remove()
+    })
+  })
 
   // The plan strip rides the input dock above the queue rows (same posture).
   ctx.plugin(todoDockEntry)

+ 7 - 2
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<ComposerBlock | undefined> }
+  hooks: {
+    composerBlock: ObservableSnapshot<ComposerBlock | undefined>
+    /** Effective Remote Event interaction for the current Session. */
+    sessionPendingInteraction: ObservableSnapshot<readonly PendingInteraction[]>
+  }
 }
 
 /** Business callbacks injected into the strict Session body seat. */
@@ -618,7 +622,8 @@ export type ComposerBarProps =
  * with zero owner changes.
  */
 export interface ComposerChainProps {
-  interactions: readonly PendingInteraction[]
+  /** Effective domain-owned interaction selected for this Session. */
+  pendingInteraction: PendingInteraction | undefined
   /** Current conversation facts for feature-owned takeover selectors. */
   session: ConversationSnapshot | undefined
 }

+ 1 - 0
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 {

+ 119 - 0
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<readonly PendingInteraction[]> = {
+  getSnapshot: () => EMPTY_INTERACTIONS,
+  subscribe: () => () => {},
+}
+
+interface PendingEntry {
+  readonly interaction: PendingInteraction
+  readonly status: PendingInteractionStatus
+  readonly precedence: number
+}
+
+interface PendingPresentationSnapshot {
+  readonly interactions: ReadonlyMap<SessionId, readonly PendingInteraction[]>
+  readonly statuses: ReadonlyMap<SessionId, PendingInteractionStatus>
+}
+
+/** Presentation sources shared by the composer and Session navigation. */
+export interface PendingInteractionPresentation {
+  /** Effective pending-interaction status by Session. */
+  readonly statuses: ObservableSnapshot<ReadonlyMap<SessionId, PendingInteractionStatus>>
+  /**
+   * Resolve the effective composer interaction for one Session.
+   * @param sessionId - current Session identity, or absence outside a Session scope.
+   * @returns an identity-stable observable source.
+   */
+  forSession(sessionId: SessionId | undefined): ObservableSnapshot<readonly PendingInteraction[]>
+  /**
+   * Publish one domain-owned interaction until its disposer runs.
+   * @param interaction - answerable presentation object.
+   * @param status - sidebar presentation kind.
+   * @param precedence - deterministic cross-domain priority; larger values win.
+   * @returns idempotent removal function.
+   */
+  present(
+    interaction: PendingInteraction,
+    status: PendingInteractionStatus,
+    precedence: number,
+  ): () => void
+}
+
+/** Aggregate domain-owned Remote Event waits without putting them on Session state. */
+export class PendingInteractionPresenter implements PendingInteractionPresentation {
+  private readonly entries = new Map<string, PendingEntry>()
+  private readonly sources = new Map<SessionId, ObservableSnapshot<readonly PendingInteraction[]>>()
+  private readonly state = createSnapshotStore<PendingPresentationSnapshot>({
+    interactions: new Map(),
+    statuses: new Map(),
+  })
+
+  /** Effective pending-interaction status by Session. */
+  readonly statuses: ObservableSnapshot<ReadonlyMap<SessionId, PendingInteractionStatus>> = {
+    getSnapshot: () => this.state.getSnapshot().statuses,
+    subscribe: listener => this.state.subscribe(listener),
+  }
+
+  /** @inheritdoc */
+  forSession(sessionId: SessionId | undefined): ObservableSnapshot<readonly PendingInteraction[]> {
+    if (sessionId === undefined) return ABSENT_INTERACTIONS
+    let source = this.sources.get(sessionId)
+    if (source === undefined) {
+      source = {
+        getSnapshot: () => this.state.getSnapshot().interactions.get(sessionId) ?? EMPTY_INTERACTIONS,
+        subscribe: listener => this.state.subscribe(listener),
+      }
+      this.sources.set(sessionId, source)
+    }
+    return source
+  }
+
+  /** @inheritdoc */
+  present(
+    interaction: PendingInteraction,
+    status: PendingInteractionStatus,
+    precedence: number,
+  ): () => void {
+    if (this.entries.has(interaction.key)) {
+      throw new Error(`ui-conversation: duplicate pending interaction key '${interaction.key}'`)
+    }
+    const entry = { interaction, status, precedence }
+    this.entries.set(interaction.key, entry)
+    this.publish()
+    let active = true
+    return () => {
+      if (!active) return
+      active = false
+      interaction.markSettled()
+      this.entries.delete(interaction.key)
+      this.publish()
+    }
+  }
+
+  private publish(): void {
+    const selected = new Map<SessionId, PendingEntry>()
+    for (const entry of this.entries.values()) {
+      const previous = selected.get(entry.interaction.sessionId)
+      if (previous === undefined || entry.precedence >= previous.precedence) {
+        selected.set(entry.interaction.sessionId, entry)
+      }
+    }
+    this.state.set({
+      interactions: new Map(
+        [...selected].map(([sessionId, entry]) => [sessionId, [entry.interaction]] as const),
+      ),
+      statuses: new Map(
+        [...selected].map(([sessionId, entry]) => [sessionId, entry.status] as const),
+      ),
+    })
+  }
+}

+ 11 - 1
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<DraftAttachmentId, ComposerAttachment>()
   private readonly imageUrls = new Map<string, ImageUrlEntry>()
   private readonly imageGenerations = new Map<SessionId, number>()
@@ -108,10 +113,15 @@ export class ConversationController extends Service implements IConversation {
    * constructed by the plugin apply (the same instances the slot inject
    * factories close over).
    */
-  constructor(ctx: Context, config: { input: SessionInputResolver; blocks: ComposerBlocks }) {
+  constructor(ctx: Context, config: {
+    input: SessionInputResolver
+    blocks: ComposerBlocks
+    pendingInteractions: PendingInteractionPresentation
+  }) {
     super(ctx, 'conversation')
     this.input = config.input
     this.blocks = config.blocks
+    this.pendingInteractions = config.pendingInteractions
     ctx.effect(() => () => {
       this.disposed = true
       for (const url of this.createdImageUrls) revokePreview(url)

+ 3 - 2
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 },
   )
 

+ 82 - 4
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<ReturnType<typeof createChatStore>['create']>
 type ChatActions = ChatInstance['actions']
+type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
+type ApprovalListener = (
+  this: ClientContext,
+  request: { toolName: string; callId?: string; reason?: string; signal?: AbortSignal },
+  next: () => Promise<ApprovalOutcome>,
+) => Promise<ApprovalOutcome>
 
 /** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */
 function sessionFakeFor() {
@@ -34,8 +41,13 @@ function sessionFakeFor() {
 async function bench() {
   const runtime = await SlotTestRuntime.create()
   runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
-  // The plugin injects both; these specs exercise no settings path.
-  runtime.provide('remote', { $on: () => () => {} })
+  let approvalListener: ApprovalListener | undefined
+  const remoteOn = vi.fn((event: string, listener: ApprovalListener) => {
+    expect(event).toBe('approval/request')
+    approvalListener = listener
+    return () => { approvalListener = undefined }
+  })
+  runtime.provide('remote', { $on: remoteOn } as never)
   runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const sessionFake = sessionFakeFor()
   await runtime.sessions.add({
@@ -110,11 +122,77 @@ async function bench() {
   return {
     runtime, feature, slots: runtime.slots, entryOf,
     conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
-    sessionFake, layoutFake,
+    sessionFake, layoutFake, remoteOn,
+    invokeApproval(
+      owner: ClientContext,
+      request: Parameters<ApprovalListener>[0],
+      next: Parameters<ApprovalListener>[1],
+    ): Promise<ApprovalOutcome> {
+      if (approvalListener === undefined) throw new Error('approval listener was not installed')
+      return approvalListener.call(owner, request, next)
+    },
   }
 }
 
 describe('conversation slot inject API', () => {
+  it('presents a scoped approval until its Remote Event waterfall resolves', async () => {
+    const b = await bench()
+    const scope = b.runtime.sessions.scope(ROOT)
+    if (scope === undefined) throw new Error('Session scope was not created')
+    const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
+    const result = b.invokeApproval(scope, {
+      toolName: 'bash', callId: 'call-1', reason: 'needs access',
+    }, next)
+    const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
+    const wait = source.getSnapshot()[0]
+    if (wait === undefined || wait.kind !== 'approval') {
+      throw new Error('approval wait was not presented')
+    }
+
+    expect(b.remoteOn).toHaveBeenCalledOnce()
+    expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot().get(ROOT))
+      .toBe('approval')
+    await new PendingApproval(wait).answer('allowed-once')
+    await expect(result).resolves.toBe('allowed-once')
+    expect(next).not.toHaveBeenCalled()
+    expect(source.getSnapshot()).toEqual([])
+    expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
+    await b.runtime.dispose()
+  })
+
+  it('removes a scoped approval when its Remote Event lifetime aborts', async () => {
+    const b = await bench()
+    const scope = b.runtime.sessions.scope(ROOT)
+    if (scope === undefined) throw new Error('Session scope was not created')
+    const controller = new AbortController()
+    const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener')
+    const reason = new DOMException('aborted by Host', 'AbortError')
+    const result = b.invokeApproval(scope, {
+      toolName: 'bash', signal: controller.signal,
+    }, () => Promise.resolve('unavailable'))
+    const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
+    expect(source.getSnapshot()).toHaveLength(1)
+
+    controller.abort(reason)
+
+    await expect(result).rejects.toBe(reason)
+    expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function))
+    expect(source.getSnapshot()).toEqual([])
+    await b.runtime.dispose()
+  })
+
+  it('delegates an approval without a Session-scoped Client Context', async () => {
+    const b = await bench()
+    const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
+
+    await expect(b.invokeApproval(b.runtime.ctx, { toolName: 'bash' }, next))
+      .resolves.toBe('unavailable')
+
+    expect(next).toHaveBeenCalledOnce()
+    expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
+    await b.runtime.dispose()
+  })
+
   it('assembles the thin API side-effect-free', async () => {
     const b = await bench()
     const { injected } = b.conversationApi(ROOT)

+ 1 - 1
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,
   }
 }

+ 2 - 16
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(<h.ChatView {...h.props} />)
-    expect(view.queryByText(/等待回答/)).toBeNull()
-    expect(view.queryByText(/等待审批/)).toBeNull()
-  })
-
   it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
     // Settled success: the bare command name is the title, the outcome text
     // the summary — neither the dispatched `/` nor its arguments reach the row

+ 1 - 1
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,
   }
 }

+ 1 - 1
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -40,7 +40,7 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
   return {
     sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
     nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
-    pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
+    queue: [], running: false, composerPhase: 'active', removed: false,
     openState: 'open', openError: null, hasMore: false, loadingOlder: false,
     promptError: null, blank: false, subagent: null, lastAgentError: null,
     ...overrides,

+ 1 - 1
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<ConversationSnapshot>({
     sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
     nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
-    pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
+    queue: [], running: over?.running ?? false, composerPhase: 'active',
     removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
     loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
   })

+ 1 - 1
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -130,7 +130,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
   const sessionStore = createSnapshotStore<ConversationSnapshot>({
     sessionId, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
     nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
-    pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
+    queue: [], running: false, composerPhase: 'active', removed: false,
     openState: 'open', openError: null, hasMore: false, loadingOlder: false,
     promptError: null, blank: false, subagent: null, lastAgentError: null,
   })

+ 100 - 0
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([])
+  })
+})

+ 1 - 1
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,
   }
 }

+ 3 - 0
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/)

+ 2 - 8
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -72,7 +72,7 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
   return {
     sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
     nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
-    pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
+    queue: [], running: false, composerPhase: 'active', removed: false,
     openState: 'open', openError: null, hasMore: false, loadingOlder: false,
     promptError: null, blank: false, subagent: null, lastAgentError: null,
     ...overrides,
@@ -256,6 +256,7 @@ function mount(
     useSessions: bindSnapshotSelector(sessions),
     useWorkspaces: bindSnapshotSelector(workspaces),
     useProjection: (() => undefined),
+    useSessionPendingInteraction: selector => selector([]),
     useComposerBlock: select => select(options.composerBlock),
     useInput,
     inputActions,
@@ -475,13 +476,6 @@ describe('ConversationRoot resident composer', () => {
     expect(b.view.getByTestId('view-chat')).toBeTruthy()
   })
 
-  it('keeps pending takeover interaction accessible outside the Chat view', () => {
-    const b = mount(conversationSnapshot({ pending: [{} as never] }))
-    act(() => { b.chat.actions.setView('trajectory') })
-    expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
-    expect(b.view.getByRole('textbox')).toBeTruthy()
-  })
-
   it('keeps the Chat fallback selected by id when a view is inserted before it', () => {
     const viewTabs: ViewTab[] = [
       { id: 'chat', label: 'Chat' },

+ 1 - 1
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),

+ 1 - 1
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,
   }

+ 1 - 1
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,
     }

+ 1 - 1
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,
     }

+ 1 - 1
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,
     }

+ 1 - 1
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,
     }

+ 1 - 1
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,
     }

+ 0 - 1
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,

+ 2 - 0
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"

+ 47 - 4
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<Awaited<ReturnType<typeof next>>>()
+    const wait = new PendingWait('question', interactionId, sessionId, {
+      questions: request.questions,
+    }, (response) => {
+      if (response.result.ok) {
+        completion.resolve(response.result.value.answer)
+      } else {
+        const error = new Error(response.result.error.message) as Error & { code: string }
+        error.name = 'UserQuestionError'
+        error.code = response.result.error.code === 'cancelled'
+          ? 'ASK_CANCELLED'
+          : response.result.error.code
+        completion.reject(error)
+      }
+      return Promise.resolve({ ok: true, value: { accepted: true } })
+    })
+    const status = planReviewOf(request.questions) === undefined ? 'question' : 'plan-review'
+    const remove = ctx.conversation.pendingInteractions.present(
+      wait,
+      status,
+      status === 'plan-review' ? 2 : 1,
+    )
+    const signal = request.signal
+    if (signal === undefined) return completion.promise.finally(remove)
+    const abort = (): void => {
+      completion.reject(signal.reason)
+    }
+    signal.addEventListener('abort', abort, { once: true })
+    if (signal.aborted) abort()
+    return completion.promise.finally(() => {
+      signal.removeEventListener('abort', abort)
+      remove()
+    })
+  })
 }

+ 185 - 45
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<typeof ANSWER>
+type QuestionListener = (
+  this: Context,
+  request: QuestionRequest,
+  next: QuestionNext,
+) => Promise<typeof ANSWER>
+
+async function bench(declare = true) {
   const ctx = new Context()
   await ctx.plugin(SlotRegistry).await()
   const slots = ctx.get('slots') as SlotRegistry
-  // The composer slot exists only while its declaring entry is live.
-  slots.register(
-    { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
-    () => null,
-  )
+  if (declare) {
+    slots.register(
+      { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
+      () => null,
+    )
+  }
   ctx.provide('locale', new LocaleRuntime(ctx))
-  return { ctx, slots }
+  const owner = ctx.extend()
+  const scopeOf = vi.fn((candidate: Context) => candidate === owner ? SESSION_ID : undefined)
+  ctx.provide('sessions', { scopeOf } as never)
+
+  let presented: PendingWait<'question'> | undefined
+  const remove = vi.fn(() => {
+    presented?.markSettled()
+    presented = undefined
+  })
+  const present = vi.fn((wait: PendingWait<'question'>) => {
+    presented = wait
+    return remove
+  })
+  ctx.provide('conversation', {
+    pendingInteractions: {
+      present,
+      statuses: { getSnapshot: () => new Map(), subscribe: () => () => {} },
+      forSession: () => ({ getSnapshot: () => [], subscribe: () => () => {} }),
+    },
+  } as never)
+
+  let listener: QuestionListener | undefined
+  const on = vi.fn((event: string, value: QuestionListener) => {
+    expect(event).toBe('user-questions/request')
+    listener = value
+    return () => { listener = undefined }
+  })
+  ctx.provide('remote', { $on: on } as never)
+  const fiber = ctx.plugin({ inject: [...inject], apply })
+  await fiber.await()
+
+  return {
+    ctx,
+    slots,
+    owner,
+    scopeOf,
+    present,
+    remove,
+    on,
+    fiber,
+    presented: () => presented,
+    invoke(request: QuestionRequest, next: QuestionNext, target = owner): Promise<typeof ANSWER> {
+      if (listener === undefined) throw new Error('question listener was not installed')
+      return listener.call(target, request, next)
+    },
+  }
 }
 
 describe('apply', () => {
   it('declares the services it binds', () => {
-    expect(inject).toEqual(['slots', 'locale'])
+    expect(inject).toEqual(['slots', 'sessions', 'remote', 'conversation', 'locale'])
   })
 
-  it('waits until a live entry declares the composer slot', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SlotRegistry).await()
-    ctx.provide('locale', new LocaleRuntime(ctx))
-    const fiber = ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(ctx.slots.entries('conversation.composer')).toHaveLength(0)
-    ctx.slots.register(
+  it('installs the Remote Event listener and waits for the composer declaration', async () => {
+    const b = await bench(false)
+    expect(b.on).toHaveBeenCalledOnce()
+    expect(b.slots.entries('conversation.composer')).toHaveLength(0)
+
+    b.slots.register(
       { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
       () => null,
     )
     await Promise.resolve()
-    expect(ctx.slots.entries('conversation.composer')).toHaveLength(1)
+    expect(b.slots.entries('conversation.composer')).toHaveLength(1)
+  })
+
+  it('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('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('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('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 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('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)
   })
 })

+ 9 - 9
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(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
     expect(screen.getByText(zh['plan.header'])).toBeTruthy()
@@ -132,7 +132,7 @@ describe('PlanReviewPanel', () => {
 
   it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
     const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     const approve = screen.getByRole('button', { name: zh['plan.approve'] })
     expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
@@ -147,7 +147,7 @@ describe('PlanReviewPanel', () => {
 
   it('answers with the asker\'s decline label', () => {
     const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
     expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
@@ -155,7 +155,7 @@ describe('PlanReviewPanel', () => {
 
   it('dismisses the request so the composer returns for a plain message', () => {
     const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
     expect(respond).toHaveBeenCalledWith({
@@ -172,7 +172,7 @@ describe('PlanReviewPanel', () => {
       ...questions()[0] as object,
       options: [{ label: 'Approve' }, { label: 'Keep planning' }],
     }] as never })
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
     expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
@@ -182,7 +182,7 @@ describe('PlanReviewPanel', () => {
     const { carrier } = wait({ questions: [{
       ...questions()[0] as object, options: [{ label: 'Approve' }],
     }] as never })
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
     expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
@@ -196,7 +196,7 @@ describe('PlanReviewPanel', () => {
         value: { accepted: false as const, reason: 'not-pending' as const },
       })),
     )
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
     const failure = await screen.findByText('question response rejected: not-pending')
@@ -212,7 +212,7 @@ describe('PlanReviewPanel', () => {
     // anything, and the panel must still show the user something.
     // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercises non-Error rejections
     const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
     expect(await screen.findByText('socket gone')).toBeTruthy()
@@ -220,7 +220,7 @@ describe('PlanReviewPanel', () => {
 
   it('carries the same decision surface in English', () => {
     const { carrier } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} t={seatOver(en, commonEn)} />)
 
     expect(screen.getByText('Plan review')).toBeTruthy()
     expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()

+ 14 - 14
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(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     expect(screen.getByText('偏好')).toBeTruthy()
     expect(screen.getByText('1 / 3')).toBeTruthy()
@@ -141,7 +141,7 @@ describe('QuestionComposer', () => {
       },
       vi.fn(),
     )
-    const view = render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    const view = render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     expect(screen.getByRole('heading', { level: 1, name: '实施计划' })).toBeTruthy()
     expect(view.container.querySelector('strong')?.textContent).toBe('先验证')
@@ -151,7 +151,7 @@ describe('QuestionComposer', () => {
 
   it('skips individual questions without discarding earlier answers', () => {
     const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
     fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
@@ -169,7 +169,7 @@ describe('QuestionComposer', () => {
 
   it('keeps IME Enter inside the custom input until composition finishes', () => {
     const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
     const custom = screen.getByPlaceholderText('输入你的答案')
@@ -189,7 +189,7 @@ describe('QuestionComposer', () => {
 
   it('shows the inline custom input, reports missing answers, and supports pager navigation', () => {
     const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
     fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
@@ -211,7 +211,7 @@ describe('QuestionComposer', () => {
 
   it('answers over multiple lines: both fields grow with the draft and keep Shift+Enter a newline', () => {
     const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     // Both question shapes answer into a textarea, so the engine soft-wraps a
     // long answer and Shift+Enter breaks the line natively.
@@ -251,7 +251,7 @@ describe('QuestionComposer', () => {
       .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } })
       .mockRejectedValueOnce(new Error('第二次取消失败'))
     const { carrier } = wait('question-1', respond)
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
 
     // Receipt rejection surfaces through the domain face's thrown message.
     fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
@@ -267,12 +267,12 @@ describe('QuestionComposer', () => {
       .mockRejectedValueOnce(new Error('网络中断'))
       .mockRejectedValueOnce('字符串错误')
     const first = wait('first', respond)
-    const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
+    const view = render(<QuestionComposer matched={first.carrier} pendingInteraction={first.carrier} {...kit} />)
 
     fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
     expect(screen.getByText('2 / 3')).toBeTruthy()
     const second = wait('second', respond)
-    view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
+    view.rerender(<QuestionComposer matched={second.carrier} pendingInteraction={second.carrier} {...kit} />)
     expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
 
     fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
@@ -297,7 +297,7 @@ describe('QuestionComposer', () => {
     const respond = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
     const carrier = new PendingWait(
       'question', interactionId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} t={seatOver(en, commonEn)} />)
     expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
     expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
     expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
@@ -305,12 +305,12 @@ describe('QuestionComposer', () => {
 
   it('same-key carrier replacement (baseline replay) keeps drafts', () => {
     const first = wait('same-id')
-    const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
+    const view = render(<QuestionComposer matched={first.carrier} pendingInteraction={first.carrier} {...kit} />)
     fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
     expect(screen.getByText('2 / 3')).toBeTruthy()
     // Replay mints a NEW carrier for the same request; same key = no remount.
     const replayed = wait('same-id')
-    view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
+    view.rerender(<QuestionComposer matched={replayed.carrier} pendingInteraction={replayed.carrier} {...kit} />)
     expect(screen.getByText('2 / 3')).toBeTruthy()
   })
 })
@@ -351,7 +351,7 @@ describe('PendingQuestion domain face', () => {
 
   it('collapses the card to the header strip and expands it back', () => {
     const { carrier } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
     // Expanded: the option list is visible.
     expect(screen.getByRole('radiogroup')).toBeTruthy()
     // Collapse: options leave the tree; the title and minimize toggle stay.
@@ -367,7 +367,7 @@ describe('PendingQuestion domain face', () => {
 
   it('keeps the collapse toggle out of the cancel path and preserves drafts across collapse', () => {
     const { carrier, respond } = wait()
-    render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
+    render(<QuestionComposer matched={carrier} pendingInteraction={carrier} {...kit} />)
     fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
     // Single-select auto-advances to the second question; collapse and expand
     // must not lose either the picked option or the current position.

+ 22 - 11
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<string[]>([])
   // Transient drag marker state; the selected mode owns the resulting order.
@@ -321,13 +322,13 @@ function SessionTree({
     [sessionOrderByAccount, ungroupedSessionIds],
   )
   const groups = useMemo(
-    () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, {
+    () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, pendingInteractions, {
       expandedGroups,
       ...(sessionOrderByAccount[UNGROUPED_KEY] === undefined
         ? {}
         : { ungroupedOrder: sessionOrderByAccount[UNGROUPED_KEY] }),
     }),
-    [list, orderedWorkspaces, archivedSessionIds, expandedGroups, sessionOrderByAccount],
+    [list, orderedWorkspaces, archivedSessionIds, pendingInteractions, expandedGroups, sessionOrderByAccount],
   )
   const now = Date.now()
   const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
@@ -547,11 +548,12 @@ function SessionTree({
 
 /** The flat "In one list" body: every session is one draggable top-level row. */
 function FlatList({
-  useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
+  useSessions, usePendingInteractions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
   orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t,
 }: Pick<
   SessionTreeProps,
   | 'useSessions'
+  | 'usePendingInteractions'
   | 'open'
   | 'forkSession'
   | 'onSessionRename'
@@ -565,9 +567,10 @@ function FlatList({
   | 't'
 >) {
   const list = useSessions(s => s)
+  const pendingInteractions = usePendingInteractions(s => s)
   const baseRows = useMemo(
-    () => deriveFlat(list, archivedSessionIds),
-    [list, archivedSessionIds],
+    () => deriveFlat(list, archivedSessionIds, pendingInteractions),
+    [list, archivedSessionIds, pendingInteractions],
   )
   const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows])
   const previousOrderBy = useRef(orderBy)
@@ -675,6 +678,7 @@ interface RemoteSearchState {
 /** Flat search body: local metadata matches plus the current Host result page. */
 function SearchResults({
   useSessions,
+  usePendingInteractions,
   open,
   workspaces,
   archivedSessionIds,
@@ -682,7 +686,7 @@ function SearchResults({
   remote,
   resultLimit,
   t,
-}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
+}: Pick<SessionTreeProps, 'useSessions' | 'usePendingInteractions' | 'open' | 't'> & {
   workspaces: readonly WorkspaceView[]
   archivedSessionIds: readonly SessionNode['id'][]
   query: string
@@ -690,12 +694,15 @@ function SearchResults({
   resultLimit: number
 }) {
   const list = useSessions(s => s)
+  const pendingInteractions = usePendingInteractions(s => s)
   const currentRemote = remote.query === query
     ? remote
     : { query, status: 'loading' as const, items: [], hasMore: false }
   const results = useMemo(
-    () => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit),
-    [list, workspaces, query, archivedSessionIds, currentRemote, resultLimit],
+    () => deriveSearchResults(
+      list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit,
+    ),
+    [list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit],
   )
   const pending = currentRemote.status === 'loading'
   const failed = currentRemote.status === 'error'
@@ -745,6 +752,7 @@ export function WorkspaceBrowser({
   wide,
   expandSidebar,
   useSessions,
+  usePendingInteractions,
   useWorkspaces,
   useStore,
   actions,
@@ -1145,6 +1153,7 @@ export function WorkspaceBrowser({
           ? (
             <SearchResults
               useSessions={useSessions}
+              usePendingInteractions={usePendingInteractions}
               open={open}
               workspaces={workspaces}
               archivedSessionIds={archivedSessionIds}
@@ -1157,7 +1166,8 @@ export function WorkspaceBrowser({
           : groupBy === 'flat'
             ? (
               <FlatList
-                useSessions={useSessions} open={open} forkSession={forkSession}
+                useSessions={useSessions} usePendingInteractions={usePendingInteractions}
+                open={open} forkSession={forkSession}
                 onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
                 archivedSessionIds={archivedSessionIds}
                 orderBy={orderBy}
@@ -1171,6 +1181,7 @@ export function WorkspaceBrowser({
             : (
               <SessionTree
                 useSessions={useSessions}
+                usePendingInteractions={usePendingInteractions}
                 onSessionRename={onSessionRename}
                 onSessionArchive={onSessionArchive}
                 forkSession={forkSession}

+ 3 - 1
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -29,7 +29,7 @@ import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRu
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
-  SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
+  PendingInteractionStatus, SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { createWorkspaceViewStore } from '../stores.ts'
 
@@ -91,6 +91,8 @@ export type WorkspaceBrowserInjected = {
   hooks: DirectoryPickingInjected['hooks'] & {
     /** Current generation's Host description, bound by the slot renderer. */
     hostDescription: HostDescriptionSource
+    /** Effective pending Remote Event interaction by Session. */
+    pendingInteractions: HostObservable<ReadonlyMap<SessionId, PendingInteractionStatus>>
   }
   /**
    * Start a New Session in a Workspace: reuse-or-create its blank session and

+ 6 - 2
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),

+ 20 - 7
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<SessionId, PendingInteractionStatus>
+
 /** Group key for Sessions outside every Workspace. */
 export const UNGROUPED_KEY = ''
 
@@ -22,7 +24,7 @@ export interface SessionNode {
   title: string
   /** The provisional blank session (renderer shows the localized New Session title). */
   blank: boolean
-  /** The runtime Session list reports an interaction awaiting this user. */
+  /** A Remote Event interaction awaiting this user. */
   pendingInteraction?: PendingInteractionStatus
   running: boolean
   /** Running descendants connected through uninterrupted subagent-origin lineage. */
@@ -59,7 +61,7 @@ export interface SearchResultNode {
   id: SessionId
   title: string
   workspace: string
-  /** The runtime Session list reports an interaction awaiting this user. */
+  /** A Remote Event interaction awaiting this user. */
   pendingInteraction?: PendingInteractionStatus
   running: boolean
   /** Running descendants connected through uninterrupted subagent-origin lineage. */
@@ -214,7 +216,9 @@ function groupByWorkspace(
 function sessionNode(
   s: SessionSummary,
   descendants: ReadonlyMap<SessionId, SubagentDescendantSummary>,
+  pendingInteractions: PendingInteractions,
 ): SessionNode {
+  const pendingInteraction = pendingInteractions.get(s.id)
   return {
     id: s.id,
     title: sessionTitle(s),
@@ -223,7 +227,7 @@ function sessionNode(
     runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
     completed: s.completed === true,
     updatedAt: s.updatedAt,
-    ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
+    ...(pendingInteraction === undefined ? {} : { pendingInteraction }),
   }
 }
 
@@ -238,6 +242,7 @@ function sessionNode(
  * @param list - sessions list snapshot (`current` feeds containsCurrent).
  * @param workspaces - real workspaces in stable Host order.
  * @param archivedSessionIds - registry-global archive set.
+ * @param pendingInteractions - pending Remote Event presentation by Session.
  * @param view - local expansion arrays.
  * @returns group sections in render order.
  */
@@ -245,6 +250,7 @@ export function deriveGroups(
   list: SessionListState,
   workspaces: readonly WorkspaceView[],
   archivedSessionIds: readonly SessionId[],
+  pendingInteractions: PendingInteractions,
   view: TreeView,
 ): GroupNode[] {
   const archived = new Set(archivedSessionIds)
@@ -266,7 +272,9 @@ export function deriveGroups(
       sessionCount: g.sessions.length,
       expanded,
       containsCurrent: g.key === currentGroup,
-      sessions: expanded ? g.sessions.map(session => sessionNode(session, descendants)) : [],
+      sessions: expanded
+        ? g.sessions.map(session => sessionNode(session, descendants, pendingInteractions))
+        : [],
     })
   }
   return groups
@@ -279,11 +287,13 @@ export function deriveGroups(
  * (see {@link deriveSearchResults}).
  * @param list - sessions list snapshot.
  * @param archivedSessionIds - registry-global archive set.
+ * @param pendingInteractions - pending Remote Event presentation by Session.
  * @returns flat rows in render order.
  */
 export function deriveFlat(
   list: SessionListState,
   archivedSessionIds: readonly SessionId[],
+  pendingInteractions: PendingInteractions,
 ): SessionNode[] {
   const archived = new Set(archivedSessionIds)
   const descendants = indexSubagentDescendants(list.byId)
@@ -294,7 +304,7 @@ export function deriveFlat(
     rows.push(s)
   }
   rows.sort(byRecency)
-  return rows.map(session => sessionNode(session, descendants))
+  return rows.map(session => sessionNode(session, descendants, pendingInteractions))
 }
 
 /** Relative-time bucket of a session row's trailing label. */
@@ -314,6 +324,7 @@ export interface RelativeTime {
  * @param workspaces - Workspace membership and display labels.
  * @param query - caller text; surrounding whitespace is ignored.
  * @param archivedSessionIds - registry-global archive set (members never match).
+ * @param pendingInteractions - pending Remote Event presentation by Session.
  * @param content - ranked Host content-search page.
  * @param limit - protocol-owned maximum merged row count.
  * @returns bounded deduplicated flat rows and a refine-query hint bit.
@@ -323,6 +334,7 @@ export function deriveSearchResults(
   workspaces: readonly WorkspaceView[],
   query: string,
   archivedSessionIds: readonly SessionId[],
+  pendingInteractions: PendingInteractions,
   content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
   limit: number,
 ): SearchResultSet {
@@ -375,15 +387,16 @@ export function deriveSearchResults(
   return {
     items: ordered.slice(0, limit).map((summary) => {
       const match = contentBySession.get(summary.id)
+      const pendingInteraction = pendingInteractions.get(summary.id)
       return {
         id: summary.id,
         title: sessionTitle(summary),
         workspace: labelOf(summary),
         running: summary.running,
         runningSubagentCount: descendants.get(summary.id)?.runningCount ?? 0,
-        ...(summary.pendingInteraction === undefined
+        ...(pendingInteraction === undefined
           ? {}
-          : { pendingInteraction: summary.pendingInteraction }),
+          : { pendingInteraction }),
         completed: summary.completed === true,
         ...match === undefined ? {} : { snippet: match.snippet },
       }

+ 6 - 1
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 () => {

+ 8 - 0
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<SlotTestRuntime> {
   const runtime = await SlotTestRuntime.create()
+  const noPendingInteractions = new Map()
   runtime.provide('connection', {
     hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
   })
+  runtime.provide('conversation', {
+    pendingInteractions: {
+      statuses: { getSnapshot: () => noPendingInteractions, subscribe: () => () => {} },
+      forSession: () => ({ getSnapshot: () => [], subscribe: () => () => {} }),
+      present: () => () => {},
+    },
+  })
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.provide('locale', locale)
   runtime.slots.installLocale(locale)

+ 57 - 24
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<SessionId, PendingInteractionStatus> = new Map()
 const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
 
 describe('deriveGroups', () => {
   it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
     const sessions = list(summary('newer', 20), summary('older', 10))
     const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
-    const groups = deriveGroups(sessions, workspaces, noArchive, view(['first']))
+    const groups = deriveGroups(sessions, workspaces, noArchive, noPending, view(['first']))
     expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
     expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
   })
 
   it('projects pending-interaction state into grouped and flat rows', () => {
-    const awaiting = { ...summary('awaiting', 10), pendingInteraction: 'plan-review' as const, running: true }
+    const awaiting = { ...summary('awaiting', 10), running: true }
     const sessions = list(awaiting)
-    const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project']))
+    const pending = new Map([[awaiting.id, 'plan-review' as const]])
+    const grouped = deriveGroups(
+      sessions, [workspace('project', ['awaiting'])], noArchive, pending, view(['project']),
+    )
     expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
-    expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
+    expect(deriveFlat(sessions, noArchive, pending)[0]).toMatchObject({
+      pendingInteraction: 'plan-review', running: true,
+    })
   })
 
   it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
     const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
-    const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))
+    const groups = deriveGroups(
+      sessions, [workspace('first', ['owned'])], noArchive, noPending, view([UNGROUPED_KEY]),
+    )
     expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
     expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
   })
@@ -61,6 +69,7 @@ describe('deriveGroups', () => {
       sessions,
       [],
       noArchive,
+      noPending,
       view([UNGROUPED_KEY], ['two', 'stale', 'two']),
     )
     expect(groups[0]!.sessions.map(session => session.id)).toEqual([
@@ -77,7 +86,8 @@ describe('deriveGroups', () => {
       current: currentBlank.id,
     }
     const groups = deriveGroups(
-      sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']),
+      sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])],
+      noArchive, noPending, view(['first']),
     )
     expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
     const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
@@ -88,7 +98,10 @@ describe('deriveGroups', () => {
     expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
     expect(groups[0]!.sessionCount).toBe(2)
     // A non-current blank stray never surfaces an Ungrouped bucket either.
-    const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view())
+    const strayGroups = deriveGroups(
+      list({ ...summary('stray', 2), blank: true }), [workspace('first', [])],
+      noArchive, noPending, view(),
+    )
     expect(strayGroups.map(group => group.key)).toEqual(['first'])
   })
 
@@ -97,14 +110,17 @@ describe('deriveGroups', () => {
     const plain = summary('plain', 2)
     const sessions = list(done, plain)
     const groups = deriveGroups(
-      sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']),
+      sessions, [workspace('first', ['done', 'plain'])], noArchive, noPending, view(['first']),
     )
     const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
     const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
     expect(doneNode.completed).toBe(true)
     expect(plainNode.completed).toBe(false)
-    expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true)
-    const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10)
+    expect(deriveFlat(sessions, noArchive, noPending).find(node => node.id === done.id)!.completed).toBe(true)
+    const search = deriveSearchResults(
+      sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
+      noPending, { items: [], hasMore: false }, 10,
+    )
     expect(search.items[0]?.completed).toBe(true)
   })
 
@@ -125,6 +141,7 @@ describe('deriveGroups', () => {
       sessions,
       [workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])],
       noArchive,
+      noPending,
       view(['first']),
     )
 
@@ -132,11 +149,11 @@ describe('deriveGroups', () => {
     expect(groups[0]!.sessionCount).toBe(2)
     expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
     expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
-    expect(deriveFlat(sessions, noArchive).map(node => [node.id, node.runningSubagentCount])).toEqual([
+    expect(deriveFlat(sessions, noArchive, noPending).map(node => [node.id, node.runningSubagentCount])).toEqual([
       [fork.id, 1], [parent.id, 2],
     ])
     expect(deriveSearchResults(
-      sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
+      sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, noPending,
       { items: [], hasMore: false }, 10,
     ).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 })
   })
@@ -155,6 +172,7 @@ describe('deriveGroups', () => {
       list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
       [],
       noArchive,
+      noPending,
       { expandedGroups: [UNGROUPED_KEY] },
     )
 
@@ -165,7 +183,9 @@ describe('deriveGroups', () => {
     ])
 
     // Equal timestamps use ids as a deterministic tiebreak in either input order.
-    expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]!
+    expect(deriveGroups(
+      list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noPending, view([UNGROUPED_KEY]),
+    )[0]!
       .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
   })
 
@@ -175,7 +195,9 @@ describe('deriveGroups', () => {
       ids: [sid('present')],
       byId: { [sid('present')]: summary('present', 1) },
     }
-    const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project']))
+    const groups = deriveGroups(
+      partial, [workspace('project', ['missing', 'present'])], noArchive, noPending, view(['project']),
+    )
     expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
   })
 
@@ -185,7 +207,8 @@ describe('deriveGroups', () => {
     const looseGone = summary('loose-gone', 3, '/other')
     const sessions = list(kept, gone, looseGone)
     const groups = deriveGroups(
-      sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]),
+      sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'),
+      noPending, view(['first', UNGROUPED_KEY]),
     )
     // The archived member drops from its group AND the archived stray never
     // surfaces an Ungrouped bucket; counts follow the visible rows.
@@ -198,9 +221,13 @@ describe('deriveGroups', () => {
     const owned = summary('owned', 1)
     const loose = summary('loose', 2)
     const ws = workspace('project', ['owned'])
-    const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view())
+    const ownedGroups = deriveGroups(
+      { ...list(owned, loose), current: owned.id }, [ws], noArchive, noPending, view(),
+    )
     expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
-    const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view())
+    const looseGroups = deriveGroups(
+      { ...list(owned, loose), current: loose.id }, [ws], noArchive, noPending, view(),
+    )
     expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
   })
 })
@@ -211,7 +238,7 @@ describe('deriveFlat', () => {
     const child = { ...summary('child', 30), parentId: parent.id }
     const tieB = summary('tie-b', 20)
     const tieA = summary('tie-a', 20)
-    const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive)
+    const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noPending)
     expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
   })
 
@@ -222,13 +249,14 @@ describe('deriveFlat', () => {
     const rows = deriveFlat(
       { ...list(parent, fork, subagent), current: subagent.id },
       noArchive,
+      noPending,
     )
     expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
   })
 
   it('tolerates ids whose summary has not landed yet', () => {
     const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
-    expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')])
+    expect(deriveFlat(partial, noArchive, noPending).map(row => row.id)).toEqual([sid('present')])
   })
 
   it('shows only the current blank session and excludes blanks from search', () => {
@@ -238,7 +266,7 @@ describe('deriveFlat', () => {
       ...list(summary('real', 1), currentBlank, staleBlank),
       current: currentBlank.id,
     }
-    const rows = deriveFlat(sessions, noArchive)
+    const rows = deriveFlat(sessions, noArchive, noPending)
     expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
     expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
     expect(rows.map(row => row.blank)).toEqual([true, false])
@@ -247,7 +275,7 @@ describe('deriveFlat', () => {
   it('hides archived sessions in flat mode', () => {
     const kept = summary('kept', 1)
     const gone = summary('gone', 2)
-    expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id])
+    expect(deriveFlat(list(kept, gone), archived('gone'), noPending).map(row => row.id)).toEqual([kept.id])
   })
 })
 
@@ -262,6 +290,7 @@ describe('deriveSearchResults archive filtering', () => {
       [],
       'needle',
       archived('gone'),
+      noPending,
       { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
       10,
     )
@@ -273,7 +302,7 @@ describe('deriveSearchResults', () => {
   it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
     const titleHit = summary('title-hit', 30, '/projects/a')
     titleHit.displayTitle = 'Needle title'
-    titleHit.pendingInteraction = 'plan-review'
+    const pending = new Map([[titleHit.id, 'plan-review' as const]])
     const workspaceHit = summary('workspace-hit', 20, '/projects/b')
     workspaceHit.displayTitle = 'Ordinary title'
     const contentHit = summary('content-hit', 10, '/projects/c')
@@ -287,6 +316,7 @@ describe('deriveSearchResults', () => {
       ],
       ' NEEDLE ',
       noArchive,
+      pending,
       {
         items: [
           { sessionId: contentHit.id, snippet: 'body needle excerpt' },
@@ -347,6 +377,7 @@ describe('deriveSearchResults', () => {
       [workspace('first', ['opaque-current', 'new session stale'])],
       'new session',
       noArchive,
+      noPending,
       {
         items: [
           { sessionId: staleBlank.id, snippet: 'stale body' },
@@ -370,6 +401,7 @@ describe('deriveSearchResults', () => {
       [],
       'needle',
       noArchive,
+      noPending,
       { items: [], hasMore: false },
       3,
     )
@@ -381,12 +413,13 @@ describe('deriveSearchResults', () => {
       [],
       'needle',
       noArchive,
+      noPending,
       { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
       3,
     )
     expect(backendMore.items).toHaveLength(1)
     expect(backendMore.hasMore).toBe(true)
-    expect(deriveSearchResults(list(), [], '  ', noArchive, { items: [], hasMore: true }, 3))
+    expect(deriveSearchResults(list(), [], '  ', noArchive, noPending, { items: [], hasMore: true }, 3))
       .toEqual({ items: [], hasMore: false })
   })
 })

+ 3 - 1
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<WorkspaceBrowserProps> = {}) {
     wide: true,
     expandSidebar: vi.fn(),
     useSessions: hook(sessionState([])),
+    usePendingInteractions: hook(new Map<SessionId, PendingInteractionStatus>()),
     useWorkspaces: hook(workspaceState([])),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

+ 0 - 1
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,