From c7d8e32aece2dbf6328c4bbed34bdf6d5934b56f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:18:05 +0800 Subject: [PATCH] refactor(conversation): separate Conversation, Chat, and Trajectory owners --- .../conversation-registry.client.spec.ts | 161 ---- packages/client/ui-attachment/package.json | 10 +- .../src/client/MessageImages.tsx | 2 +- .../client/ui-attachment/src/client/index.ts | 4 +- .../tests/message-image.client.spec.tsx | 24 +- .../ui-attachment/tests/plugin.client.spec.ts | 2 +- packages/client/ui-attachment/tsconfig.json | 5 +- packages/client/ui-chat/package.json | 115 +++ packages/client/ui-chat/src/client/apply.ts | 141 ++++ .../src/client/chat/ApprovalCommand.tsx | 40 + .../client/chat/AssistantMarkdown.module.css | 0 .../src/client/chat/AssistantMarkdown.tsx | 2 +- .../src/client/chat/AssistantNodeView.tsx | 0 .../src/client/chat/ChatNodeSeat.tsx | 6 +- .../src/client/chat/ChatView.module.css | 0 .../src/client/chat/ChatView.tsx | 15 +- .../src/client/chat/CommandNodeView.tsx | 0 .../src/client/chat/CompactionCommandCard.tsx | 0 .../src/client/chat/CompactionItem.tsx | 2 +- .../src/client/chat/ContextBody.module.css | 0 .../src/client/chat/ContextBody.tsx | 3 +- .../chat/ContextInjectionRow.module.css | 0 .../src/client/chat/ContextInjectionRow.tsx | 4 +- .../client/chat/GenericCommandCard.module.css | 0 .../src/client/chat/GenericCommandCard.tsx | 0 .../client/chat/MessageIconActions.module.css | 0 .../src/client/chat/MessageIconActions.tsx | 0 .../src/client/chat/MessageItem.module.css | 0 .../src/client/chat/MessageItem.tsx | 6 +- .../src/client/chat/ReasoningRow.module.css | 0 .../src/client/chat/ReasoningRow.tsx | 0 .../src/client/chat/StatsLine.module.css | 0 .../src/client/chat/StatsLine.tsx | 52 +- .../client/chat/TurnTailNodeView.module.css | 0 .../src/client/chat/TurnTailNodeView.tsx | 6 +- .../src/client/chat/accessibility.module.css | 0 .../src/client/chat/message-chrome.ts | 0 .../client/chat/register-node-renderers.ts | 2 +- .../src/client/chat/turn-assistant.ts | 2 +- .../src/client/chat/use-calendar-day.ts | 0 .../chat/use-throttled-visual-update.ts | 0 .../src/client/contract/chat-nodes.ts | 17 +- .../ui-chat/src/client/contract/slots.ts | 205 +++++ .../ui-chat/src/client/contract/snapshot.ts | 79 ++ .../ui-chat/src/client/contract/store.ts | 17 + .../src/client/contract}/turn-metrics.ts | 2 +- .../client/conversation-nodes/assistant.ts | 19 +- .../chat-snapshot-builder.ts | 21 +- .../src/client/conversation-nodes/command.ts | 12 +- .../src/client/conversation-nodes/common.ts | 2 +- .../client/conversation-nodes/compaction.ts | 9 +- .../src/client/conversation-nodes/fallback.ts | 11 +- .../src/client/conversation-nodes/inbox.ts | 6 +- .../src/client/conversation-nodes/message.ts | 14 +- .../src/client/conversation-nodes}/partial.ts | 18 +- .../src/client/conversation-nodes/register.ts | 0 .../src/client/conversation-nodes/retry.ts | 9 +- .../src/client/conversation-nodes/tool.ts | 10 +- .../client/conversation-nodes/turn-error.ts | 11 +- .../conversation-nodes/turn-max-tokens.ts | 9 +- .../client/conversation-nodes/turn-tail.ts | 13 +- .../client/details}/DetailsPanel.module.css | 0 .../src/client/details}/DetailsPanel.tsx | 12 +- .../src/client/details}/tool-node-reader.ts | 16 +- .../ui-chat/src/client/historical-images.ts | 107 +++ packages/client/ui-chat/src/client/index.ts | 56 ++ packages/client/ui-chat/src/client/locale.ts | 159 ++++ .../src/client/model}/conversation-context.ts | 4 +- .../src/client/model}/steering-history.ts | 0 .../src/client/model}/tool-call-tree.ts | 2 +- packages/client/ui-chat/src/client/stores.ts | 20 + packages/client/ui-chat/src/css-modules.d.ts | 6 + packages/client/ui-chat/src/index.ts | 4 + packages/client/ui-chat/src/invariant.ts | 21 + .../tests/apply-inject.client.spec.tsx | 176 ++++ .../tests/approval-command.client.spec.tsx | 71 ++ .../ui-chat/tests/chat-apply.client.spec.tsx | 157 ++++ .../tests/chat-branch-tails.client.spec.tsx | 10 +- .../tests/chat-snapshot-fixture.client.ts | 12 +- .../tests/chat-stats.client.spec.tsx | 51 +- .../ui-chat/tests/chat-store.client.spec.ts | 26 + .../tests/chat-view.client.spec.tsx | 247 +++--- ...nversation-node-definitions.client.spec.ts | 32 +- .../tests/conversation.client.spec.ts | 2 +- .../tests/coverage-tails.client.spec.tsx | 8 +- .../tests/gate-branch-tails.client.spec.tsx | 100 ++- .../tests/historical-images.client.spec.ts | 28 + .../tests/image-labels.client.spec.tsx | 41 +- .../tests/partial.client.spec.ts | 2 +- .../tests/reasoning-row.client.spec.tsx | 2 +- .../tests/selection-survival.client.spec.tsx | 79 ++ .../tests/tool-call-tree.client.spec.ts | 4 +- .../tests/turn-metrics.client.spec.ts | 6 +- .../tests/views-type-chain.client.spec.tsx | 22 + packages/client/ui-chat/tsconfig.json | 87 ++ packages/client/ui-chat/tsdown.config.ts | 3 + .../src/client/browser-bytes.ts | 13 + .../src/client/context-occupancy.ts | 25 + .../src/client/contract/composer-blocks.ts | 29 + .../client/contract}/context-provenance.ts | 2 +- .../src/client/contract/conversation.ts | 16 +- .../{input/contract.ts => contract/input.ts} | 146 +++- .../src/client/contract/queue.ts | 14 +- .../src/client/contract/records.ts} | 178 +--- .../client/contract}/request-inspection.ts | 4 +- .../src/client/contract/slots.ts | 759 +++--------------- .../src/client/contract/snapshot.ts | 34 + .../src/client/contract/views.ts | 35 +- .../src/client/conversation/assembler.ts} | 24 +- .../src/client/conversation/assembly.ts | 205 +++++ .../client/conversation}/assistant-timing.ts | 6 +- .../conversation/definition-registry.ts | 14 +- .../src/client/conversation/event-registry.ts | 13 +- .../client/conversation}/failure-display.ts | 0 .../client/conversation/location-index.ts} | 0 .../src/client/conversation/view-registry.ts | 8 +- .../ui-conversation/src/client/index.ts | 111 ++- .../src/client/input/blocks.ts | 39 +- .../src/client/input/facade.ts | 24 +- .../ui-conversation/src/client/input/hub.ts | 41 +- .../src/client/input/machine.ts | 8 +- .../{queue/store.ts => input/queue-store.ts} | 7 +- .../src/client/input/submission-policy.ts | 5 +- .../ui-conversation/src/client/locales.ts | 161 +--- .../src/client/pending-composer.ts | 18 + .../src/client/queue/QueueDock.tsx | 2 +- .../ui-conversation/src/client/service.ts | 113 +-- .../src/client/settings/EnterBehaviorRow.tsx | 2 +- .../client/skeleton/ApprovalPanel.module.css | 97 --- .../src/client/skeleton/ApprovalPanel.tsx | 69 -- .../src/client/skeleton/ContextMeter.tsx | 18 +- .../skeleton/ConversationRoot.module.css | 4 +- .../src/client/skeleton/ConversationRoot.tsx | 38 +- .../client/skeleton/ConversationSession.tsx | 51 +- .../src/client/skeleton/EmptyHero.tsx | 2 +- .../src/client/skeleton/InputBar.tsx | 20 +- .../{reference => skeleton}/ReferenceIcon.tsx | 0 .../client/{input => skeleton}/decorations.ts | 2 +- .../ui-conversation/src/client/stores.ts | 39 +- .../client/ui-conversation/src/invariant.ts | 6 +- .../tests/chat-apply.client.spec.tsx | 124 --- .../tests/chat-store.client.spec.ts | 81 -- .../tests/context-provenance.client.spec.ts | Bin 5161 -> 5164 bytes .../conversation-assembler.client.spec.ts | 134 ++-- .../conversation-registry.client.spec.ts | 241 ++++++ .../tests/conversation-store.client.spec.ts | 57 ++ .../tests/coverage-tails.client.spec.ts | 9 + .../tests/enter-behavior-row.client.spec.tsx | 13 +- .../tests/image-labels.client.spec.ts | 45 ++ .../tests/input-bar.client.spec.tsx | 45 +- .../tests/input-machine.client.spec.ts | 4 +- .../tests/input-matrix.client.spec.tsx | 31 +- .../input-reference-submit.client.spec.ts | 22 +- .../tests/input-scenarios.client.spec.tsx | 56 +- .../tests/queue-dock.client.spec.tsx | 39 +- .../tests/selection-survival.client.spec.tsx | 138 ++-- .../service-orchestration.client.spec.ts | 33 +- .../tests/skeleton.client.spec.tsx | 165 ++-- .../tests/todo-panel.client.spec.tsx | 4 +- .../tests/views-type-chain.client.spec.tsx | 26 +- packages/client/ui-conversation/tsconfig.json | 41 +- packages/client/ui-trajectory/package.json | 25 +- .../src/client/TrajectoryTable.tsx | 2 +- .../src/client/TrajectoryView.tsx | 27 +- .../src/client/duration-store.ts | 2 +- .../client/ui-trajectory/src/client/index.ts | 44 +- .../client/ui-trajectory/src/client/layout.ts | 34 +- .../client/trajectory-assistant-definition.ts | 13 +- .../trajectory-compaction-definition.ts | 6 +- .../src/client/trajectory-contract.ts | 21 +- .../client/trajectory-definition-common.ts | 2 +- .../client/trajectory-message-definitions.ts | 12 +- .../src/client/trajectory-record.ts | 2 +- .../trajectory-request-header-definition.ts | 7 +- .../src/client/trajectory-snapshot-builder.ts | 9 +- .../src/client/trajectory-tool-definition.ts | 8 +- .../tests/client-bundle.client.spec.ts | 20 +- .../conversation-definitions.client.spec.ts | 17 +- .../tests/layout.client.spec.tsx | 38 +- .../tests/snapshot-builder.client.spec.ts | 2 +- .../ui-trajectory/tests/views.client.spec.tsx | 381 ++++++--- packages/client/ui-trajectory/tsconfig.json | 17 +- 182 files changed, 4157 insertions(+), 2903 deletions(-) delete mode 100644 packages/client/runtime/tests/conversation-registry.client.spec.ts create mode 100644 packages/client/ui-chat/package.json create mode 100644 packages/client/ui-chat/src/client/apply.ts create mode 100644 packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx rename packages/client/{ui-conversation => ui-chat}/src/client/chat/AssistantMarkdown.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/AssistantMarkdown.tsx (98%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/AssistantNodeView.tsx (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ChatNodeSeat.tsx (91%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ChatView.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ChatView.tsx (97%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/CommandNodeView.tsx (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/CompactionCommandCard.tsx (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/CompactionItem.tsx (96%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ContextBody.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ContextBody.tsx (99%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ContextInjectionRow.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ContextInjectionRow.tsx (95%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/GenericCommandCard.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/GenericCommandCard.tsx (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/MessageIconActions.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/MessageIconActions.tsx (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/MessageItem.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/MessageItem.tsx (98%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ReasoningRow.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/ReasoningRow.tsx (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/StatsLine.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/StatsLine.tsx (83%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/TurnTailNodeView.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/TurnTailNodeView.tsx (92%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/accessibility.module.css (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/message-chrome.ts (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/register-node-renderers.ts (98%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/turn-assistant.ts (80%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/use-calendar-day.ts (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/chat/use-throttled-visual-update.ts (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/contract/chat-nodes.ts (82%) create mode 100644 packages/client/ui-chat/src/client/contract/slots.ts create mode 100644 packages/client/ui-chat/src/client/contract/snapshot.ts create mode 100644 packages/client/ui-chat/src/client/contract/store.ts rename packages/client/{ui-conversation/src/client/chat => ui-chat/src/client/contract}/turn-metrics.ts (97%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/assistant.ts (94%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/chat-snapshot-builder.ts (96%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/command.ts (95%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/common.ts (97%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/compaction.ts (88%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/fallback.ts (75%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/inbox.ts (92%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/message.ts (85%) rename packages/client/{runtime/src/client/sessions => ui-chat/src/client/conversation-nodes}/partial.ts (84%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/register.ts (100%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/retry.ts (92%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/tool.ts (97%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/turn-error.ts (90%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/turn-max-tokens.ts (92%) rename packages/client/{ui-conversation => ui-chat}/src/client/conversation-nodes/turn-tail.ts (95%) rename packages/client/{ui-conversation/src/client/skeleton => ui-chat/src/client/details}/DetailsPanel.module.css (100%) rename packages/client/{ui-conversation/src/client/skeleton => ui-chat/src/client/details}/DetailsPanel.tsx (89%) rename packages/client/{ui-conversation/src/client/chat => ui-chat/src/client/details}/tool-node-reader.ts (70%) create mode 100644 packages/client/ui-chat/src/client/historical-images.ts create mode 100644 packages/client/ui-chat/src/client/index.ts create mode 100644 packages/client/ui-chat/src/client/locale.ts rename packages/client/{runtime/src/client/sessions => ui-chat/src/client/model}/conversation-context.ts (86%) rename packages/client/{runtime/src/client/sessions => ui-chat/src/client/model}/steering-history.ts (100%) rename packages/client/{runtime/src/client/sessions => ui-chat/src/client/model}/tool-call-tree.ts (99%) create mode 100644 packages/client/ui-chat/src/client/stores.ts create mode 100644 packages/client/ui-chat/src/css-modules.d.ts create mode 100644 packages/client/ui-chat/src/index.ts create mode 100644 packages/client/ui-chat/src/invariant.ts create mode 100644 packages/client/ui-chat/tests/apply-inject.client.spec.tsx create mode 100644 packages/client/ui-chat/tests/approval-command.client.spec.tsx create mode 100644 packages/client/ui-chat/tests/chat-apply.client.spec.tsx rename packages/client/{ui-conversation => ui-chat}/tests/chat-branch-tails.client.spec.tsx (99%) rename packages/client/{ui-conversation => ui-chat}/tests/chat-snapshot-fixture.client.ts (96%) rename packages/client/{ui-conversation => ui-chat}/tests/chat-stats.client.spec.tsx (90%) create mode 100644 packages/client/ui-chat/tests/chat-store.client.spec.ts rename packages/client/{ui-conversation => ui-chat}/tests/chat-view.client.spec.tsx (90%) rename packages/client/{ui-conversation => ui-chat}/tests/conversation-node-definitions.client.spec.ts (97%) rename packages/client/{runtime => ui-chat}/tests/conversation.client.spec.ts (97%) rename packages/client/{ui-conversation => ui-chat}/tests/coverage-tails.client.spec.tsx (89%) rename packages/client/{ui-conversation => ui-chat}/tests/gate-branch-tails.client.spec.tsx (71%) create mode 100644 packages/client/ui-chat/tests/historical-images.client.spec.ts rename packages/client/{ui-conversation => ui-chat}/tests/image-labels.client.spec.tsx (55%) rename packages/client/{runtime => ui-chat}/tests/partial.client.spec.ts (98%) rename packages/client/{ui-conversation => ui-chat}/tests/reasoning-row.client.spec.tsx (98%) create mode 100644 packages/client/ui-chat/tests/selection-survival.client.spec.tsx rename packages/client/{runtime => ui-chat}/tests/tool-call-tree.client.spec.ts (97%) rename packages/client/{ui-conversation => ui-chat}/tests/turn-metrics.client.spec.ts (97%) create mode 100644 packages/client/ui-chat/tests/views-type-chain.client.spec.tsx create mode 100644 packages/client/ui-chat/tsconfig.json create mode 100644 packages/client/ui-chat/tsdown.config.ts create mode 100644 packages/client/ui-conversation/src/client/browser-bytes.ts create mode 100644 packages/client/ui-conversation/src/client/context-occupancy.ts create mode 100644 packages/client/ui-conversation/src/client/contract/composer-blocks.ts rename packages/client/{runtime/src/client/sessions => ui-conversation/src/client/contract}/context-provenance.ts (98%) rename packages/client/{runtime => ui-conversation}/src/client/contract/conversation.ts (96%) rename packages/client/ui-conversation/src/client/{input/contract.ts => contract/input.ts} (73%) rename packages/client/{runtime/src/client/sessions/conversation.ts => ui-conversation/src/client/contract/records.ts} (62%) rename packages/client/{runtime/src/client/sessions => ui-conversation/src/client/contract}/request-inspection.ts (98%) create mode 100644 packages/client/ui-conversation/src/client/contract/snapshot.ts rename packages/client/{runtime/src/client/sessions/conversation-assembler.ts => ui-conversation/src/client/conversation/assembler.ts} (98%) create mode 100644 packages/client/ui-conversation/src/client/conversation/assembly.ts rename packages/client/{runtime/src/client/sessions => ui-conversation/src/client/conversation}/assistant-timing.ts (92%) rename packages/client/{runtime => ui-conversation}/src/client/conversation/definition-registry.ts (81%) rename packages/client/{runtime => ui-conversation}/src/client/conversation/event-registry.ts (84%) rename packages/client/{runtime/src/client/sessions => ui-conversation/src/client/conversation}/failure-display.ts (100%) rename packages/client/{runtime/src/client/sessions/conversation-location-index.ts => ui-conversation/src/client/conversation/location-index.ts} (100%) rename packages/client/{runtime => ui-conversation}/src/client/conversation/view-registry.ts (74%) rename packages/client/ui-conversation/src/client/{queue/store.ts => input/queue-store.ts} (76%) create mode 100644 packages/client/ui-conversation/src/client/pending-composer.ts delete mode 100644 packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css delete mode 100644 packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx rename packages/client/ui-conversation/src/client/{reference => skeleton}/ReferenceIcon.tsx (100%) rename packages/client/ui-conversation/src/client/{input => skeleton}/decorations.ts (98%) delete mode 100644 packages/client/ui-conversation/tests/chat-apply.client.spec.tsx delete mode 100644 packages/client/ui-conversation/tests/chat-store.client.spec.ts rename packages/client/{runtime => ui-conversation}/tests/context-provenance.client.spec.ts (97%) rename packages/client/{runtime => ui-conversation}/tests/conversation-assembler.client.spec.ts (92%) create mode 100644 packages/client/ui-conversation/tests/conversation-registry.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/conversation-store.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/coverage-tails.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/image-labels.client.spec.ts diff --git a/packages/client/runtime/tests/conversation-registry.client.spec.ts b/packages/client/runtime/tests/conversation-registry.client.spec.ts deleted file mode 100644 index b7c9d149bc..0000000000 --- a/packages/client/runtime/tests/conversation-registry.client.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' -import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' -import type { - ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, -} from '../src/client/contract/conversation.ts' -import { Session } from '../src/client/sessions/session.ts' -import { SessionRuntime } from '../src/client/sessions/service.ts' -import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' - -function eventDefinition(kind: string): ConversationNodeDefinition { - return { - kind, - target: 'chat', - match: () => null, - start: () => null, - update: context => context.state, - buildViewNode: () => null, - } -} - -function viewDefinition(target: string): ConversationViewDefinition { - return { - target, - create: () => ({ - empty: null, - replace: () => null, - apply: () => null, - }), - } -} - -async function bootRegistries(): Promise<{ - ctx: Context - events: ConversationEventRegistry - views: ConversationViewRegistry -}> { - const ctx = new Context() - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() - const events = ctx.get('conversationEvents') as ConversationEventRegistry - const views = ctx.get('conversationViews') as ConversationViewRegistry - return { ctx, events, views } -} - -describe('Conversation registries', () => { - it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => { - const { events } = await bootRegistries() - const definition = eventDefinition('message') - const dispose = events.register(definition) - - expect(events.entries()).toEqual([definition]) - expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/) - - dispose() - dispose() - expect(events.entries()).toEqual([]) - }) - - it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => { - const { events } = await bootRegistries() - const fallback = eventDefinition('unknown') - const dispose = events.registerFallback(fallback) - - expect(events.fallbackEntry()).toBe(fallback) - expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/) - - dispose() - dispose() - expect(events.fallbackEntry()).toBeUndefined() - }) - - it('rejects rendering Definitions that omit either target or builder', async () => { - const { events } = await bootRegistries() - const targetOnly: ConversationNodeDefinition = { - kind: 'target-only', - target: 'chat', - match: () => null, - start: () => null, - update: context => context.state, - } - const builderOnly: ConversationNodeDefinition = { - kind: 'builder-only', - match: () => null, - start: () => null, - update: context => context.state, - buildViewNode: () => null, - } - - expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/) - expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/) - }) - - it('rejects a State-only Definition as the unmatched-event fallback', async () => { - const { events } = await bootRegistries() - const fallback: ConversationNodeDefinition = { - kind: 'state-only-fallback', - match: () => null, - start: () => null, - update: context => context.state, - } - - expect(() => events.registerFallback(fallback)) - .toThrow('conversation fallback Definition must declare a target') - }) - - it('rejects duplicate view targets and disposes a view registration once', async () => { - const { views } = await bootRegistries() - const definition = viewDefinition('chat') - const dispose = views.register(definition) - - expect(views.entries()).toEqual([definition]) - expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/) - - dispose() - dispose() - expect(views.entries()).toEqual([]) - }) - - it('removes Event, fallback, and view contributions with their caller fiber', async () => { - const { ctx, events, views } = await bootRegistries() - const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => { - featureCtx.conversationEvents.register(eventDefinition('message')) - featureCtx.conversationEvents.registerFallback(eventDefinition('unknown')) - featureCtx.conversationViews.register(viewDefinition('chat')) - }) - await feature.await() - - expect(events.entries()).toHaveLength(1) - expect(events.fallbackEntry()).toBeDefined() - expect(views.entries()).toHaveLength(1) - - await feature.dispose() - expect(events.entries()).toEqual([]) - expect(events.fallbackEntry()).toBeUndefined() - expect(views.entries()).toEqual([]) - }) - - it('coalesces registry changes into one rebuild of every resident Session', async () => { - const { ctx, events, views } = await bootRegistries() - const api = new FakeApiClient() - const sessionId = 'resident' as SessionId - api.onList = () => Promise.resolve(ok({ - items: [{ sessionId, updatedAt: 1, running: false, blank: true }], - }) as never) - const sessions = new SessionRuntime(ctx, api, fakeRemote(api)) - await sessions.refresh() - await Promise.resolve() - sessions.scope(sessionId) - const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') - - events.register(eventDefinition('message')) - views.register(viewDefinition('chat')) - await Promise.resolve() - - expect(rebuild).toHaveBeenCalledOnce() - rebuild.mockRestore() - }) -}) diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 9f25dc5421..71ba3edf76 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -32,7 +32,9 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } @@ -50,8 +52,9 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "react": "^18.2.0", @@ -67,8 +70,9 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^" } } diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 0d0dac02f8..c84914db54 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -1,4 +1,4 @@ -import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 616e9c7292..bcdbfb7adf 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,6 +1,8 @@ /** Browser attachment plugin: fills conversation's composer and message-image slots. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { ComposerAttachments } from './ComposerAttachments.tsx' import { MessageImages } from './MessageImages.tsx' diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 62f4887cdd..d7d37bfd97 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ImageGallery, MessageImage } from '../src/MessageImage.tsx' import type { MessageImageLabels } from '../src/MessageImage.tsx' import { MessageImages } from '../src/client/MessageImages.tsx' @@ -28,6 +29,23 @@ const attachment = { name: 'history.png', } +type AttentionSnapshot = Parameters[0]>[0] +type TrajectorySnapshot = Parameters[0]>[0] + +const noAttention: AttentionSnapshot = new Map() +const emptyTrajectory: TrajectorySnapshot = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} +const useSessionPendingInteraction: MessageImagesProps['useSessionPendingInteraction'] = selector => selector(noAttention) +const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT) +const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT) +const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory) + describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') @@ -190,8 +208,12 @@ describe('ImageGallery', () => { sessionId: 'message-images-test' as MessageImagesProps['sessionId'], useSession, useSessions, + useSessionPendingInteraction, useWorkspaces, useProjection: () => undefined, + useConversation, + useChat, + useTrajectory, useInput, inputActions: { setDraft: vi.fn(), diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 4b2ff047e8..9ca742377f 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyHost } from '../src/index.ts' import { apply, inject } from '../src/client/index.ts' import { ComposerAttachments } from '../src/client/ComposerAttachments.tsx' diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 2f5cd3a73e..0cd20ee366 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -15,7 +15,10 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../runtime" + "path": "../ui-renderer" + }, + { + "path": "../ui-chat" }, { "path": "../ui-conversation" diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json new file mode 100644 index 0000000000..4b5275de98 --- /dev/null +++ b/packages/client/ui-chat/package.json @@ -0,0 +1,115 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-chat", + "description": "Chat Conversation target, node definitions, renderers, and details surface", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-chat" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-workspace-controller/client", + "@deepseek-ai/dsh-client-ui-conversation/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts new file mode 100644 index 0000000000..8b3dcd3e01 --- /dev/null +++ b/packages/client/ui-chat/src/client/apply.ts @@ -0,0 +1,141 @@ +/** Register the Chat Conversation target, renderers, stats, and details surface. */ +import type { Context } from '@deepseek-ai/cordis' +import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +// Type-only service and declaration merges used by the apply world. +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { + ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected, + TurnTailOwnerProps, +} from './contract/slots.ts' +import type { ChatSnapshot } from './contract/snapshot.ts' +import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts' +import { ApprovalCommand } from './chat/ApprovalCommand.tsx' +import { ChatView } from './chat/ChatView.tsx' +import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' +import { StatsLine } from './chat/StatsLine.tsx' +import { registerConversationNodes } from './conversation-nodes/register.ts' +import { DetailsPanel } from './details/DetailsPanel.tsx' +import { HistoricalImageCache } from './historical-images.ts' +import { en, NS, zh } from './locale.ts' +import { createChatStore } from './stores.ts' + +const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { + hooks: { + turnData: ({ useChat }, nodeKey) => function useTurnData(key) { + return useChat((snapshot) => { + const location = snapshot.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(key) + : undefined + }) + }, + }, +} + +/** Services required by the Chat target and its presentation registrations. */ +export const inject = [ + 'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', +] + +/** + * Mount all Chat-owned contributions. + * @param ctx - Client root context. + */ +export function apply(ctx: Context): void { + const chatSources = new WeakMap>() + const chatSource = (binding: SessionBinding): ObservableSnapshot => { + let source = chatSources.get(binding) + if (source === undefined) { + const target = ctx.uiConversation.binding(binding).target('chat') + source = { + getSnapshot: () => target.getSnapshot() ?? EMPTY_CHAT_SNAPSHOT, + subscribe: listener => target.subscribe(listener), + } + chatSources.set(binding, source) + } + return source + } + registerConversationNodes(ctx) + registerChatNodeRenderers(ctx) + ctx.uiSession.provide({ + hooks: ['chat'], + resolve: binding => ({ hooks: { chat: chatSource(binding) } }), + }) + + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-chat: dictionaries') + const t = ctx.locale.bind(NS) + const chatStore = createChatStore() + const chatScrollPositions = new Map() + const images = new HistoricalImageCache(ctx) + + ctx.slots.inject('conversation.view', () => { + const disposeView = ctx.slots.register({ + name: 'conversation.view', + id: 'chat', + order: 0, + label: () => t('view.chat'), + locale: NS, + children: { + 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, + 'conversation.message.images': { kind: 'single', scope: 'session' }, + }, + store: chatStore, + inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { + const session = ctx.sessions.binding(sessionId)?.session + if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) + return { + openDetails: (target) => { + actions.select(target) + ctx.layout.openDetails() + }, + fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), + openFile: (path) => { + const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd + return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path)) + }, + loadOlder: () => { void session.loadOlder() }, + loadImage: attachment => images.resolve(sessionId, attachment), + chatScroll: { + save: (position) => { + if (position === null) chatScrollPositions.delete(sessionId) + else chatScrollPositions.set(sessionId, position) + }, + read: () => chatScrollPositions.get(sessionId) ?? null, + }, + forkAt: (seq) => { + ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true }) + .then((childId) => { ctx.sessions.open(childId) }) + .catch(() => { + // Fork or child-title failure leaves the source view unchanged. + }) + }, + } + }, + }, ChatView) + return disposeView + }) + + ctx.slots.inject('conversation.composer.dock', () => + ctx.slots.register({ + name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS, + }, StatsLine)) + + ctx.slots.inject('conversation.approval.detail', () => + ctx.slots.register({ name: 'conversation.approval.detail' }, ApprovalCommand)) + + ctx.slots.inject('details', () => ctx.slots.register({ + name: 'details', + locale: NS, + children: { 'conversation.details.tool': { kind: 'single', scope: 'session' } }, + store: chatStore, + inject: (): DetailsInjected => ({ closeDetails: () => { ctx.layout.closeDetails() } }), + }, DetailsPanel)) +} diff --git a/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx b/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx new file mode 100644 index 0000000000..32c3d3e415 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx @@ -0,0 +1,40 @@ +/** Chat-owned approval detail resolving a correlated Tool call's command. */ +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-approval/client' +import type { ChatNode } from '../contract/chat-nodes.ts' + +interface ApprovalToolCall { + readonly callId: string + readonly argsRaw: string +} + +/** + * Extract a shell command from a correlated Tool call when its arguments carry one. + * @param call - Tool call arguments, when a correlated call exists. + * @returns command text, or undefined for absent, malformed, or unrelated arguments. + */ +export function commandOf(call: ApprovalToolCall | undefined): string | undefined { + if (call === undefined) return undefined + try { + const args = JSON.parse(call.argsRaw) as Record + return typeof args.command === 'string' ? args.command : undefined + } catch { + return undefined + } +} + +/** + * Render the command of the Chat Tool node correlated with an approval. + * @param props - Approval identity and Session-standard Chat selector hook. + * @returns command text when the correlated call carries one. + */ +export function ApprovalCommand({ callId, useChat }: PropsRuntime<'conversation.approval.detail'>) { + const command = useChat((snapshot) => { + for (const node of snapshot.nodes.values()) { + const root = node.kind === 'tool-call' ? (node as ChatNode<'tool-call'>).data.root : undefined + if (root !== undefined && root.callId === callId && !('kind' in root)) return commandOf(root) + } + return undefined + }) + return command ?? null +} diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css rename to packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx similarity index 98% rename from packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx rename to packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index d47c8e12d0..208874d655 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,9 +1,9 @@ import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' -import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { AssistantBlock } from '../contract/snapshot.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx similarity index 100% rename from packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx rename to packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx similarity index 91% rename from packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx rename to packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index bc9c96fd41..22a6d0dc35 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -6,7 +6,7 @@ import css from './ChatView.module.css' interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string - readonly useSession: ChatViewSlotProps['useSession'] + readonly useChat: ChatViewSlotProps['useChat'] readonly renderSlot: ChatViewSlotProps['renderSlot'] readonly t: ChatViewSlotProps['t'] } @@ -18,9 +18,9 @@ type RoutedChatNodeOwner = { /** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, - renderMessageImages, fileMentions, useSession, renderSlot, t, + renderMessageImages, fileMentions, useChat, renderSlot, t, }: ChatNodeSeatProps) { - const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) + const node = useChat(snapshot => snapshot.nodes.get(nodeKey)) const routedNode = node as ChatNode | undefined const owner = useMemo(() => node === undefined ? null diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/ChatView.module.css rename to packages/client/ui-chat/src/client/chat/ChatView.module.css diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx similarity index 97% rename from packages/client/ui-conversation/src/client/chat/ChatView.tsx rename to packages/client/ui-chat/src/client/chat/ChatView.tsx index 8964a32482..e835273107 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -2,7 +2,7 @@ // otherwise this view owns it. Each row subscribes to one stable node key. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' @@ -144,12 +144,12 @@ function TurnStatus({ startTime, t }: { * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, inspectCall, chatScroll, forkAt, + useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, fileMentions, t, }: ChatViewSlotProps) { - const order = useSession(s => s.chat.order) - const nodeStore = useSession(s => s.chat.nodes) - const timeline = useSession(s => s.chat.timeline) + const order = useChat(s => s.order) + const nodeStore = useChat(s => s.nodes) + const timeline = useChat(s => s.timeline) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -159,6 +159,9 @@ export function ChatView({ const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) + const inspectCall = useCallback((callId: string) => { + openView('trajectory', callId) + }, [openView]) const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) const [fileOpenBusy, setFileOpenBusy] = useState(false) // Close/retry must ignore a settlement that started before the latest @@ -421,7 +424,7 @@ export function ChatView({ () let steps = 0 let llmMs = 0 @@ -170,46 +174,16 @@ export function billedInputTokens(usage: TokenUsageProjection): number { return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens } -interface ContextOccupancy { - percent: number - usedTokens: number - contextWindow: number -} - -/** - * Approximate context occupancy, using the TUI's integer rounding and upper - * clamp. The numerator is `projectedTokens` — the provider sample carried - * forward over the surface's movement since — so compaction shows immediately - * instead of waiting for the next request to report usage; it falls back to the - * bare sample only for a log whose projection predates that field. Numerator - * and capacity remain independent last-wins projection fields, so this is a - * reference figure rather than an exact measurement of one request (see the - * token-meter README). - * @param pressure - the session's context-pressure projection value. - * @returns occupancy with its numerator and denominator, or null until both values are known. - */ -export function contextOccupancy( - pressure: ContextPressureProjection | undefined, -): ContextOccupancy | null { - const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens - if (usedTokens === undefined || pressure?.contextWindow === undefined) return null - return { - percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)), - usedTokens, - contextWindow: pressure.contextWindow, - } -} - /** Props: the conversation-snapshot selector plus the projection read seat. */ export interface StatsLineProps { - useSession: SnapshotSelectorHook + useChat: SnapshotSelectorHook useProjection: UseProjection /** The owning dock's locale seat. */ - t: ComposerBarProps['t'] + t: ChatViewSlotProps['t'] } -export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { - const settledNodes = useSession(s => s.chat.legacy.nodes) +export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: StatsLineProps) { + const settledNodes = useChat(s => s.legacy.nodes) const usage = useProjection('tokenUsage') // Every figure rides the durable sessionStats projection, so paging and // compaction cannot change any of them; an assembly without the unit falls diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css rename to packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx similarity index 92% rename from packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx rename to packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index d4d27570f9..3fd7619a49 100644 --- a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -10,11 +10,11 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession, + node, openFile, forkAt, renderSlot, renderSlotChain, t, useChat, }: TurnTailNodeViewProps) { const data = node.data - const hasLaterChatNode = useSession(snapshot => - snapshot.chat.locations.getTurn(data.turn).at(-1) !== node.key) + const hasLaterChatNode = useChat(snapshot => + snapshot.locations.getTurn(data.turn).at(-1) !== node.key) const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined diff --git a/packages/client/ui-conversation/src/client/chat/accessibility.module.css b/packages/client/ui-chat/src/client/chat/accessibility.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/accessibility.module.css rename to packages/client/ui-chat/src/client/chat/accessibility.module.css diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-chat/src/client/chat/message-chrome.ts similarity index 100% rename from packages/client/ui-conversation/src/client/chat/message-chrome.ts rename to packages/client/ui-chat/src/client/chat/message-chrome.ts diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts similarity index 98% rename from packages/client/ui-conversation/src/client/chat/register-node-renderers.ts rename to packages/client/ui-chat/src/client/chat/register-node-renderers.ts index ed311e4b74..826e1344f2 100644 --- a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -1,5 +1,5 @@ import type { Context } from '@deepseek-ai/cordis' -import { NS } from '../locales.ts' +import { NS } from '../locale.ts' import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' import { diff --git a/packages/client/ui-conversation/src/client/chat/turn-assistant.ts b/packages/client/ui-chat/src/client/chat/turn-assistant.ts similarity index 80% rename from packages/client/ui-conversation/src/client/chat/turn-assistant.ts rename to packages/client/ui-chat/src/client/chat/turn-assistant.ts index 2abfff56b3..90e075d79a 100644 --- a/packages/client/ui-conversation/src/client/chat/turn-assistant.ts +++ b/packages/client/ui-chat/src/client/chat/turn-assistant.ts @@ -1,4 +1,4 @@ -import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantBlock } from '../contract/snapshot.ts' /** * Collect visible prose from one Assistant lifecycle. diff --git a/packages/client/ui-conversation/src/client/chat/use-calendar-day.ts b/packages/client/ui-chat/src/client/chat/use-calendar-day.ts similarity index 100% rename from packages/client/ui-conversation/src/client/chat/use-calendar-day.ts rename to packages/client/ui-chat/src/client/chat/use-calendar-day.ts diff --git a/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts b/packages/client/ui-chat/src/client/chat/use-throttled-visual-update.ts similarity index 100% rename from packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts rename to packages/client/ui-chat/src/client/chat/use-throttled-visual-update.ts diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts similarity index 82% rename from packages/client/ui-conversation/src/client/contract/chat-nodes.ts rename to packages/client/ui-chat/src/client/contract/chat-nodes.ts index c057d15d1b..b4f8605c4f 100644 --- a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -1,7 +1,18 @@ import type { - AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode, - CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationViewNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + AssistantBlock, AssistantMessageNode, CommandNode, CompactionSummaryNode, + ModelRetryNode, RunningToolCall, ToolCallBlock, +} from './snapshot.ts' + +/** Final Chat render unit produced by a Chat business Definition. */ +export interface ChatConversationViewNode extends ConversationViewNode { + readonly target: 'chat' + readonly anchorSeq: number + readonly location: ConversationLocation + readonly visibility: 'visible' | 'hidden' +} /** Merge-extensible payload registry keyed by final Chat renderer kind. */ export interface ChatNodeDataMap {} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts new file mode 100644 index 0000000000..5009781fd1 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -0,0 +1,205 @@ +/** Chat-owned Slot declarations and composed component props. */ +import type { ReactNode } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { + ConversationTurnDataMap, TurnLocation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, + SnapshotSelectorHook, +} from '@deepseek-ai/dsh-client-ui-slots' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { createChatStore } from '../stores.ts' +import type { CallId, SelectionTarget } from './store.ts' +import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' +import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts' + +/** Selector hook over the current Conversation binding's Chat target. */ +export type UseChat = SnapshotSelectorHook + +/** Historical image group handed to the optional attachment presentation plugin. */ +export interface MessageImagesOwnerProps { + images: readonly { readonly attachment: ImageAttachmentRef }[] + loadImage: (attachment: ImageAttachmentRef) => Promise + align: 'start' | 'end' +} + +/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */ +export type RenderMessageImages = (owner: Omit) => ReactNode + +/** Owner currency of the completed-Turn extension chain. */ +export interface TurnTailOwnerProps { + turn: TurnLocation + seq: number + openFile: (path: string) => void +} + +/** Owner currency of finalized-assistant actions. */ +export interface AssistantActionOwnerProps { + messageId: MessageId +} + +/** Optional prose file-mention provider consumed by Chat. */ +export interface ChatFileMentions { + /** + * Resolve prose links for one closing Turn. + * @param owner - closing-Turn identity and file opener. + * @returns link resolver when available. + */ + forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Optional prose file-mention provider. */ + chatFileMentions: ChatFileMentions + } +} + +/** Hook constrained to business data published on the current Chat Node's Turn. */ +export type UseChatNodeTurnData = >( + key: Key, +) => Readonly | undefined + +/** Slot-level Hook factory for keyed Chat renderers. */ +export interface ChatNodeTurnDataInjected { + hooks: { turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> } +} + +/** Stable owner currency delivered to a keyed Chat renderer. */ +export interface ChatNodeOwnerProps { + selectedCallId?: CallId | undefined + cwd?: string | undefined + openFile: (path: string) => void + inspectCall: (callId: CallId) => void + forkAt: (seq: number) => void + renderMessageImages: RenderMessageImages + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined +} + +/** Full props of one keyed Chat renderer. */ +export type ChatNodeViewProps = + PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'> + +/** Tool block rendered in the details panel. */ +export interface DetailsToolOwnerProps { + block: ToolCallBlock + cwd?: string | undefined +} + +/** Command-row owner share. */ +export interface CommandRowOwnerProps { + node: CommandNode + compaction?: CompactionSummaryNode +} + +/** Full props of a registered command row. */ +export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'> + +/** Shared Chat store handle. */ +export type ChatStore = ReturnType + +/** In-memory reader position resilient to transcript reflow. */ +export interface ChatScrollPosition { + readonly anchorKey: string + readonly anchorTop: number + readonly scrollTop: number +} + +/** Business callbacks injected into the Chat view. */ +export interface ChatViewInjected { + openDetails: (target: SelectionTarget) => void + openFile: (path: string) => Promise + loadOlder: () => void + loadImage: (attachment: ImageAttachmentRef) => Promise + chatScroll: { + save: (position: ChatScrollPosition | null) => void + read: () => ChatScrollPosition | null + } + forkAt: (seq: number) => void + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined +} + +/** Full Chat view props. */ +export type ChatViewSlotProps = + PropsRuntime<'conversation.view'> + & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'> + & PropsStore + & InjectFace + & PropsLocale<'chat'> + +/** Full props of the durable-message image renderer. */ +export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'> + +/** Details-panel callbacks. */ +export interface DetailsInjected { + closeDetails: () => void +} + +/** Full details-panel props. */ +export type DetailsSlotProps = + PropsRuntime<'details'> + & PropsRenderSlots<'conversation.details.tool'> + & PropsStore + & InjectFace + & PropsLocale<'chat'> + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SessionStandardProps { + /** Selector hook over the current Conversation binding's Chat target. */ + useChat: UseChat + } + + interface LocaleNamespaceMap { + /** Chat target, transcript node, statistics, and details copy. */ + chat: import('../locale.ts').ChatKey + } + + interface SlotMap { + /** + * Final Chat node renderer, keyed by `ChatNodeKind`. The component receives + * the typed node, shared Chat actions, and Turn-data hook. Reusing a key + * replaces that node renderer; a kind with no occupant renders no row. + */ + 'conversation.chat.node': { + kind: 'keyed' + scope: 'session' + owner: ChatNodeOwnerProps + keyProps: { [Kind in ChatNodeKind]: { node: ChatNode } } + hookContext: string + inject: ChatNodeTurnDataInjected + } + /** + * Renderer for one consecutive group of durable message images. The owner + * supplies image references, an authorized loader, and alignment. A + * registration replaces the shipped gallery; without one, images are omitted. + */ + 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + /** + * Command row keyed by the command name. The component receives the folded + * command lifecycle and linked compaction when present. Reusing a key + * replaces that command renderer; an unoccupied key uses the generic card. + */ + 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } + /** + * Selector-routed extension before a completed Turn's action row. The + * component receives the Turn, closing sequence, and file opener. The first + * selector that accepts the owner renders; an all-declined chain is empty. + */ + 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } + /** + * Ordered actions for one finalized assistant message. Each entry receives + * the durable message id; a fresh `id` adds an action and reusing one replaces + * that entry. With no entries, the standard action row remains unchanged. + */ + 'conversation.chat.assistant-actions': { kind: 'list'; scope: 'session'; owner: AssistantActionOwnerProps } + /** + * Whole details-panel body for the selected Tool call. The component receives + * the running or settled block and optional workspace root. A registration + * replaces the shipped Tool details renderer; absence uses the raw fallback. + */ + 'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps } + } +} diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts new file mode 100644 index 0000000000..9a938cfa09 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -0,0 +1,79 @@ +import type { + ConversationNode, ConversationTimelineSnapshot, PartialAssistant, RunningToolCall, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from './chat-nodes.ts' + +export type { + AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, + AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, + ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, + ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, + UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +export { + emptyAssistantBlock, toAssistantBlock, toAssistantBlocks, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/** Stable live per-key reader for Chat nodes. */ +export interface ChatNodeStore { + /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ + get(key: string): ChatConversationViewNode | undefined + /** @returns all currently materialized Nodes without imposing render order. */ + values(): readonly ChatConversationViewNode[] +} + +/** Stable live Location index for Chat nodes. */ +export interface ChatLocationNodeIndex { + /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ + getTurn(turn: number): readonly string[] + /** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */ + getStep(turn: number, step: number): readonly string[] +} + +/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */ +export interface LegacyConversationSlice { + readonly nodes: readonly ConversationNode[] + readonly turnTimings: ReadonlyMap + readonly turnEnds: ReadonlyMap + readonly partial: PartialAssistant | null + readonly runningCalls: readonly RunningToolCall[] +} + +/** Incremental Chat publication with immutable order and stable live keyed readers. */ +export interface ChatSnapshot { + readonly order: readonly string[] + readonly nodes: ChatNodeStore + readonly locations: ChatLocationNodeIndex + readonly timeline: ConversationTimelineSnapshot + readonly legacy: LegacyConversationSlice +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationViewSnapshotMap { + chat: ChatSnapshot + } +} + +const EMPTY_LIST: readonly never[] = [] +const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } + +/** Empty Chat target used before a view builder is registered. */ +export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { + order: EMPTY_LIST, + nodes: { + get: () => undefined, + values: () => EMPTY_LIST, + }, + locations: { + getTurn: () => EMPTY_LIST, + getStep: () => EMPTY_LIST, + }, + timeline: EMPTY_TIMELINE, + legacy: { + nodes: EMPTY_LIST, + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: EMPTY_LIST, + }, +} diff --git a/packages/client/ui-chat/src/client/contract/store.ts b/packages/client/ui-chat/src/client/contract/store.ts new file mode 100644 index 0000000000..41a3e58052 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/store.ts @@ -0,0 +1,17 @@ +/** Chat-owned selection state shared by the transcript and details panel. */ + +/** Tool call identity as carried by Chat nodes. */ +export type CallId = string + +/** Selection target for the Chat details linkage channel. */ +export interface SelectionTarget { + turnSeq: number + stepSeq?: number + callId?: CallId + toolName?: string +} + +/** Per-Session state shared only by the Chat view and details surface. */ +export interface ChatStoreState { + selection: SelectionTarget | null +} diff --git a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts b/packages/client/ui-chat/src/client/contract/turn-metrics.ts similarity index 97% rename from packages/client/ui-conversation/src/client/chat/turn-metrics.ts rename to packages/client/ui-chat/src/client/contract/turn-metrics.ts index b93bbfc8eb..2f8f0eabbc 100644 --- a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts +++ b/packages/client/ui-chat/src/client/contract/turn-metrics.ts @@ -1,6 +1,6 @@ // Latency/throughput folds shared by the settled turn footer and StatsLine. -import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantMessageNode, ConversationNode } from './snapshot.ts' /** Latency and decode-throughput readings for one turn's footer. */ export interface TurnMetrics { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts similarity index 94% rename from packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts rename to packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index df8f87ee53..7eb11d7ab9 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -1,23 +1,24 @@ import type { Context } from '@deepseek-ai/cordis' import type { - AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, - ConversationNodeContext, ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import { - emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' import type {} from '@deepseek-ai/dsh-llm-retry/types' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { AssistantChatData } from '../contract/chat-nodes.ts' +import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts' +import { toAssistantBlock, toAssistantBlocks } from '../contract/snapshot.ts' +import { emptyAssistantBlock } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Streaming, settled, or interrupted Assistant step. */ 'assistant-step': AssistantChatData } } -declare module '@deepseek-ai/dsh-client-runtime/client' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationStepDataMap { /** Streaming, settled, or interrupted Assistant material for this Step. */ 'assistant-step': AssistantChatData @@ -314,5 +315,5 @@ export const assistantDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerAssistantConversationNode(ctx: Context): void { - ctx.conversationEvents.register(assistantDefinition) + ctx.uiConversation.events.register(assistantDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts similarity index 96% rename from packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts rename to packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 651fc780a3..ce55ef45e9 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,13 +1,15 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, - ConversationLocation, ConversationNode, ConversationTimelineSnapshot, - ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice, - PartialAssistant, RunningToolCall, -} from '@deepseek-ai/dsh-client-runtime/client' -import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatNode } from '../contract/chat-nodes.ts' + ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder, + ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' +import type { + ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode, + LegacyConversationSlice, PartialAssistant, RunningToolCall, +} from '../contract/snapshot.ts' +import { sessionRecallLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' const EMPTY_KEYS: readonly string[] = [] const EMPTY_TURNS: readonly number[] = [] @@ -542,10 +544,11 @@ function locationIdentity(location: ConversationLocation): string { return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}` } -/** Chat target factory contributed to the Runtime view registry. */ +/** Chat target factory contributed to the Conversation view registry. */ export const chatViewDefinition: ConversationViewDefinition = { target: 'chat', create: () => new ChatSnapshotBuilder(), + isActive: snapshot => snapshot.order.some(key => snapshot.nodes.get(key)?.kind !== 'command'), } /** @@ -553,5 +556,5 @@ export const chatViewDefinition: ConversationViewDefinition = { * @param ctx - owning UI Conversation context. */ export function registerCommandConversationNode(ctx: Context): void { - ctx.conversationEvents.register(commandDefinition) + ctx.uiConversation.events.register(commandDefinition) } /** Shared structural checkpoint recognizer for automatic compaction. */ diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-chat/src/client/conversation-nodes/common.ts similarity index 97% rename from packages/client/ui-conversation/src/client/conversation-nodes/common.ts rename to packages/client/ui-chat/src/client/conversation-nodes/common.ts index 1e2693bfff..fd63c47cdd 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/common.ts @@ -1,6 +1,6 @@ import type { ConversationLocation, ConversationNodeContext, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNode, ChatNodeDataMap, ChatNodeKind, } from '../contract/chat-nodes.ts' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts similarity index 88% rename from packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts rename to packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 41f58f35eb..5874c7bd61 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,12 +1,13 @@ import type { Context } from '@deepseek-ai/cordis' import type { - CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' +import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Automatic compaction checkpoint marker. */ compaction: CompactionSummaryNode @@ -61,5 +62,5 @@ export const compactionDefinition: ConversationNodeDefinition = * @param ctx - owning UI Conversation context. */ export function registerCompactionConversationNode(ctx: Context): void { - ctx.conversationEvents.register(compactionDefinition) + ctx.uiConversation.events.register(compactionDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts similarity index 75% rename from packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts rename to packages/client/ui-chat/src/client/conversation-nodes/fallback.ts index 79bc97e636..356b46ed62 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts @@ -1,11 +1,10 @@ import type { Context } from '@deepseek-ai/cordis' -import type { - ConversationNodeDefinition, UnknownSurfaceNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { UnknownSurfaceNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Generic presentation of an unclaimed append-surface event. */ unknown: UnknownSurfaceNode @@ -37,5 +36,5 @@ export const unknownFallbackDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerMessageConversationNode(ctx: Context): void { - ctx.conversationEvents.register(messageDefinition) + ctx.uiConversation.events.register(messageDefinition) } diff --git a/packages/client/runtime/src/client/sessions/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts similarity index 84% rename from packages/client/runtime/src/client/sessions/partial.ts rename to packages/client/ui-chat/src/client/conversation-nodes/partial.ts index 478b5b2791..cc94609bb3 100644 --- a/packages/client/runtime/src/client/sessions/partial.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts @@ -1,6 +1,6 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' -import type { AssistantBlock, PartialAssistant } from './conversation.ts' -import { toAssistantBlock } from './conversation.ts' +import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts' +import { emptyAssistantBlock, toAssistantBlock } from '../contract/snapshot.ts' /** * Whether a stream chunk changes the partial assistant projection shown by the UI. @@ -97,17 +97,3 @@ export class PartialAccumulator { return this.snapshot } } - -/** - * Create the empty client projection for one streamed Assistant block kind. - * @param blockType - wire block kind. - * @returns empty projected block ready to receive deltas. - */ -export function emptyAssistantBlock(blockType: string): AssistantBlock { - switch (blockType) { - case 'text': return { kind: 'text', text: '' } - case 'reasoning': return { kind: 'reasoning', text: '' } - case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' } - default: return { kind: 'other', block: null } - } -} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-chat/src/client/conversation-nodes/register.ts similarity index 100% rename from packages/client/ui-conversation/src/client/conversation-nodes/register.ts rename to packages/client/ui-chat/src/client/conversation-nodes/register.ts diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts similarity index 92% rename from packages/client/ui-conversation/src/client/conversation-nodes/retry.ts rename to packages/client/ui-chat/src/client/conversation-nodes/retry.ts index 4a0f9f9fed..5209e1dbc2 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -1,12 +1,13 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationNodeDefinition, ModelRetryNode, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' +import type { ModelRetryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Producer-correlated model retry chain. */ 'model-retry': RetryChatData @@ -93,5 +94,5 @@ export const retryDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerRetryConversationNode(ctx: Context): void { - ctx.conversationEvents.register(retryDefinition) + ctx.uiConversation.events.register(retryDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts similarity index 97% rename from packages/client/ui-conversation/src/client/conversation-nodes/tool.ts rename to packages/client/ui-chat/src/client/conversation-nodes/tool.ts index 0d6fb57cf3..20ce57831f 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,14 +1,14 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, - RunningToolCall, ToolCallBlock, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' +import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Root Tool lifecycle with recursively nested subcalls. */ 'tool-call': ToolChatData @@ -273,5 +273,5 @@ export const toolDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerToolConversationNode(ctx: Context): void { - ctx.conversationEvents.register(toolDefinition) + ctx.uiConversation.events.register(toolDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts similarity index 90% rename from packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts rename to packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index f2d6f6fc26..cee7c5c9dc 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -1,11 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client' + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TurnErrorNode } from '../contract/snapshot.ts' +import { displayFailureMessage } from '@deepseek-ai/dsh-client-ui-conversation/client' import { chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Terminal turn failure recorded on the turn's end reason. */ 'turn-error': TurnErrorNode @@ -92,5 +93,5 @@ export const turnErrorDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerTurnErrorConversationNode(ctx: Context): void { - ctx.conversationEvents.register(turnErrorDefinition) + ctx.uiConversation.events.register(turnErrorDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts similarity index 92% rename from packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts rename to packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index baf83a1b3c..31508ab77c 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -1,10 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TurnMaxTokensNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Turn ended by the per-request output-token cap. */ 'turn-max-tokens': TurnMaxTokensNode @@ -78,5 +79,5 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerTurnTailConversationNode(ctx: Context): void { - ctx.conversationEvents.register(turnTailDefinition) + ctx.uiConversation.events.register(turnTailDefinition) } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-chat/src/client/details/DetailsPanel.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css rename to packages/client/ui-chat/src/client/details/DetailsPanel.module.css diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx similarity index 89% rename from packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx rename to packages/client/ui-chat/src/client/details/DetailsPanel.tsx index 33a6194a53..d3109076f8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx @@ -1,9 +1,9 @@ import { Fragment } from 'react' import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import { shallowEqual } from '@deepseek-ai/dsh-client-store' import type { DetailsSlotProps } from '../contract/slots.ts' -import { findToolCall } from '../chat/tool-node-reader.ts' +import type { ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' +import { findToolCall } from './tool-node-reader.ts' import css from './DetailsPanel.module.css' export type DetailsPanelProps = DetailsSlotProps @@ -23,7 +23,7 @@ function runningMaterial(call: RunningToolCall): CallMaterial { return { name: call.name, argsRaw: call.argsRaw, block: call } } -function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { +function materialFor(s: ChatSnapshot, callId: string): CallMaterial | null { const found = findToolCall(s, callId) if (found === undefined) return null return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) @@ -45,7 +45,7 @@ function rawResultText(block: ToolCallBlock): string { return parts.join('\n') } -export function DetailsPanel({ useSession, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) { +export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) { const selection = useStore(s => s.selection) // Session workspace root: an omitted or relative terminal cwd resolves // against it, which the pure presenter cannot see. @@ -53,7 +53,7 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, ren const callId = selection?.callId // materialFor builds a fresh wrapper; shallowEqual short-circuits on its // stable members (result node reference rides the snapshot's structural sharing). - const material = useSession( + const material = useChat( s => (callId === undefined ? null : materialFor(s, callId)), (a, b) => shallowEqual(a, b)) diff --git a/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts b/packages/client/ui-chat/src/client/details/tool-node-reader.ts similarity index 70% rename from packages/client/ui-conversation/src/client/chat/tool-node-reader.ts rename to packages/client/ui-chat/src/client/details/tool-node-reader.ts index dca9992b8d..690cea34bc 100644 --- a/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts +++ b/packages/client/ui-chat/src/client/details/tool-node-reader.ts @@ -1,10 +1,8 @@ -import type { - ConversationSnapshot, ToolCallBlock, -} from '@deepseek-ai/dsh-client-runtime/client' -import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' +import { conversationContextKey } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts' -function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { +function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined } @@ -15,10 +13,10 @@ function toolNode(node: ReturnType * @returns root lifecycle when it is materialized in the current window. */ export function rootToolCall( - snapshot: ConversationSnapshot, + snapshot: ChatSnapshot, rootCallId: string, ): ToolCallBlock | undefined { - return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root + return toolNode(snapshot.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root } /** @@ -27,7 +25,7 @@ export function rootToolCall( * @param callId - root or nested call identity. * @returns current Tool lifecycle when materialized in the loaded window. */ -export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined { +export function findToolCall(snapshot: ChatSnapshot, callId: string): ToolCallBlock | undefined { const visit = (block: ToolCallBlock): ToolCallBlock | undefined => { if (block.callId === callId) return block for (const child of block.subCalls) { @@ -36,7 +34,7 @@ export function findToolCall(snapshot: ConversationSnapshot, callId: string): To } return undefined } - for (const node of snapshot.chat.nodes.values()) { + for (const node of snapshot.nodes.values()) { const root = toolNode(node)?.data.root if (root === undefined) continue const found = visit(root) diff --git a/packages/client/ui-chat/src/client/historical-images.ts b/packages/client/ui-chat/src/client/historical-images.ts new file mode 100644 index 0000000000..6203d4343e --- /dev/null +++ b/packages/client/ui-chat/src/client/historical-images.ts @@ -0,0 +1,107 @@ +/** Session-scoped historical image URL cache owned by the Chat plugin. */ +import type { Context } from '@deepseek-ai/cordis' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import { bytesToBase64 } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +interface ImageUrlEntry { + readonly sessionId: SessionId + readonly generation: number + readonly pending: Promise +} + +/** Resolve durable Chat images and release their browser URLs with Session scope. */ +export class HistoricalImageCache { + private readonly sessions: ISessions + private readonly entries = new Map() + private readonly generations = new Map() + private readonly scopeDisposers = new Map void>() + private readonly urls = new Set() + private disposed = false + + /** + * @param ctx - Owning ui-chat fiber. + */ + constructor(ctx: Context) { + this.sessions = ctx.sessions + ctx.effect(() => () => { this.dispose() }, 'ui-chat historical image cache') + } + + /** + * Resolve and cache one session-authorized image URL. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference. + * @returns browser URL valid until the Session binding is released. + */ + resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + if (this.disposed) return Promise.reject(new Error('ui-chat image cache is disposed')) + const key = `${sessionId}:${attachment.attachmentId}` + const cached = this.entries.get(key) + if (cached !== undefined) return cached.pending + const binding = this.sessions.binding(sessionId) + if (binding === undefined) { + return Promise.reject(new Error(`ui-chat: unknown session "${sessionId}"`)) + } + this.bindScope(sessionId, binding.ctx) + const generation = this.generations.get(sessionId) ?? 0 + const pending = binding.session.readAttachment(attachment.attachmentId) + .then((result) => { + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + if (this.disposed) throw new Error('ui-chat image cache was disposed before loading completed') + if ((this.generations.get(sessionId) ?? 0) !== generation) { + throw new Error('ui-chat image scope was released before loading completed') + } + if (typeof URL.createObjectURL !== 'function') { + return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + } + const bytes = Uint8Array.from(result.value.data) + const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + this.urls.add(url) + return url + }) + .catch((error: unknown) => { + if (this.entries.get(key)?.generation === generation) this.entries.delete(key) + throw error + }) + this.entries.set(key, { sessionId, generation, pending }) + return pending + } + + private bindScope(sessionId: SessionId, scope: Context): void { + if (this.scopeDisposers.has(sessionId)) return + const dispose = scope.effect(() => () => { + this.scopeDisposers.delete(sessionId) + this.release(sessionId) + }, 'ui-chat historical image scope') + this.scopeDisposers.set(sessionId, () => { void dispose() }) + } + + private release(sessionId: SessionId): void { + this.generations.set(sessionId, (this.generations.get(sessionId) ?? 0) + 1) + for (const [key, entry] of this.entries) { + if (entry.sessionId !== sessionId) continue + this.entries.delete(key) + void entry.pending.then((url) => { + if (!this.urls.delete(url)) return + revokeUrl(url) + }, () => { + // Failed and invalidated loads create no browser URL. + }) + } + } + + private dispose(): void { + if (this.disposed) return + this.disposed = true + for (const dispose of [...this.scopeDisposers.values()]) dispose() + this.scopeDisposers.clear() + for (const url of this.urls) revokeUrl(url) + this.urls.clear() + this.entries.clear() + } +} + +function revokeUrl(url: string): void { + if (url.startsWith('blob:')) URL.revokeObjectURL(url) +} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts new file mode 100644 index 0000000000..9c82da7564 --- /dev/null +++ b/packages/client/ui-chat/src/client/index.ts @@ -0,0 +1,56 @@ +/** Browser Chat target plugin. */ +export { apply, inject } from './apply.ts' +export type {} from './conversation-nodes/assistant.ts' +export type {} from './conversation-nodes/command.ts' +export type {} from './conversation-nodes/compaction.ts' +export type {} from './conversation-nodes/fallback.ts' +export type {} from './conversation-nodes/message.ts' +export type {} from './conversation-nodes/retry.ts' +export type {} from './conversation-nodes/tool.ts' +export type {} from './conversation-nodes/turn-error.ts' +export type {} from './conversation-nodes/turn-max-tokens.ts' +export type {} from './conversation-nodes/turn-tail.ts' + +export type { + AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, + AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, CommandNode, + CompactionSummaryNode, ContextMessageNode, ConversationNode, LegacyConversationSlice, + ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, ToolCallBlock, + ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, +} from './contract/snapshot.ts' +export type { + AssistantChatData, ChatConversationViewNode, ChatNode, ChatNodeKind, + FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, + TurnTailChatData, +} from './contract/chat-nodes.ts' +export type { CallId, ChatStoreState, SelectionTarget } from './contract/store.ts' +export type { + AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, + ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, + CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps, + DetailsToolOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, + TurnTailOwnerProps, UseChat, UseChatNodeTurnData, +} from './contract/slots.ts' +export type { ChatKey } from './locale.ts' +export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts' +export type { + ContextProvenanceView, ContextRole, KnownContextForm, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +export type { + ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts' +export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts' +export { + contextForm, contextProvenance, displayFailureMessage, emptyAssistantBlock, isTokenDelta, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/** Public merge surface for Chat renderer payloads contributed by other plugins. */ +export interface ChatNodeDataMap {} + +type PublicChatNodeDataMap = ChatNodeDataMap + +declare module './contract/chat-nodes.ts' { + interface ChatNodeDataMap extends PublicChatNodeDataMap {} +} diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts new file mode 100644 index 0000000000..beb059bdf1 --- /dev/null +++ b/packages/client/ui-chat/src/client/locale.ts @@ -0,0 +1,159 @@ +/** Chat-owned locale namespace and dictionaries. */ + +/** Namespace for Chat target, node, statistics, and details copy. */ +export const NS = 'chat' + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + 'view.chat': '对话', + 'stats.counts': '{turns} 轮 · {steps} 步', + 'stats.llm': 'LLM {duration}', + 'stats.toolCall': '工具调用 {duration}', + 'stats.ttftAverage': '首 token 平均 {duration}', + 'stats.tokensPerSecond': '{throughput} tok/s', + 'stats.cacheHit': '缓存命中 {percent}%', + 'stats.tokens': '输入 {input} tok · 输出 {output} tok', + 'details.title': '详情', + 'details.close': '关闭详情', + 'details.empty': '点击消息流中的工具行查看详情', + 'details.notInWindow': '该调用不在当前窗口内', + 'details.input': '输入', + 'details.output': '输出', + 'details.running': '运行中…', + 'chat.loadingHistory': '载入历史…', + 'chat.loadError': '历史加载失败:{message}({code})', + 'chat.loadOlder': '加载更早', + 'chat.toBottom': '回到底部', + 'fileOpen.title': '无法打开文件', + 'fileOpen.unknown': '无法打开此文件', + 'fileOpen.folderTitle': '无法打开文件夹', + 'fileOpen.folderUnknown': '无法打开此文件夹', + 'message.extraBlock': '附加内容块', + 'message.contextInjection': '上下文注入', + 'message.contextRecall': '跨会话召回', + 'message.referenceSummary': '引用会话 · {labels}', + 'message.referenceSeparator': '、', + 'message.context.instructions.loaded': '已载入', + 'message.context.instructions.added': '已新增', + 'message.context.instructions.updated': '已更新', + 'message.context.instructions.removed': '已移除', + 'message.context.catalog.replaced': '替换目录', + 'message.context.catalog.more': '…还有 {count} 条', + 'message.context.snapshot.supersedes': '取代先前的快照', + 'message.context.relay.from': '来自会话 {session}', + 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', + 'message.context.recall.truncated': '已截断', + 'message.compaction': '上下文已压缩', + 'message.compaction.running': '正在压缩…', + 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', + 'message.compaction.expand': '点击查看压缩摘要', + 'message.compaction.unavailable': '压缩摘要不可用', + 'message.unknownSurface': '未知 surface 事件:{type}', + 'message.unknownBlock': '未知内容块', + 'message.stopped': '已停止', + 'message.branch': '在新对话中分支', + 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', + 'message.retry.active': '正在重试模型请求', + 'message.retry.cancelled': '模型请求重试已取消', + 'message.retry.started': '已重试模型请求', + 'message.retry.scheduled': '等待重试模型请求', + 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': '重试延迟:', + 'message.retry.failure': '失败原因:', + 'message.turnError': '本轮运行失败', + 'message.maxTokens': '已达到输出 token 上限', + 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', + 'message.ranFor': '用时 {duration}', + 'message.ttft': '首 token {seconds}秒', + 'message.tokensPerSecond': '{tps} tok/s', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'command.running': '执行中…', + 'command.failed': '命令失败', + 'command.done': '已完成', + 'command.title': '命令', + 'row.running': '运行中', + 'row.failed': '失败', + 'json.truncated': '… 已截断,共 {total} 字符', + 'clock.md': '{m}月{d}日', + 'clock.ymd': '{y}年{m}月{d}日', +} satisfies Record + +/** Chat dictionary key union. */ +export type ChatKey = keyof typeof zh + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + 'view.chat': 'Chat', + 'stats.counts': '{turns} turns · {steps} steps', + 'stats.llm': 'LLM {duration}', + 'stats.toolCall': 'Tool call {duration}', + 'stats.ttftAverage': 'TTFT avg {duration}', + 'stats.tokensPerSecond': '{throughput} tok/s', + 'stats.cacheHit': 'Cache hit {percent}%', + 'stats.tokens': 'Input {input} tok · Output {output} tok', + 'details.title': 'Details', + 'details.close': 'Close details', + 'details.empty': 'Click a tool row in the message flow to view its details', + 'details.notInWindow': 'This call is outside the current window', + 'details.input': 'Input', + 'details.output': 'Output', + 'details.running': 'Running…', + 'chat.loadingHistory': 'Loading history…', + 'chat.loadError': 'Failed to load history: {message} ({code})', + 'chat.loadOlder': 'Load earlier', + 'chat.toBottom': 'Back to bottom', + 'fileOpen.title': 'Couldn’t open file', + 'fileOpen.unknown': 'Couldn’t open this file', + 'fileOpen.folderTitle': 'Couldn’t open folder', + 'fileOpen.folderUnknown': 'Couldn’t open this folder', + 'message.extraBlock': 'Extra content block', + 'message.contextInjection': 'Context injection', + 'message.contextRecall': 'Session recall', + 'message.referenceSummary': 'Referenced session · {labels}', + 'message.referenceSeparator': ', ', + 'message.context.instructions.loaded': 'loaded', + 'message.context.instructions.added': 'added', + 'message.context.instructions.updated': 'updated', + 'message.context.instructions.removed': 'removed', + 'message.context.catalog.replaced': 'Replacement catalog', + 'message.context.catalog.more': '… {count} more', + 'message.context.snapshot.supersedes': 'Supersedes earlier snapshots', + 'message.context.relay.from': 'From session {session}', + 'message.context.recall.counts': '{retained} kept · {omitted} omitted', + 'message.context.recall.truncated': 'truncated', + 'message.compaction': 'Context compacted', + 'message.compaction.running': 'Compacting context…', + 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', + 'message.compaction.expand': 'View compaction summary', + 'message.compaction.unavailable': 'Compaction summary unavailable', + 'message.unknownSurface': 'Unknown surface event: {type}', + 'message.unknownBlock': 'Unknown content block', + 'message.stopped': 'Stopped', + 'message.branch': 'Branch into a new conversation', + 'message.branchUnavailable': 'Available only on the last message of a completed turn', + 'message.retry.active': 'Retrying model request', + 'message.retry.cancelled': 'Model request retry cancelled', + 'message.retry.started': 'Retried model request', + 'message.retry.scheduled': 'Waiting to retry model request', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': 'Retry delay: ', + 'message.retry.failure': 'Failure reason: ', + 'message.turnError': 'This turn failed', + 'message.maxTokens': 'Output token limit reached', + 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', + 'message.ranFor': 'Ran for {duration}', + 'message.ttft': 'TTFT {seconds}s', + 'message.tokensPerSecond': '{tps} tok/s', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'command.running': 'Running…', + 'command.failed': 'Command failed', + 'command.done': 'Completed', + 'command.title': 'Command', + 'row.running': 'Running', + 'row.failed': 'Failed', + 'json.truncated': '… truncated, {total} characters total', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record diff --git a/packages/client/runtime/src/client/sessions/conversation-context.ts b/packages/client/ui-chat/src/client/model/conversation-context.ts similarity index 86% rename from packages/client/runtime/src/client/sessions/conversation-context.ts rename to packages/client/ui-chat/src/client/model/conversation-context.ts index 20a8bbcadd..7f8116ffcc 100644 --- a/packages/client/runtime/src/client/sessions/conversation-context.ts +++ b/packages/client/ui-chat/src/client/model/conversation-context.ts @@ -1,5 +1,5 @@ -import type { ConversationNode } from './conversation.ts' -import type { ConversationPromptSnapshot } from './request-inspection.ts' +import type { ConversationNode } from '../contract/snapshot.ts' +import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' /** Operation that started a new append-only model context. */ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' diff --git a/packages/client/runtime/src/client/sessions/steering-history.ts b/packages/client/ui-chat/src/client/model/steering-history.ts similarity index 100% rename from packages/client/runtime/src/client/sessions/steering-history.ts rename to packages/client/ui-chat/src/client/model/steering-history.ts diff --git a/packages/client/runtime/src/client/sessions/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts similarity index 99% rename from packages/client/runtime/src/client/sessions/tool-call-tree.ts rename to packages/client/ui-chat/src/client/model/tool-call-tree.ts index 4507b7a947..705bc481dc 100644 --- a/packages/client/runtime/src/client/sessions/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -2,7 +2,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-tools/types' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, -} from './conversation.ts' +} from '../contract/snapshot.ts' interface ProjectedBlock { source: ToolCallBlock diff --git a/packages/client/ui-chat/src/client/stores.ts b/packages/client/ui-chat/src/client/stores.ts new file mode 100644 index 0000000000..7ff6b80ad7 --- /dev/null +++ b/packages/client/ui-chat/src/client/stores.ts @@ -0,0 +1,20 @@ +/** Per-Session Chat selection store shared by the transcript and details panel. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { ChatStoreState, SelectionTarget } from './contract/store.ts' + +type ChatActions = { + select: (draft: ChatStoreState, target: SelectionTarget | null) => void +} + +/** + * Create the Chat selection store handle. + * @returns a handle instantiated once per rendered Session scope. + */ +export function createChatStore(): EngineStoreHandle { + return defineStore({ + init: (): ChatStoreState => ({ selection: null }), + actions: { + select: (draft, target: SelectionTarget | null) => { draft.selection = target }, + }, + }) +} diff --git a/packages/client/ui-chat/src/css-modules.d.ts b/packages/client/ui-chat/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-chat/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts new file mode 100644 index 0000000000..4c47106067 --- /dev/null +++ b/packages/client/ui-chat/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser-only Chat UI target. */ + +/** Provides no Host-side behavior. */ +export function apply(): void {} diff --git a/packages/client/ui-chat/src/invariant.ts b/packages/client/ui-chat/src/invariant.ts new file mode 100644 index 0000000000..d9e06d4f59 --- /dev/null +++ b/packages/client/ui-chat/src/invariant.ts @@ -0,0 +1,21 @@ +/** Package-owned invariant companion for the Chat UI target. */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-chat' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-chat-invariant' +/** Service required before the companion reserves package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: Conversation and Slot registration enforce Chat target consistency. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx new file mode 100644 index 0000000000..0fd861a14a --- /dev/null +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */ +import { describe, expect, it, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { + SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import { + apply as applyConversation, inject as injectConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + apply as applyChat, inject as injectChat, type ChatViewInjected, type DetailsInjected, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createChatStore } from '../src/client/stores.ts' + +usePinnedBrowserLanguages('zh-CN') + +const ROOT = 'root-1' as SessionId +const ATTACHMENT = { + attachmentId: AttachmentId('image-1'), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} as const + +type ChatInstance = ReturnType['create']> +type ChatActions = ChatInstance['actions'] + +function sessionFakeFor() { + return { + loadOlder: vi.fn(() => Promise.resolve()), + readAttachment: vi.fn(() => Promise.resolve({ + ok: true, + value: { attachment: ATTACHMENT, data: Uint8Array.of(1) }, + })), + prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + } satisfies SessionBehaviorOverrides +} + +async function bench() { + const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } + runtime.ctx.provide('layout', layout as never) + const openPath = vi.fn<(path: string) => Promise>(async () => {}) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => ROOT), + openPath, + } as never) + const session = sessionFakeFor() + await runtime.sessions.add({ + id: ROOT, + summary: { title: 'R', displayTitle: 'R', cwd: '/proj' }, + session, + }, { current: false }) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, + }, (_props: { renderSlot?: unknown }) => null) + await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) + runtime.renderRoot() + + const chatViewApi = (id: SessionId) => { + const entry = runtime.slots.entries('conversation.view')[0]! + const instance = runtime.storeOf('conversation.view', id) as ChatInstance + const injected = (entry.inject as unknown as ( + sessionId: SessionId, + actions: ChatActions, + ) => ChatViewInjected)(id, instance.actions) + return { instance, injected } + } + return { runtime, layout, openPath, session, chatViewApi } +} + +describe('Chat inject API', () => { + it('loads older history and forks through the Session Controller', async () => { + const b = await bench() + const { injected } = b.chatViewApi(ROOT) + injected.loadOlder() + expect(b.session.loadOlder).toHaveBeenCalledOnce() + + injected.forkAt(17) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) + }) + expect(b.runtime.sessions.calls).toContainEqual({ + method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }], + }) + + const fork = vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork failed')) + injected.forkAt(18) + await vi.waitFor(() => { + expect(fork).toHaveBeenCalledWith({ sessionId: ROOT, atSeq: 18, increaseTitle: true }) + }) + await b.runtime.dispose() + }) + + it('writes Chat selection before opening details', async () => { + const b = await bench() + const { instance, injected } = b.chatViewApi(ROOT) + injected.openDetails({ turnSeq: 2, callId: 'c1' }) + expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' }) + expect(b.layout.openDetails).toHaveBeenCalledOnce() + expect(b.runtime.storeOf('details', ROOT)).toBe(instance) + expect(b.runtime.storeOf('conversation.session', ROOT)).not.toBe(instance) + await b.runtime.dispose() + }) + + it('resolves file paths against the Session cwd and preserves failures', async () => { + const b = await bench() + const { injected } = b.chatViewApi(ROOT) + await injected.openFile('src/a.ts') + expect(b.openPath).toHaveBeenCalledWith('/proj/src/a.ts') + + b.openPath.mockRejectedValueOnce(new Error('xdg-open is not available')) + await expect(injected.openFile('src/b.ts')).rejects.toThrow('xdg-open is not available') + await b.runtime.dispose() + }) + + it('fails loud when a Chat View inject resolves no Session', async () => { + const b = await bench() + const entry = b.runtime.slots.entries('conversation.view')[0]! + const injectView = entry.inject as unknown as ( + sessionId: SessionId, + actions: ChatActions, + ) => ChatViewInjected + expect(() => injectView('never-listed' as SessionId, {} as ChatActions)) + .toThrow(/unknown session/) + await b.runtime.dispose() + }) + + it('closes details while sharing selection through the Chat store', async () => { + const b = await bench() + const entry = b.runtime.slots.entries('details')[0]! + const injected = (entry.inject as unknown as () => DetailsInjected)() + expect(Object.keys(injected)).toEqual(['closeDetails']) + injected.closeDetails() + expect(b.layout.closeDetails).toHaveBeenCalledOnce() + expect(b.runtime.storeOf('details', ROOT)).toBe(b.runtime.storeOf('conversation.view', ROOT)) + await b.runtime.dispose() + }) + + it('owns image loading, scroll memory, and optional closing-file mentions', async () => { + const b = await bench() + const { injected } = b.chatViewApi(ROOT) + const owner = {} as never + + expect(injected.fileMentions(owner)).toBeUndefined() + const mentions = { resolve: vi.fn() } as never + const forClosing = vi.fn(() => mentions) + b.runtime.ctx.provide('chatFileMentions', { forClosing } as never) + expect(injected.fileMentions(owner)).toBe(mentions) + expect(forClosing).toHaveBeenCalledWith(owner) + + expect(injected.chatScroll.read()).toBeNull() + const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 } + injected.chatScroll.save(position) + expect(injected.chatScroll.read()).toEqual(position) + injected.chatScroll.save(null) + expect(injected.chatScroll.read()).toBeNull() + + await expect(injected.loadImage(ATTACHMENT)).resolves.toEqual(expect.any(String)) + expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId) + await b.runtime.dispose() + }) +}) diff --git a/packages/client/ui-chat/tests/approval-command.client.spec.tsx b/packages/client/ui-chat/tests/approval-command.client.spec.tsx new file mode 100644 index 0000000000..5287b68272 --- /dev/null +++ b/packages/client/ui-chat/tests/approval-command.client.spec.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import type { ChatSnapshot, UseChat } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ApprovalCommand, commandOf } from '../src/client/chat/ApprovalCommand.tsx' +import { apply as nodeApply } from '../src/index.ts' +import * as ChatInvariant from '../src/invariant.ts' + +function props( + nodes: readonly unknown[], + callId = 'call-1', +): PropsRuntime<'conversation.approval.detail'> { + const snapshot = { + nodes: { values: () => nodes }, + } as unknown as ChatSnapshot + const useChat = ((selector: (value: ChatSnapshot) => unknown) => selector(snapshot)) as UseChat + return { callId, useChat } as PropsRuntime<'conversation.approval.detail'> +} + +describe('commandOf', () => { + it('accepts only a string command from valid JSON arguments', () => { + expect(commandOf(undefined)).toBeUndefined() + expect(commandOf({ callId: 'c1', argsRaw: '{' })).toBeUndefined() + expect(commandOf({ callId: 'c1', argsRaw: '{}' })).toBeUndefined() + expect(commandOf({ callId: 'c1', argsRaw: '{"command":42}' })).toBeUndefined() + expect(commandOf({ callId: 'c1', argsRaw: '{"command":"pnpm test"}' })).toBe('pnpm test') + }) +}) + +describe('ApprovalCommand', () => { + it('renders the running correlated Tool command', () => { + render() + + expect(screen.getByText('pnpm test')).toBeTruthy() + }) + + it('omits absent, uncorrelated, and settled Tool calls', () => { + const { container, rerender } = render() + expect(container.textContent).toBe('') + + rerender() + expect(container.textContent).toBe('') + }) +}) + +describe('ui-chat package entries', () => { + it('keeps the Host half inert and registers the invariant companion', async () => { + expect(() => { nodeApply() }).not.toThrow() + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + + await expect(ctx.plugin(ChatInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx new file mode 100644 index 0000000000..eaf64ab256 --- /dev/null +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { + chatSnapshot, SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, +} from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + apply as applyConversation, inject as injectConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + apply as applyChat, EMPTY_CHAT_SNAPSHOT, inject as injectChat, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { + ChatNodeTurnDataInjected, ChatSnapshot, UseChat, +} from '@deepseek-ai/dsh-client-ui-chat/client' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationTurnDataMap { + metric: number + } +} + +usePinnedBrowserLanguages('zh-CN') + +const SID = 'session-1' as SessionId + +async function bench() { + const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => SID), + openPath: vi.fn(async () => {}), + } as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, + 'conversation.approval.detail': { kind: 'single', scope: 'session' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, (_props: { renderSlot?: unknown }) => null) + const conversation = await runtime.mount({ + inject: [...injectConversation], + apply: applyConversation, + }) + const provide = vi.spyOn(runtime.ctx.uiSession, 'provide') + const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) + const sourceDescriptor = provide.mock.calls[0]?.[0] + if (sourceDescriptor === undefined) throw new Error('ui-chat did not provide its standard source') + return { runtime, conversation, chat, sourceDescriptor } +} + +function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') { + return (runtime.slots.entries(key)[0] as { store?: unknown } | undefined)?.store +} + +describe('Chat apply wiring', () => { + it('contributes Chat View, node renderers, stats, and details', async () => { + const b = await bench() + const views = b.runtime.slots.entries('conversation.view') + expect(views.map(row => row.options.id)).toEqual(['chat']) + expect(resolveSlotLabel(views[0]?.options.label)).toBe('对话') + expect(b.runtime.slots.spec('conversation.chat.node')) + .toMatchObject({ kind: 'keyed', scope: 'session' }) + expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id)) + .toEqual(['stats']) + expect(b.runtime.slots.entries('details')).toHaveLength(1) + await b.runtime.dispose() + }) + + it('shares one Chat store while keeping it distinct from Conversation state', async () => { + const b = await bench() + const conversationStore = storeOf(b.runtime, 'conversation.session') + const chatStore = storeOf(b.runtime, 'conversation.view') + expect(storeOf(b.runtime, 'conversation.session.header')).toBe(conversationStore) + expect(storeOf(b.runtime, 'details')).toBe(chatStore) + expect(chatStore).toBeDefined() + expect(chatStore).not.toBe(conversationStore) + await b.runtime.dispose() + }) + + it('removes only Chat contributions when Chat unloads', async () => { + const b = await bench() + await b.chat.dispose() + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0) + expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined() + expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.ctx.get('uiConversation')).toBeDefined() + await b.runtime.dispose() + }) + + it('keeps the Chat standard source total while its target enters and leaves', async () => { + const b = await bench() + await b.runtime.sessions.add({ id: SID }, { current: false }) + const binding = b.runtime.sessions.binding(SID) + if (binding === undefined) throw new Error('Chat source test Session binding is unavailable') + const resolveSource = (owner: SessionBinding): ObservableSnapshot => { + const contribution = b.sourceDescriptor.resolve(owner) as { + hooks: { chat: ObservableSnapshot } + } + return contribution.hooks.chat + } + const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.chat as + ObservableSnapshot + expect(resolveSource(binding)).toBe(source) + expect(resolveSource(binding)).toBe(source) + const listener = vi.fn() + const off = source.subscribe(listener) + + expect(source.getSnapshot()).toBeDefined() + await b.chat.dispose() + expect(source.getSnapshot()).toBe(EMPTY_CHAT_SNAPSHOT) + + off() + await b.runtime.dispose() + }) + + it('binds Turn data through the Chat selector hook for Turn and Step locations', async () => { + const b = await bench() + const spec = b.runtime.slots.spec('conversation.chat.node') as unknown as { + inject: ChatNodeTurnDataInjected + } + let snapshot: ChatSnapshot = chatSnapshot() + const useChat = ((selector: (value: ChatSnapshot) => unknown) => selector(snapshot)) as UseChat + const useTurnData = spec.inject.hooks.turnData( + { useChat } as Parameters[0], + 'node-1', + ) + const data = { get: (key: string) => key === 'metric' ? 42 : undefined } + const turn = { data } + + snapshot = chatSnapshot({ + nodes: { get: () => ({ location: { kind: 'turn', turn } }), values: () => [] } as never, + }) + expect(useTurnData('metric')).toBe(42) + snapshot = chatSnapshot({ + nodes: { get: () => ({ location: { kind: 'step', turn } }), values: () => [] } as never, + }) + expect(useTurnData('metric')).toBe(42) + snapshot = chatSnapshot({ + nodes: { get: () => ({ location: { kind: 'session' } }), values: () => [] } as never, + }) + expect(useTurnData('metric')).toBeUndefined() + snapshot = chatSnapshot({ + nodes: { get: () => undefined, values: () => [] }, + }) + expect(useTurnData('metric')).toBeUndefined() + + await b.runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx similarity index 99% rename from packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx rename to packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 7edb6a2215..8cae81f191 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -7,7 +7,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ChatConversationViewNode, ConversationNode, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' import { formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, @@ -17,8 +17,8 @@ import { UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' -import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' -import { zh } from '../src/client/locales.ts' +import { StatsLine } from '../src/client/chat/StatsLine.tsx' +import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ @@ -1022,12 +1022,12 @@ describe('small branch tails', () => { const nodes = [{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 }, }] as const - const snap = { chat: chatSnapshotFixture({ nodes }), nodes } + const snap = chatSnapshotFixture({ nodes }) const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( key === 'tokenUsage' ? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 } : undefined} diff --git a/packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts similarity index 96% rename from packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts rename to packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index fb343f0f68..a6033c4c8c 100644 --- a/packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -1,10 +1,12 @@ import type { AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode, - ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, ConversationLocationDataStore, - ConversationTurnDataMap, LegacyConversationSlice, PartialAssistant, RunningToolCall, - ToolCallBlock, TurnLocation, -} from '@deepseek-ai/dsh-client-runtime/client' -import { deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts' + ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, LegacyConversationSlice, + PartialAssistant, RunningToolCall, ToolCallBlock, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { + ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts' const EMPTY: readonly never[] = [] diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx similarity index 90% rename from packages/client/ui-conversation/tests/chat-stats.client.spec.tsx rename to packages/client/ui-chat/tests/chat-stats.client.spec.tsx index ecaec0d2a4..62d0e553b8 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -3,15 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { - AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { EMPTY_CONVERSATION_VIEWS } from '@deepseek-ai/dsh-client-runtime/client' + AssistantMessageNode, ChatSnapshot, LegacyConversationSlice, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' -import { en, zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' const t: StatsLineProps['t'] = makeTranslate(zh, commonZh) @@ -32,48 +31,19 @@ afterEach(() => { vi.useRealTimers() }) -const SID = 's1' as SessionId - const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({ kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], ...(usage === undefined ? {} : { usage }), }) -function snapshotBase(): ConversationSnapshot { - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, - } -} +type ChatUpdate = Partial -function makeSource(init?: Partial) { - const initial = { ...snapshotBase(), ...init } - let snap: ConversationSnapshot = { - ...initial, - chat: init?.chat ?? chatSnapshotFixture({ - nodes: initial.nodes, - partial: initial.partial, - runningCalls: initial.runningCalls, - turnTimings: initial.turnTimings, - turnEnds: initial.turnEnds, - }), - } +function makeSource(init: ChatUpdate = {}) { + let snap = chatSnapshotFixture(init) const subs = new Set<() => void>() return { - set: (next: Partial) => { - const merged = { ...snap, ...next } - snap = { - ...merged, - chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({ - nodes: merged.nodes, - partial: merged.partial, - runningCalls: merged.runningCalls, - turnTimings: merged.turnTimings, - turnEnds: merged.turnEnds, - })), - } + set: (next: ChatUpdate) => { + snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap) for (const fn of [...subs]) fn() }, source: { @@ -182,10 +152,10 @@ describe('StatsLine', () => { } function props( - source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }, + source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void }, values: Record = { tokenUsage: USAGE }, ): StatsLineProps { - return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn } + return { useChat: bindSnapshotSelector(source), useProjection: projections(values), t: tEn } } function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) { @@ -413,7 +383,6 @@ describe('StatsLine', () => { // Chunk frames swap partial only; nodes keeps its reference (object-layer contract). act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) }) act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) }) - act(() => { set({ running: true }) }) expect(renders).toBe(before) }) }) diff --git a/packages/client/ui-chat/tests/chat-store.client.spec.ts b/packages/client/ui-chat/tests/chat-store.client.spec.ts new file mode 100644 index 0000000000..efdda72dae --- /dev/null +++ b/packages/client/ui-chat/tests/chat-store.client.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { createChatStore } from '../src/client/stores.ts' + +describe('createChatStore', () => { + it('starts without a selected Chat target', () => { + const store = createChatStore().create() + expect(store.store.getSnapshot()).toEqual({ selection: null }) + }) + + it('selects and clears one Chat details target', () => { + const store = createChatStore().create() + store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) + expect(store.store.getSnapshot().selection) + .toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) + store.actions.select(null) + expect(store.store.getSnapshot().selection).toBeNull() + }) + + it('creates independent instances', () => { + const handle = createChatStore() + const first = handle.create() + const second = handle.create() + first.actions.select({ turnSeq: 1 }) + expect(second.store.getSnapshot().selection).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx similarity index 90% rename from packages/client/ui-conversation/tests/chat-view.client.spec.tsx rename to packages/client/ui-chat/tests/chat-view.client.spec.tsx index ab2fdcfbca..57186c03f6 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -4,22 +4,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect } from 'react' import type { - AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, - ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode, - TurnMaxTokensNode, UserMessageNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot, + ChatViewSlotProps, CommandNode, CompactionSummaryNode, ConversationNode, + LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, + ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, + UseChatNodeTurnData, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { - ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData, -} from '@deepseek-ai/dsh-client-ui-conversation/client' + SessionListState, SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' -import { zh } from '../src/client/locales.ts' +import { zh } from '../src/client/locale.ts' import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' import { @@ -43,32 +47,54 @@ beforeEach(() => { const SID = 's1' as SessionId type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } -function snapshotBase(): ConversationSnapshot { +function sessionSnapshot(overrides: Partial = {}): SessionSnapshot { return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [], - turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, + sessionId: SID, + queue: [], + running: false, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + subagent: null, + lastAgentError: null, + promptAttempted: true, + awaitingFirstTurn: false, + ...overrides, } } -/** Scripted snapshot source: set() swaps the top-level object like the real Session. */ -function makeSource(init?: Partial) { - const initial = { ...snapshotBase(), ...init } - let snap: ConversationSnapshot = { - ...initial, - chat: init?.chat ?? chatSnapshotFixture(initial), - } +/** Scripted Session source: set() swaps the top-level object like the real Controller binding. */ +function makeSessionSource(init: Partial = {}) { + let snap = sessionSnapshot(init) const subs = new Set<() => void>() return { - set: (next: Partial) => { - const merged = { ...snap, ...next } - snap = { - ...merged, - chat: Object.hasOwn(next, 'chat') && next.chat !== undefined - ? next.chat - : chatSnapshotFixture(merged, snap.chat), - } + set: (next: Partial) => { + snap = { ...snap, ...next } + for (const fn of [...subs]) fn() + }, + source: { + getSnapshot: () => snap, + subscribe: (fn: () => void) => { + subs.add(fn) + return () => subs.delete(fn) + }, + }, + } +} + +type ChatSlice = Partial + +/** Scripted Chat target source, independent from Session lifecycle state. */ +function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) { + let snap = snapshot ?? chatSnapshotFixture(init) + const subs = new Set<() => void>() + return { + set: (next: ChatSlice) => { + snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap) for (const fn of [...subs]) fn() }, source: { @@ -138,19 +164,23 @@ function emptySessions() { } function emptyWorkspaces() { - const store = createSnapshotStore({ + const store = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) } -function makeHarness(init?: Partial) { - const { set, source } = makeSource(init) +function makeHarness( + chatSlice: ChatSlice = {}, + sessionInit: Partial = {}, + chatSnapshot?: ChatSnapshot, +) { + const session = makeSessionSource(sessionInit) + const chatSource = makeChatSource(chatSlice, chatSnapshot) const openDetails = vi.fn<(t: SelectionTarget) => void>() const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const loadOlder = vi.fn() - const inspectCall = vi.fn<(callId: string) => void>() + const openView = vi.fn<(view: string, focus: string) => void>() // In-memory scroll memory matching the apply.ts per-session map contract. let savedScroll: ReturnType = null const chatScroll: ChatViewSlotProps['chatScroll'] = { @@ -182,8 +212,8 @@ function makeHarness(init?: Partial) { if (key !== 'conversation.chat.node') return opts?.fallback ?? null const nodeOwner = owner as RoutedChatNodeOwner const nodeKey = opts?.hookContext as string | undefined - const useTurnData: UseChatNodeTurnData = dataKey => props.useSession((snapshot) => { - const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + const useTurnData: UseChatNodeTurnData = dataKey => props.useChat((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.nodes.get(nodeKey)?.location return location?.kind === 'turn' || location?.kind === 'step' ? location.turn.data.get(dataKey) : undefined @@ -255,12 +285,18 @@ function makeHarness(init?: Partial) { } }) as unknown as ChatViewSlotProps['renderSlot'] // SessionProvider seat arrives with the session-scope child declaration; - // ChatView never invokes it (render-prop pass-through stub). - const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)} + // ChatView never invokes it (pass-through stub). + const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: ChatViewSlotProps = { sessionId: SID, - useSession: bindSnapshotSelector(source), + useSession: bindSnapshotSelector(session.source), + useChat: bindSnapshotSelector(chatSource.source), + useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)), + useTrajectory: (() => { throw new Error('unused') }), useSessions: emptySessions(), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined), useInput: (() => { throw new Error('unused') }), @@ -275,11 +311,13 @@ function makeHarness(init?: Partial) { actions: chat.actions, renderSlot, SessionProvider: SessionProviderStub, + viewRequest: null, + openView, + completeViewRequest: () => {}, openDetails, openFile, loadOlder, loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - inspectCall, chatScroll, forkAt, // Absent-service default; mention tests override with a real resolver. @@ -288,7 +326,8 @@ function makeHarness(init?: Partial) { } const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) } return { - set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, + setSession: session.set, setChat: chatSource.set, ChatView, props, + openDetails, openFile, loadOlder, openView, chatScroll, forkAt, setSelection, toolOwners, } } @@ -380,7 +419,10 @@ describe('ChatView', () => { }) it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => { - const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true }) + const h = makeHarness( + { nodes: [user(9, 'first visible'), user(10, 'next visible')] }, + { hasMore: true }, + ) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement @@ -407,7 +449,9 @@ describe('ChatView', () => { readerScroll(scroller, 90) Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true }) nextTop = 560 - act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) }) + act(() => { + h.setChat({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) + }) expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) @@ -460,7 +504,10 @@ describe('ChatView', () => { preview: 'later', text: 'later', } - const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true }) + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { queue: [queued, pending], running: true }, + ) const view = render() expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull() @@ -474,8 +521,8 @@ describe('ChatView', () => { & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) act(() => { - h.set({ - queue: [queued], + h.setSession({ queue: [queued] }) + h.setChat({ nodes: [ assistant(1, 'working'), { @@ -496,7 +543,8 @@ describe('ChatView', () => { expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull() act(() => { - h.set({ running: false, turnEnds: new Map([[1, 3]]) }) + h.setSession({ running: false }) + h.setChat({ turnEnds: new Map([[1, 3]]) }) }) // The Turn Tail belongs to the closed Turn, independently of a later // steering bubble's placement in the Chat list. @@ -517,13 +565,11 @@ describe('ChatView', () => { text: 'same steering', } const h = makeHarness({ - queue: [pending], nodes: [{ kind: 'user', seq: 2, time: 2_000, content: pending.content, source: null, }], - running: true, - }) + }, { queue: [pending], running: true }) const view = render() expect(view.getAllByText('same steering')).toHaveLength(2) @@ -538,35 +584,36 @@ describe('ChatView', () => { provenance: { role: 'inject', label: null }, form: null, } as const satisfies ConversationNode - const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) + const h = makeHarness({ nodes: [user(1, 'try'), retryNode] }, { running: true }) const view = render() const disclosure = view.container.querySelector('details') as HTMLDetailsElement expect(disclosure.dataset.active).toBe('true') expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), nextRetry] }) + h.setChat({ nodes: [user(1, 'try'), nextRetry] }) }) expect(within(disclosure).getAllByRole('status')).toHaveLength(1) expect(view.container.querySelector('details')).toBe(disclosure) expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') act(() => { - h.set({ + h.setChat({ nodes: [ user(1, 'try'), { ...nextRetry, retryState: 'started' }, context, assistant(5, 'done'), ], - running: false, }) + h.setSession({ running: false }) }) expect(disclosure.dataset.active).toBeUndefined() expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true }) + h.setChat({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }] }) + h.setSession({ running: true }) }) const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement expect(cancelledDisclosure.dataset.active).toBeUndefined() @@ -598,7 +645,8 @@ describe('ChatView', () => { nodes: [toolResult(3, 'a')], }) render() - expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall) + h.toolOwners[0]?.inspectCall('a') + expect(h.openView).toHaveBeenCalledWith('trajectory', 'a') }) it('shows assistant IconActions only on the last content message of each turn', () => { @@ -623,7 +671,6 @@ describe('ChatView', () => { it('withholds assistant IconActions while the turn is still running', () => { const h = makeHarness({ - running: true, runningCalls: [runningCall('a')], nodes: [ user(1, 'first'), @@ -633,7 +680,7 @@ describe('ChatView', () => { ], // Boundary seqs follow the log: a turn/end is strictly after its own nodes. turnEnds: new Map([[1, 3]]), - }) + }, { running: true }) const view = render() // 2 user + the settled turn-1 tail, which keeps its seat while a later // turn runs; turn 2's narration stays chrome-free while its tool runs, so @@ -641,7 +688,10 @@ describe('ChatView', () => { expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3) expect(view.getByText('mid-turn text')).toBeTruthy() // turn/end lands: the same node becomes the settled answer and takes the seat. - act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) }) + act(() => { + h.setSession({ running: false }) + h.setChat({ runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) + }) expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) }) @@ -694,8 +744,7 @@ describe('ChatView', () => { nodes: [user(1, 'hi'), settled], turnTimings: new Map([[1, { startTime: 1_000 }]]), turnEnds: new Map(), - running: true, - }) + }, { running: true }) const view = render() expect(view.queryByText(/首 token|tok\/s/)).toBeNull() }) @@ -748,7 +797,7 @@ describe('ChatView', () => { getStep: (turn: number, step: number) => base.locations.getStep(turn, step), }, } - const h = makeHarness({ chat }) + const h = makeHarness({}, {}, chat) const view = render() const branch = view.getByRole('button', { name: '在新对话中分支' }) expect(branch.getAttribute('aria-disabled')).toBe('true') @@ -785,13 +834,13 @@ describe('ChatView', () => { expect(literal.querySelector('h1')).toBeNull() act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } }) }) expect(view.container.querySelectorAll('h1')).toHaveLength(2) expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered') act(() => { - h.set({ + h.setChat({ nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)], partial: null, }) @@ -800,7 +849,7 @@ describe('ChatView', () => { expect(view.container.querySelector('[data-streaming="true"]')).toBeNull() act(() => { - h.set({ + h.setChat({ nodes: [ user(1, markdown), assistant(2, markdown), @@ -820,10 +869,10 @@ describe('ChatView', () => { const tool = view.getByTestId('tool-seat-a') const beforeHtml = tool.innerHTML act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } }) }) act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } }) }) expect(view.getByText('streaming… more')).toBeTruthy() expect(view.getByTestId('tool-seat-a')).toBe(tool) @@ -847,10 +896,10 @@ describe('ChatView', () => { expect(view.getByTestId('counting-row')).toBeTruthy() const afterMount = rowRenders act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } }) }) act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } }) }) expect(rowRenders).toBe(afterMount) }) @@ -864,7 +913,7 @@ describe('ChatView', () => { }) it('hands running calls to a live Tool group', () => { - const h = makeHarness({ runningCalls: [runningCall('r1')], running: true }) + const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true }) const view = render() expect(view.getByTestId('tool-seat-r1')).toBeTruthy() expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' }) @@ -890,8 +939,7 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(4, 'later')], runningCalls: [runningCall('r1')], - running: true, - }) + }, { running: true }) h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { const routed = owner as RoutedChatNodeOwner return key === 'conversation.chat.node' && routed.node.kind === 'tool-call' @@ -905,11 +953,11 @@ describe('ChatView', () => { expect(mounted).toHaveBeenCalledTimes(1) act(() => { - h.set({ + h.setChat({ nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')], runningCalls: [], - running: false, }) + h.setSession({ running: false }) }) expect(view.getByTestId('stateful-tool')).toBe(tool) @@ -922,16 +970,17 @@ describe('ChatView', () => { it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => { const startTime = Date.now() - 125_000 const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 } - const h = makeHarness({ - nodes: [trigger], turnTimings: new Map([[1, { startTime }]]), running: true, - }) + const h = makeHarness( + { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) }, + { running: true }, + ) const view = render() // Freshly mounted (as after a reload) yet already past the 15s gate. const status = view.getByRole('status') expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/) expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() act(() => { - h.set({ queue: [{ + h.setSession({ queue: [{ id: 'steering-occurrence' as never, messageId: 'steering-message' as never, placement: 'steering', @@ -963,7 +1012,8 @@ describe('ChatView', () => { expect(owner.openFile).not.toBe(h.openFile) owner.openFile('src/a.ts') expect(h.openFile).toHaveBeenCalledWith('src/a.ts') - expect(owner.inspectCall).toBe(h.inspectCall) + owner.inspectCall('a') + expect(h.openView).toHaveBeenCalledWith('trajectory', 'a') }) it('shows a Host open refusal with the reason and retries the same path', async () => { @@ -1068,7 +1118,10 @@ describe('ChatView', () => { }) it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { - const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true }) + const h = makeHarness( + { nodes: [user(5, 'later'), assistant(6, 'a')] }, + { hasMore: true }, + ) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement // jsdom has no layout: fake the metrics the anchor math reads. @@ -1084,15 +1137,21 @@ describe('ChatView', () => { fireEvent.click(view.getByText('加载更早')) Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true }) anchoredTop = 700 - act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) }) + act(() => { + h.setChat({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) + }) expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift // A new trailing user bubble (own words) force-scrolls to the bottom. - act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) }) + act(() => { + h.setChat({ + nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')], + }) + }) expect(scroller.scrollTop).toBe(1600) }) it('back-to-bottom cancels an in-flight paging anchor', () => { - const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) + const h = makeHarness({ nodes: [user(9, 'late')] }, { hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true }) @@ -1101,7 +1160,7 @@ describe('ChatView', () => { fireEvent.click(view.getByText('加载更早')) fireEvent.click(view.getByLabelText('回到底部')) Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true }) - act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) + act(() => { h.setChat({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) expect(scroller.scrollTop).toBe(1_300) expect(h.chatScroll.read()).toBeNull() }) @@ -1116,7 +1175,9 @@ describe('ChatView', () => { const backButton = view.getByLabelText('回到底部') expect(backButton).toBeTruthy() // Streaming growth must NOT drag a scrolled-away reader down. - act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) }) + act(() => { + h.setChat({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) + }) expect(scroller.scrollTop).toBe(100) fireEvent.click(backButton) expect(scroller.scrollTop).toBe(1000) @@ -1142,7 +1203,7 @@ describe('ChatView', () => { expect(h.chatScroll.read()).toBeNull() metrics.setHeight(1_200) - act(() => { h.set({ running: true }) }) + act(() => { h.setSession({ running: true }) }) expect(scroller.scrollTop).toBe(900) }) @@ -1318,22 +1379,22 @@ describe('ChatView', () => { }) it('paging button loads older and shows its busy label', () => { - const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true }) + const h = makeHarness({ nodes: [user(5, 'later')] }, { hasMore: true }) const view = render() fireEvent.click(view.getByText('加载更早')) expect(h.loadOlder).toHaveBeenCalledTimes(1) - act(() => { h.set({ loadingOlder: true }) }) + act(() => { h.setSession({ loadingOlder: true }) }) expect(view.getByText('加载中…')).toBeTruthy() }) it('shows open error and loading states', () => { - const h = makeHarness({ + const h = makeHarness({}, { openState: 'error', openError: { code: 'internal', message: 'boom' } as never, }) const view = render() expect(view.getByText(/历史加载失败:boom/)).toBeTruthy() - const loading = makeHarness({ openState: 'loading' }) + const loading = makeHarness({}, { openState: 'loading' }) const lv = render() expect(lv.getByText('载入历史…')).toBeTruthy() }) @@ -1388,7 +1449,7 @@ describe('ChatView', () => { expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() act(() => { - h.set({ + h.setChat({ nodes: [{ ...running, outcome: { diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts similarity index 97% rename from packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts rename to packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 262d2e73e1..8b3b0f5eea 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest' import type { - ChatConversationViewNode, ChatSnapshot, ConversationEventInput, - ConversationNodeDefinition, ConversationViewDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' + ChatConversationViewNode, ChatSnapshot, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import { + ConversationNodeAssembler, + type ConversationEventInput, + type ConversationNodeDefinition, + type ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' import { commandDefinition } from '../src/client/conversation-nodes/command.ts' @@ -64,7 +68,6 @@ function at( data, ...extra, } as unknown as ConversationEventInput['event'], - view: undefined, } } @@ -118,6 +121,25 @@ function toolResult(callId: string, text: string) { } describe('built-in conversation node Definitions', () => { + it('keeps ordinary command-only history inactive for the Conversation shell', () => { + const value = assembler([ + at(1, 'command/run', { + commandId: 'command-1', + name: 'help', + source: { kind: 'user' }, + }), + at(2, 'command/done', { + commandId: 'command-1', + kind: 'success', + }), + ]) + const current = snapshot(value) + + expect(current.order).toHaveLength(1) + expect(current.nodes.get(current.order[0] ?? '')?.kind).toBe('command') + expect(chatViewDefinition.isActive?.(current)).toBe(false) + }) + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/runtime/tests/conversation.client.spec.ts b/packages/client/ui-chat/tests/conversation.client.spec.ts similarity index 97% rename from packages/client/runtime/tests/conversation.client.spec.ts rename to packages/client/ui-chat/tests/conversation.client.spec.ts index 1237f73ae8..4664b92879 100644 --- a/packages/client/runtime/tests/conversation.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation.client.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client' -import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts' +import { toAssistantBlock, toAssistantBlocks } from '../src/client/contract/snapshot.ts' describe('toAssistantBlock', () => { it('classifies the four block shapes', () => { diff --git a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx similarity index 89% rename from packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx rename to packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 604f2fc095..da771f8f28 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -1,13 +1,11 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' import { cleanup, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { apply as nodeApply } from '../src/index.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' -import { zh } from '../src/client/locales.ts' +import { zh } from '../src/client/locale.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null @@ -15,10 +13,6 @@ const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => afterEach(cleanup) describe('tails', () => { - it('node-half apply tolerates a Host without settings', () => { - expect(() => { nodeApply(new Context()) }).not.toThrow() - }) - it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( { const SID = 's1' as SessionId /** Minimal framework seat for direct DetailsPanel host tests. */ -const SessionProviderStub: SessionProviderComponent = ({ children }) => children(SID) +const SessionProviderStub: SessionProviderComponent = ({ children }) => children /** Observe the owner currency without importing the Tool details renderer. */ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] { @@ -48,15 +53,31 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr } } -function snapshotBase(): ConversationSnapshot { +function sessionSnapshot(): SessionSnapshot { return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, + sessionId: SID, + queue: [], + running: false, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + subagent: null, + lastAgentError: null, + promptAttempted: true, + awaitingFirstTurn: false, } } +function emptyWorkspaces() { + return createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + }) +} + describe('render branch tails', () => { it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => { const view = render( @@ -81,18 +102,12 @@ describe('render branch tails', () => { { kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, { kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, ] as const - const snap = { - ...snapshotBase(), - chat: chatSnapshotFixture({ nodes }), - nodes: [ - ...nodes, - ], - } + const snap = chatSnapshotFixture({ nodes }) const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( } + useChat={bindSnapshotSelector(source)} useProjection={() => undefined} />, ) @@ -113,23 +128,27 @@ describe('render branch tails', () => { it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => { localStorage.clear() - const snap = snapshotBase() + const session = sessionSnapshot() + const chatSnapshot = chatSnapshotFixture() const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) - const emptyWorkspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const workspaces = emptyWorkspaces() const view = render( snap, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(createSnapshotStore(session))} + useChat={bindSnapshotSelector(createSnapshotStore(chatSnapshot))} + useConversation={bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT))} + useTrajectory={(() => { throw new Error('unused') })} useSessions={bindSnapshotSelector(emptyList)} - useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} + useSessionPendingInteraction={bindSnapshotSelector( + createSnapshotStore(new Map()), + )} + useWorkspaces={bindSnapshotSelector(workspaces)} useProjection={(() => undefined)} useInput={(() => { throw new Error('unused') })} inputActions={{ @@ -151,9 +170,9 @@ describe('render branch tails', () => { it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => { localStorage.clear() - const snap = snapshotBase() + const session = sessionSnapshot() const longText = 'x'.repeat(1_000) - snap.runningCalls = [{ + const runningCalls: readonly RunningToolCall[] = [{ callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1, time: 7_000, callView: null, subCalls: [{ kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1', @@ -169,24 +188,27 @@ describe('render branch tails', () => { }], }], }] - snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls }) + const chatSnapshot = chatSnapshotFixture({ runningCalls }) const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) - const emptyWorkspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const workspaces = emptyWorkspaces() const owners: DetailsToolOwnerProps[] = [] const view = render( snap, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(createSnapshotStore(session))} + useChat={bindSnapshotSelector(createSnapshotStore(chatSnapshot))} + useConversation={bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT))} + useTrajectory={(() => { throw new Error('unused') })} useSessions={bindSnapshotSelector(emptyList)} - useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} + useSessionPendingInteraction={bindSnapshotSelector( + createSnapshotStore(new Map()), + )} + useWorkspaces={bindSnapshotSelector(workspaces)} useProjection={(() => undefined)} useInput={(() => { throw new Error('unused') })} inputActions={{ @@ -202,7 +224,7 @@ describe('render branch tails', () => { t={t} />, ) - // Conversation resolves the selected sub-call and hands its complete + // Chat resolves the selected sub-call and hands its complete // frozen block to the Tool-owned details seat. expect(view.getByText('read')).toBeTruthy() expect(view.getByTestId('tool-details-seat')).toBeTruthy() diff --git a/packages/client/ui-chat/tests/historical-images.client.spec.ts b/packages/client/ui-chat/tests/historical-images.client.spec.ts new file mode 100644 index 0000000000..1642923a31 --- /dev/null +++ b/packages/client/ui-chat/tests/historical-images.client.spec.ts @@ -0,0 +1,28 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { HistoricalImageCache } from '../src/client/historical-images.ts' + +describe('HistoricalImageCache', () => { + it('invalidates a pending image load when its Session binding is released', async () => { + const read = Promise.withResolvers>>() + const runtime = await SlotTestRuntime.create() + const sessionId = await runtime.sessions.add({ + id: 's1', + session: { readAttachment: () => read.promise }, + }) + const cache = new HistoricalImageCache(runtime.ctx) + const attachment = { + attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + + const pending = cache.resolve(sessionId, attachment) + await runtime.sessions.remove(sessionId) + read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) + + await expect(pending).rejects.toThrow('ui-chat image scope was released before loading completed') + await runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx similarity index 55% rename from packages/client/ui-conversation/tests/image-labels.client.spec.tsx rename to packages/client/ui-chat/tests/image-labels.client.spec.tsx index 7f4a986286..b260850bc3 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -7,13 +7,11 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import type { RenderMessageImages } from '../src/client/contract/slots.ts' -import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts' -import { en, zh } from '../src/client/locales.ts' +import { zh } from '../src/client/locale.ts' afterEach(cleanup) const t = makeTranslate(zh, commonZh) -const enT = makeTranslate(en, commonZh) const attachment = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), @@ -39,43 +37,6 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { } } -describe('attachment rejection copy', () => { - const limits = { - maxImageBytes: 5 * 1024 * 1024, - maxImagesPerMessage: 20, - maxMessageImageBytes: 100 * 1024 * 1024, - maxImagePixels: 40_000_000, - maxImageDimension: 2000, - mediaTypes: ['image/png'] as const, - } - - it('renders megabytes without a trailing fraction unless one exists', () => { - expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB') - expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB') - }) - - it('maps user-solvable reasons to limit-naming copy', () => { - expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型') - expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片') - expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试') - expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') - expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') - expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片') - expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB') - expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片') - expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE', limits)).toBe('图片宽高不能超过 2000px,请缩小后重试') - expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images') - }) - - it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => { - expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试') - expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试') - expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试') - expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试') - expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE')).toBe('图片发送失败(IMAGE_DIMENSION_TOO_LARGE),请重新添加图片后再试') - }) -}) - describe('assistant image slot handoff', () => { it('passes one image group and its message alignment to the renderer', () => { const calls: MessageImagesRenderOwner[] = [] diff --git a/packages/client/runtime/tests/partial.client.spec.ts b/packages/client/ui-chat/tests/partial.client.spec.ts similarity index 98% rename from packages/client/runtime/tests/partial.client.spec.ts rename to packages/client/ui-chat/tests/partial.client.spec.ts index 328bd91ee4..dc4f1e2cef 100644 --- a/packages/client/runtime/tests/partial.client.spec.ts +++ b/packages/client/ui-chat/tests/partial.client.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client' -import { PartialAccumulator } from '../src/client/sessions/partial.ts' +import { PartialAccumulator } from '../src/client/conversation-nodes/partial.ts' const chunk = (c: Record): StreamChunk => c as unknown as StreamChunk diff --git a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx similarity index 98% rename from packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx rename to packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 551a286a88..95954a5060 100644 --- a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { zh } from '../src/client/locale.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' -import { zh } from '../src/client/locales.ts' let nextAnimationFrameId = 1 let animationFrames = new Map() diff --git a/packages/client/ui-chat/tests/selection-survival.client.spec.tsx b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx new file mode 100644 index 0000000000..a9f37c54bf --- /dev/null +++ b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +/** Exercises Chat selection through the real SlotRegistry store axis. */ +import { describe, expect, it } from 'vitest' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { createChatStore } from '../src/client/stores.ts' + +const sid = (value: string): SessionId => value as SessionId + +type ChatInstance = ReturnType['create']> + +async function createBench() { + const runtime = await SlotTestRuntime.create() + const chat = createChatStore() + await runtime.root.declare({ + 'conversation.view': { kind: 'list', scope: 'session' }, + 'details': { kind: 'single', scope: 'session' }, + }, (_props: PropsRenderSlots<'conversation.view' | 'details'>) => null) + runtime.slots.register({ name: 'conversation.view', id: 'chat', store: chat }, () => null) + runtime.slots.register({ name: 'details', store: chat }, () => null) + runtime.renderRoot() + return { runtime } +} + +function storeFor( + current: Awaited>, + slot: 'conversation.view' | 'details', + sessionId: SessionId, +): ChatInstance { + return current.runtime.storeOf(slot, sessionId) as ChatInstance +} + +describe('Chat selection survives on its store seat', () => { + it('shares one instance between the Chat View and details panel', async () => { + const b = await createBench() + await b.runtime.sessions.add({ id: 's1' }) + const chat = storeFor(b, 'conversation.view', sid('s1')) + const details = storeFor(b, 'details', sid('s1')) + chat.actions.select({ turnSeq: 3, callId: 'c1' }) + + expect(details).toBe(chat) + expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) + await b.runtime.dispose() + }) + + it('isolates Session instances and preserves identity across list projection updates', async () => { + const b = await createBench() + const oneId = sid('s1') + await b.runtime.sessions.add({ id: 's1' }) + await b.runtime.sessions.add({ id: 's2' }) + const one = storeFor(b, 'conversation.view', oneId) + const two = storeFor(b, 'conversation.view', sid('s2')) + one.actions.select({ turnSeq: 1, callId: 'a' }) + two.actions.select({ turnSeq: 9, callId: 'z' }) + + await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' }) + + expect(storeFor(b, 'conversation.view', oneId)).toBe(one) + expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) + expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) + await b.runtime.dispose() + }) + + it('buries selection with the Session scope', async () => { + const b = await createBench() + await b.runtime.sessions.add({ id: 's1' }) + const doomed = storeFor(b, 'conversation.view', sid('s1')) + doomed.actions.select({ turnSeq: 1 }) + + await b.runtime.sessions.remove('s1') + + await b.runtime.sessions.add({ id: 's1' }) + const reborn = storeFor(b, 'conversation.view', sid('s1')) + expect(reborn).not.toBe(doomed) + expect(reborn.store.getSnapshot()).toEqual({ selection: null }) + await b.runtime.dispose() + }) +}) diff --git a/packages/client/runtime/tests/tool-call-tree.client.spec.ts b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts similarity index 97% rename from packages/client/runtime/tests/tool-call-tree.client.spec.ts rename to packages/client/ui-chat/tests/tool-call-tree.client.spec.ts index 541e6b2e19..28cfda51be 100644 --- a/packages/client/runtime/tests/tool-call-tree.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts @@ -1,9 +1,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { describe, expect, it } from 'vitest' -import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts' +import type { RunningToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts' import { MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree, -} from '../src/client/sessions/tool-call-tree.ts' +} from '../src/client/model/tool-call-tree.ts' const at = (seq: number, type: string, data: Record): SessionEvent => ({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent diff --git a/packages/client/ui-conversation/tests/turn-metrics.client.spec.ts b/packages/client/ui-chat/tests/turn-metrics.client.spec.ts similarity index 97% rename from packages/client/ui-conversation/tests/turn-metrics.client.spec.ts rename to packages/client/ui-chat/tests/turn-metrics.client.spec.ts index 0e8d0546ac..19a844c6c4 100644 --- a/packages/client/ui-conversation/tests/turn-metrics.client.spec.ts +++ b/packages/client/ui-chat/tests/turn-metrics.client.spec.ts @@ -1,8 +1,10 @@ // Per-turn latency/throughput fold and the footer figure formatters. import { describe, expect, it } from 'vitest' -import type { AssistantMessageNode, ConversationNode, UserMessageNode } from '@deepseek-ai/dsh-client-runtime/client' -import { assistantStepReading, deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts' +import type { + AssistantMessageNode, ConversationNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import { assistantStepReading, deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts' import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts' interface StepSpec { diff --git a/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx new file mode 100644 index 0000000000..231c1520da --- /dev/null +++ b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import type { ReactNode } from 'react' +import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatViewSlotProps } from '../src/client/contract/slots.ts' + +describe('Chat View type chain', () => { + it('keeps Chat injection and store props out of the target-neutral base', () => { + const negatives = ( + base: ConvViewProps, + chat: ChatViewSlotProps, + ): ReactNode => { + // @ts-expect-error openDetails belongs to the Chat inject face. + void base.openDetails + // @ts-expect-error openDetails accepts a SelectionTarget. + chat.openDetails('nope') + // @ts-expect-error openFile accepts a path. + void chat.openFile({ turnSeq: 1, callId: 'c' }) + return null + } + expect(negatives).toBeTypeOf('function') + }) +}) diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json new file mode 100644 index 0000000000..c551d4cfc1 --- /dev/null +++ b/packages/client/ui-chat/tsconfig.json @@ -0,0 +1,87 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../compaction/compaction" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../interaction/commands" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../llm/llm-retry" + }, + { + "path": "../../llm/token-meter" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-stats" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../ui-approval" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-workspace" + } + ] +} diff --git a/packages/client/ui-chat/tsdown.config.ts b/packages/client/ui-chat/tsdown.config.ts new file mode 100644 index 0000000000..dd950f315d --- /dev/null +++ b/packages/client/ui-chat/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-chat', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-conversation/src/client/browser-bytes.ts b/packages/client/ui-conversation/src/client/browser-bytes.ts new file mode 100644 index 0000000000..90d881f1c6 --- /dev/null +++ b/packages/client/ui-conversation/src/client/browser-bytes.ts @@ -0,0 +1,13 @@ +/** + * Encode bytes as canonical browser base64 without overflowing argument limits. + * @param data - bytes to encode. + * @returns base64 text. + */ +export function bytesToBase64(data: Uint8Array): string { + let binary = '' + const chunk = 0x8000 + for (let offset = 0; offset < data.length; offset += chunk) { + binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) + } + return btoa(binary) +} diff --git a/packages/client/ui-conversation/src/client/context-occupancy.ts b/packages/client/ui-conversation/src/client/context-occupancy.ts new file mode 100644 index 0000000000..4ee330436f --- /dev/null +++ b/packages/client/ui-conversation/src/client/context-occupancy.ts @@ -0,0 +1,25 @@ +import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/client' + +/** Context usage rendered by conversation and Chat status surfaces. */ +export interface ContextOccupancy { + percent: number + usedTokens: number + contextWindow: number +} + +/** + * Resolve bounded display occupancy from independently updated pressure fields. + * @param pressure - latest token-meter projection. + * @returns occupancy, or null until numerator and capacity are known. + */ +export function contextOccupancy( + pressure: ContextPressureProjection | undefined, +): ContextOccupancy | null { + const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens + if (usedTokens === undefined || pressure?.contextWindow === undefined) return null + return { + percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)), + usedTokens, + contextWindow: pressure.contextWindow, + } +} diff --git a/packages/client/ui-conversation/src/client/contract/composer-blocks.ts b/packages/client/ui-conversation/src/client/contract/composer-blocks.ts new file mode 100644 index 0000000000..bf25624b04 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/composer-blocks.ts @@ -0,0 +1,29 @@ +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Why one session's composer is inert. */ +export interface ComposerBlock { + /** Localized placeholder owned by the plugin that raised the block. */ + readonly reason: string +} + +/** The registry face other plugins reach through `ctx.conversation.blocks`. */ +export interface ComposerBlocks { + /** + * Raise or clear this session's block. + * @param sessionId - Session whose composer is affected. + * @param block - Block to raise, or undefined to clear it. + */ + set(sessionId: SessionId, block: ComposerBlock | undefined): void + /** + * Resolve the observable block state for one Session. + * @param sessionId - Session to observe. + * @returns Identity-stable block store. + */ + storeFor(sessionId: SessionId): SnapshotStore + /** + * Drop one Session's store. + * @param sessionId - Session being released. + */ + forget(sessionId: SessionId): void +} diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/ui-conversation/src/client/contract/context-provenance.ts similarity index 98% rename from packages/client/runtime/src/client/sessions/context-provenance.ts rename to packages/client/ui-conversation/src/client/contract/context-provenance.ts index dbd3b2dd30..8fb154e558 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/ui-conversation/src/client/contract/context-provenance.ts @@ -1,4 +1,4 @@ -// Context source projection: the role and the human-facing producer name +// Conversation context source projection: the role and the human-facing producer name // of one logged non-user `user/message`, read from its durable `source` alone. // The client keeps no table of known plugin ids — a renamed or newly mounted // producer must never need a client release to stay identifiable, and a resumed diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts similarity index 96% rename from packages/client/runtime/src/client/contract/conversation.ts rename to packages/client/ui-conversation/src/client/contract/conversation.ts index eff765728c..01a019802d 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -8,7 +8,7 @@ import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/ty /** One raw log event plus its optional envelope-level presentation view. */ export interface ConversationEventInput { readonly event: SessionEvent - readonly view: SessionToolView | undefined + readonly view?: SessionToolView } /** Definition-local identity and lifecycle role extracted from one event. */ @@ -121,14 +121,6 @@ export interface ConversationViewSnapshotStore { ): ConversationViewSnapshotMap[Target] | undefined } -/** Final Chat render unit produced directly by a business Definition. */ -export interface ChatConversationViewNode extends ConversationViewNode { - readonly target: 'chat' - readonly anchorSeq: number - readonly location: ConversationLocation - readonly visibility: 'visible' | 'hidden' -} - /** Immutable public view of an assembled business Context. */ export interface ConversationNodeContext { readonly key: string @@ -261,6 +253,12 @@ export interface ConversationViewDefinition + /** + * Decide whether this target contributes visible Conversation activity. + * @param snapshot - latest target-owned snapshot. + * @returns whether the shell should treat this target as active. + */ + isActive?(snapshot: Snapshot): boolean } /** diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/contract/input.ts similarity index 73% rename from packages/client/ui-conversation/src/client/input/contract.ts rename to packages/client/ui-conversation/src/client/contract/input.ts index 72a015e996..e3ad336313 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -5,14 +5,144 @@ * conversation wiring layer alone sees the full SessionInput. InputMachine * (machine.ts) is package-private and never exported. */ -import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from '@deepseek-ai/cordis' +import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { Branded } from '@deepseek-ai/dsh-brand' -import type { - ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, SubmitOutcome, TokenSpan, -} from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { QueueRow } from '../contract/queue.ts' -import type { InputSubmitMode } from '../contract/composer-submission.ts' +import type { QueueRow } from './queue.ts' +import type { InputSubmitMode } from './composer-submission.ts' + +/** Pick-time draft span guarded by the input revision. */ +export interface TokenSpan { + readonly start: number + readonly end: number + readonly draftRev: number +} + +/** Base64 image payload passed to a claimed command submission. */ +export interface SubmitImageAttachment { + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + readonly data: string + readonly name?: string +} + +/** Settled result of a command or default composer submission. */ +export interface SubmitOutcome { + readonly kind: 'success' | 'error' + readonly text?: string +} + +/** Command-mode credential supplied by one input-trigger source. */ +export interface CommandClaim { + readonly token: string + readonly hint?: string + readonly images?: boolean + /** + * Submit the claimed command. + * @param args - command text after the claimed token. + * @param actx - current Session scope. + * @param images - serialized draft images accepted by the claim. + * @returns command settlement. + */ + submit(args: string, actx: Context, images: readonly SubmitImageAttachment[]): Promise +} + +/** Structured reference inserted by an input-trigger source. */ +export interface ReferenceInsert { + readonly source: string + readonly ref: string + readonly label: string + readonly appearance?: 'session' | 'file' | 'folder' + readonly clipboardText: string +} + +/** Result of trigger-source adjudication. */ +export type PickOutcome = + | { readonly claim: CommandClaim } + | { readonly insert: ReferenceInsert } + | { readonly text: string; readonly continue?: boolean } + | 'handled' + | undefined + +/** Keyboard keys intercepted by an open trigger menu. */ +export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' + +/** Trigger-menu keyboard routing result. */ +export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass' + +/** Scoped request to enter command mode. */ +export interface BeginCommandRequest { + readonly claim: CommandClaim + readonly span: TokenSpan +} + +/** Scoped request to insert a structured reference. */ +export interface InsertReferenceRequest { + readonly reference: ReferenceInsert + readonly span: TokenSpan +} + +/** Scoped request to consume a command token after business settlement. */ +export interface ConsumeTokenRequest { + readonly guard: + | { readonly kind: 'span'; readonly span: TokenSpan } + | { readonly kind: 'bare-token'; readonly token: string } +} + +/** Scoped request to insert ordinary completion text. */ +export interface InsertTextRequest { + readonly text: string + readonly span: TokenSpan + readonly continue?: boolean +} + +/** Trigger hit used to open one source programmatically. */ +export interface InputTriggerHit { + readonly trigger: '/' | '@' + readonly query: string + readonly quoted: boolean + readonly position: 'leading' | 'inline' + readonly span: TokenSpan +} + +/** Structural per-Session trigger provider consumed by the input shell. */ +export interface InputTriggerController { + readonly launcher: ObservableSnapshot + readonly lexicon: ObservableSnapshot> + /** @param draft - current draft. @param caret - caret offset. @param guard - availability tier. @param draftRev - input revision. */ + track( + draft: string, + caret: number, + guard: { readonly tier: 'plain' | 'claimed' | 'frozen' }, + draftRev: number, + ): void + /** @param key - intercepted key. @param composing - whether IME composition is active. @returns routing result. */ + arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome + /** @returns whether Space applied a trigger result. */ + onSpace(): boolean + /** @param source - reference source. @param ref - source-local id. @param signal - submit cancellation. @returns model text. */ + serializeReference(source: string, ref: string, signal: AbortSignal): Promise + /** @param line - trimmed draft. @param signal - submit cancellation. @param envelope - attachment count. @returns winning result. */ + adjudicate( + line: string, + signal: AbortSignal, + envelope: { readonly images: number }, + ): Promise + /** @param source - source name. @param hit - synthetic trigger hit. */ + toggleSource(source: string, hit: InputTriggerHit): void +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** @param request - command claim and span. @mode bail */ + 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined + /** @param request - reference and span. @mode bail */ + 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined + /** @param request - token guard. @mode bail */ + 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined + /** @param request - plain text and span. @mode bail */ + 'slash/input-insert-text'(request: InsertTextRequest): true | undefined + } +} /** Browser-runtime identity of one unsent image draft. */ export type DraftAttachmentId = Branded<'DraftAttachmentId'> @@ -61,7 +191,7 @@ export interface SessionInput extends InputTarget { /** Session-addressed access to the per-session input facade. */ export interface SessionInputResolver { /** Resolve the facade for one session-scope ctx. */ - for(actx: ClientContext): SessionInput + for(actx: Context): SessionInput } /** diff --git a/packages/client/ui-conversation/src/client/contract/queue.ts b/packages/client/ui-conversation/src/client/contract/queue.ts index 084cf13ade..4e54568448 100644 --- a/packages/client/ui-conversation/src/client/contract/queue.ts +++ b/packages/client/ui-conversation/src/client/contract/queue.ts @@ -1,13 +1,11 @@ -/** Queue contracts derived from the runtime session face and snapshot. */ -import type { - ConversationSnapshot, SessionFace, -} from '@deepseek-ai/dsh-client-runtime/client' +/** Queue contracts derived from the Session Controller face. */ +import type { SessionFace, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' -/** One address accepted by the runtime session's queue mutation verb. */ +/** One address accepted by the Session Controller's queue mutation verb. */ export type QueueItemId = Parameters[0] -/** One mutation accepted by the runtime session's queue mutation verb. */ +/** One mutation accepted by the Session Controller's queue mutation verb. */ export type QueueAction = Parameters[1] -/** One row projected by the runtime session's authoritative queue snapshot. */ -export type QueueRow = ConversationSnapshot['queue'][number] +/** One row projected by the authoritative Session queue snapshot. */ +export type QueueRow = SessionSnapshot['queue'][number] diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/ui-conversation/src/client/contract/records.ts similarity index 62% rename from packages/client/runtime/src/client/sessions/conversation.ts rename to packages/client/ui-conversation/src/client/contract/records.ts index ece94a7c9f..b715814bcc 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -9,12 +9,9 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' import type { - ClientFailure, SessionId, SubagentAddress, ToolCallView, ToolResultView, + ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-api-remotes/client' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' -import type { - ChatConversationViewNode, ConversationTimelineSnapshot, ConversationViewSnapshotStore, -} from '../contract/conversation.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -68,6 +65,20 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { } } +/** + * Create the empty projection for one streamed Assistant block kind. + * @param blockType - wire block kind. + * @returns empty projected block ready to receive deltas. + */ +export function emptyAssistantBlock(blockType: string): AssistantBlock { + switch (blockType) { + case 'text': return { kind: 'text', text: '' } + case 'reasoning': return { kind: 'reasoning', text: '' } + case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' } + default: return { kind: 'other', block: null } + } +} + /** A finalized user message. */ export interface UserMessageNode { kind: 'user' @@ -309,168 +320,9 @@ export interface RunningToolCall { /** One running or settled call, recursively owning its child calls. */ export type ToolCallBlock = RunningToolCall | ToolResultNode -/** One transient inbox occurrence from the Session control stream's queue snapshot. */ -export interface QueuedMessage { - readonly id: MessageId - /** Stable message identity used for transient-to-durable steering handoff. */ - readonly messageId: MessageId - /** Agent-resolved placement; only queued rows accept queue mutations. */ - readonly placement: 'queued' | 'steering' | 'context' - /** Complete content used to render pending steering before it becomes durable. */ - readonly content: readonly ContentBlock[] - readonly preview: string - /** Complete editable text; null when the message contains non-text blocks. */ - readonly text: string | null -} - /** In-progress assistant output (chunk accumulator product). */ export interface PartialAssistant { turn: number step: number blocks: readonly AssistantBlock[] } - -/** History-open lifecycle of a Session window. */ -export type OpenState = 'cold' | 'loading' | 'open' | 'error' - -/** - * Input-area shape of an OPEN session, derived at snapshot assembly (the one - * place that knows the predicate — consumers switch, never re-derive): - * - * - `blank`: the authoritative blank bit is still set and no prompt was - * attempted — the UI renders the blank-session guidance hero. - * - `engaging`: a first prompt was attempted, but no accepted turn or other - * authoritative activity signal has arrived — the UI keeps the composer - * visible through admission and error frames. - * - `active`: the session is non-blank beyond its pending first prompt, - * contains visible non-command Chat content, is running, or owns a pending - * interaction — the ordinary conversation view. - * - * A failed first prompt stays `engaging` (composer + error strip — retry - * semantics; returning to the hero would discard the error context). - * Sessions whose window is not open (`loading`/`error`) are outside phase - * jurisdiction: consumers branch on {@link ConversationSnapshot.openState} - * first. - */ -export type ComposerPhase = 'blank' | 'engaging' | 'active' - -/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ -export interface PromptError { - op: 'send' | 'stop' - error: ClientFailure -} - -/** - * Stable live per-key reader. An old ChatSnapshot observes later flushes - * through this store. - */ -export interface ChatNodeStore { - /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ - get(key: string): ChatConversationViewNode | undefined - /** @returns all currently materialized Nodes without imposing render order. */ - values(): readonly ChatConversationViewNode[] -} - -/** - * Stable live Location index. An old ChatSnapshot observes later membership - * changes through this index. - */ -export interface ChatLocationNodeIndex { - /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ - getTurn(turn: number): readonly string[] - /** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */ - getStep(turn: number, step: number): readonly string[] -} - -/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */ -export interface LegacyConversationSlice { - readonly nodes: readonly ConversationNode[] - readonly turnTimings: ReadonlyMap - readonly turnEnds: ReadonlyMap - readonly partial: PartialAssistant | null - readonly runningCalls: readonly RunningToolCall[] -} - -/** Incremental Chat publication with immutable order and stable live keyed readers. */ -export interface ChatSnapshot { - readonly order: readonly string[] - readonly nodes: ChatNodeStore - readonly locations: ChatLocationNodeIndex - readonly timeline: ConversationTimelineSnapshot - readonly legacy: LegacyConversationSlice -} - -const EMPTY_LIST: readonly never[] = [] -const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } - -/** Empty target store used by fixtures and Sessions without registered views. */ -export const EMPTY_CONVERSATION_VIEWS: ConversationViewSnapshotStore = { - get: () => undefined, -} - -/** Empty Chat target used before a view builder is registered. */ -export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { - order: EMPTY_LIST, - nodes: { - get: () => undefined, - values: () => EMPTY_LIST, - }, - locations: { - getTurn: () => EMPTY_LIST, - getStep: () => EMPTY_LIST, - }, - timeline: EMPTY_TIMELINE, - legacy: { - nodes: EMPTY_LIST, - turnTimings: new Map(), - turnEnds: new Map(), - partial: null, - runningCalls: EMPTY_LIST, - }, -} - -/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ -export interface ConversationSnapshot { - sessionId: SessionId - /** Registered target snapshots assembled from Session events. */ - views: ConversationViewSnapshotStore - /** Final Chat target assembled from independently registered business Definitions. */ - chat: ChatSnapshot - /** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */ - nodes: readonly ConversationNode[] - /** Exact in-window `turn/start` time and optional matching `turn/end` time. */ - turnTimings: ReadonlyMap - /** In-window completed turn number -> its `turn/end` event seq. */ - turnEnds: ReadonlyMap - partial: PartialAssistant | null - runningCalls: readonly RunningToolCall[] - /** Authoritative transient inbox snapshot, including queued and steering placements. */ - queue: readonly QueuedMessage[] - running: boolean - /** - * Catalog-discovered continuation address. Its parent availability controls - * human input; null means ordinary session transport. - */ - subagent: { address: SubagentAddress; parentAvailable: boolean } | null - /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ - composerPhase: ComposerPhase - /** Set after the forwarded `api-session/removed` event; the UI disables input. */ - removed: boolean - openState: OpenState - openError: ClientFailure | null - hasMore: boolean - loadingOlder: boolean - promptError: PromptError | null - /** - * Whether this session still has an empty log (no user message yet). - * Mirrors the Host summary's derived blank bit: seeded from `session.list` - * or `api-session/added`, flipped false by the first accepted - * prompt locally (on the RPC success response — acceptance proves the - * user message is in the host log; a rejected first prompt keeps the - * Session blank and reusable) and by any remote `running: true` status, - * and re-aligned by every list re-pull (the summary stays authoritative). - * Blank sessions are hidden from session lists and reused by New Session. - */ - blank: boolean - lastAgentError: string | null -} diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/ui-conversation/src/client/contract/request-inspection.ts similarity index 98% rename from packages/client/runtime/src/client/sessions/request-inspection.ts rename to packages/client/ui-conversation/src/client/contract/request-inspection.ts index 9856bce2bc..8c631fa346 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/ui-conversation/src/client/contract/request-inspection.ts @@ -1,11 +1,11 @@ import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { AssistantProvenanceView, AssistantRequestConfig, -} from './conversation.ts' +} from './records.ts' export type { AssistantProvenanceView, AssistantRequestConfig, -} from './conversation.ts' +} from './records.ts' /** Complete model-visible request header in force for an ordinary generation. */ export interface ConversationPromptSnapshot { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 8957e0e9ed..57c686383b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,28 +1,27 @@ -/** Conversation slot declarations and their composed component props. */ +/** Target-neutral Conversation slot declarations and composed component props. */ import type { ReactNode, RefObject } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { - InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, - SlotHookFactory, SnapshotSelectorHook, + MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook, +} from '@deepseek-ai/dsh-client-store' +import type { + InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' -import type { - CommandNode, CompactionSummaryNode, ConversationSnapshot, ConversationTurnDataMap, - ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, - TurnLocation, WorkspaceId, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionPendingInteraction } from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerBlock } from '../input/blocks.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { ComposerBlock } from './composer-blocks.ts' import type { ComposerKeyboard, DraftAttachmentId, EditSelection, InputActions, InputNotice, InputState, -} from '../input/contract.ts' -import type { createChatStore } from '../stores.ts' +} from './input.ts' +import type { createConversationStore } from '../stores.ts' import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' -import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' -import type { CallId, SelectionTarget, ViewTab } from './views.ts' +import type { ConversationSnapshot } from './snapshot.ts' +import type { ViewTab } from './views.ts' -/** Browser-owned image that has not crossed the durable host boundary. */ +/** Browser-owned image that has not crossed the durable Host boundary. */ export interface ComposerAttachment { kind: 'image' id: DraftAttachmentId @@ -38,574 +37,217 @@ export interface ComposerAttachmentsOwnerProps { canAcceptDrop: boolean /** Add one dropped batch through the composer's validation path. */ onAddImages: (files: readonly File[]) => void - /** Remove one draft image through the conversation service. */ + /** Remove one draft image through the Conversation service. */ onRemoveImage: (id: DraftAttachmentId) => void /** Display-ready limits for the drop invitation. */ dropLimits?: { readonly count: number; readonly size: string } | undefined } -/** Historical image group handed to the optional attachment presentation plugin. */ -export interface MessageImagesOwnerProps { - /** Consecutive image blocks rendered as one gallery. */ - images: readonly { readonly attachment: ImageAttachmentRef }[] - /** Session-authorized durable image loader. */ - loadImage: (attachment: ImageAttachmentRef) => Promise - /** Message-side alignment. */ - align: 'start' | 'end' -} - -/** Slot-backed renderer used by chat nodes without importing an attachment implementation. */ -export type RenderMessageImages = (owner: Omit) => ReactNode +/** Selector hook over the current Session's assembled Conversation. */ +export type UseConversation = SnapshotSelectorHook +/** Selector hook over the registered Conversation View roster. */ +export type UseConversationViews = SnapshotSelectorHook declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { - /** - * The entire body of one session: taking this seat means rendering that - * session's conversation yourself. The occupant also owns the per-session - * draft mirror and the active view ring, so a replacement inherits both - * duties and an empty one leaves a blank session pane — nothing here - * degrades gracefully. To ADD rather than replace, take a seat inside the - * flow instead: `conversation.view` for a whole tab, the input regions for - * composer chrome. - */ + /** Strict per-Session Conversation body. */ 'conversation.session': { kind: 'single'; scope: 'session' } - /** - * The strip above the session's scrollport: title, view tabs, and the - * action row. Taking this seat means rendering all three yourself, and it - * also collapses `conversation.session.header.actions` — that additive - * seat is declared by whoever occupies this one, so replacing the header - * takes every action entry down with it. - */ + /** Strict per-Session title, actions, and View navigation. */ 'conversation.session.header': { kind: 'single'; scope: 'session' } - /** - * One breadcrumb title and its lineage controls. The render site keeps - * the ordinary title as fallback; an occupant receives plain title data - * and may replace a subagent title with one combined navigation control. - */ + /** Optional replacement for one Session breadcrumb title. */ 'conversation.session.header.lineage': { kind: 'single' scope: 'session' owner: ConversationHeaderLineageOwnerProps } - /** - * One button in the session header's action row — the additive way to put - * a per-session control beside the title without replacing the header. - * Entries render by ascending `order`; negative values are reserved for - * static session context that precedes interactive actions. The owner - * passes nothing: everything a control needs comes from the framework - * session kit (`sessionId`, `useSession`, `useInput`, `inputActions`) and - * from the registrant's own inject face, so an empty owner share means - * self-sufficient, not starved. - */ - 'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } - /** - * Right-aligned Session utilities kept outside the title-adjacent action - * group, so an optional utility cannot reorder session context or lineage. - */ - 'conversation.session.header.utilities': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } - /** - * The conversation view ring: one list entry per view tab (chat here; - * trajectory/waterfall from ui-trajectory), rendered one-at-a-time by - * the session body via `only: `. Declared by this package's - * body entry (declaring is claiming). Session scope: views read the - * conversation snapshot through the standard kit. - */ - 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } - /** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */ - 'conversation.chat.node': { - kind: 'keyed' - scope: 'session' - owner: ChatNodeOwnerProps - keyProps: { [Kind in ChatNodeKind]: { node: ChatNode } } - hookContext: string - inject: ChatNodeTurnDataInjected - } - /** Optional renderer for one consecutive group of durable message images. */ - 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } - /** - * The chat view's per-command row hole: keyed dispatch on the command - * name (`command/run.name`; a run-less cross-window node has none and - * always lands on the fallback). Declared by the chat view entry; the - * render site dispatches via `entryKey: name` with GenericCommandCard as - * the `fallback` — a slash command renders durably with zero - * registration, and a domain upgrades by registering one row component. - */ - 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } - /** - * The completed Turn Node's extension chain, rendered before that Node's - * IconActions. Entries derive a match from the engine-owned Turn and - * closing seq before mounting, so presentation components never mount - * only to return null; an all-declined chain renders nothing. - */ - 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } - /** - * Action strip attached to one finalized assistant message, rendered - * inside that message's IconActions row. The chat entry owns the render - * site and passes the addressed message identity; contributors add - * per-message actions without importing the conversation implementation. - * Entries render by ascending `order`. - */ - 'conversation.chat.assistant-actions': { + /** Title-adjacent Session actions in ascending order. */ + 'conversation.session.header.actions': { kind: 'list' scope: 'session' - owner: AssistantActionOwnerProps + owner: ConversationHeaderActionOwnerProps } - /** - * The body of the details panel for the tool call the user selected — - * one occupant, so taking it means rendering every tool's output, not just - * the ones you know. The owner passes a frozen `block` whose two lifecycle - * forms must both be handled: branch on `'kind' in block` (a settled - * `ToolResultNode` has it, a still-running call does not), and treat - * `cwd` as display-only, for shortening workspace-rooted paths. - * A per-tool renderer belongs in the keyed `tool.call.toolview` seat - * instead; this one is the whole panel. - */ - 'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps } - /** - * The composer takeover chain: entries are selector-routed replacements - * of the default InputBar. Declared by this package's 'conversation' - * entry; the owner dispatches the {@link ComposerChainProps} currency and - * routing lives in entry selectors — new takeover kinds register with - * zero owner changes. - */ + /** Right-aligned Session utilities in ascending order. */ + 'conversation.session.header.utilities': { + kind: 'list' + scope: 'session' + owner: ConversationHeaderActionOwnerProps + } + /** Registered Conversation target Views, rendered one at a time. */ + 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } + /** Selector-routed replacements for the current Session's resident composer. */ 'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps } - /** - * The hero-phase Workspace picker hole: rendered by ConversationRoot - * while the session is blank (picking another workspace switches to that - * workspace's blank session, draft carried). Root scope: the picker - * reads the global workspace list. - */ + /** Workspace picker shown by the blank-session Hero. */ 'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } - /** - * Brand mark leading the blank-session headline. Declared by this - * package's `conversation` entry; the shell supplies a fish fallback. - */ + /** Brand mark shown before the blank-session headline. */ 'conversation.hero.brand.mark': { kind: 'single'; scope: 'root'; owner: HeroBrandMarkOwnerProps } - /** - * The agent-preset chip beside the workspace picker on the new-session - * screen. Root scope: no session exists yet, so the choice is staged for - * the next one rather than applied to a current one. - */ + /** Agent-preset control staged for a New Session. */ 'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps } - // 'conversation.input.overlay' merges in ui-input-trigger (the dependency - // direction is the hard constraint — ui-input-trigger cannot import - // this package, while this package's input contract already imports - // ui-input-trigger, so the type arrives transitively). The runtime declaration - // (children table in apply.ts) stays here with the other input slots. - /** - * A full-width row of its own, stacked above the composer card — the seat - * for anything that needs a line to itself (queue rows, a todo strip, a - * goal bar). Pick this over the three seats below when your content wraps - * or carries prose; pick `conversation.composer.dock` for an ambient - * readout under the card, and `conversation.input.left` / - * `.right` for a small control INSIDE the card's tool row. - * Read only `session`/`input` off the owner share ({@link InputZone}) — - * both are point-in-time snapshots re-rendered for you, never subscribe. - */ + /** Full-width entries above the composer card. */ 'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone } - /** - * The band under the composer card, inside the bar's width column — the - * seat for an ambient readout about the conversation (the shipped stats - * line lives here). Same {@link InputZone} owner share as the other - * regions. Anything the user must click belongs in the tool row instead - * (`conversation.input.left` / `.right`); anything needing its own line - * above the card belongs in `conversation.input.dock`. - */ + /** Floating entries rendered inside the resident composer card. */ + 'conversation.input.overlay': { kind: 'list'; scope: 'session' } + /** Ambient entries below the composer card. */ 'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone } - /** - * The left end of the tool row INSIDE the composer card, after the - * resident chrome (access mode, plan, attach) — the seat for a small - * always-visible control. Entries sit beside that chrome, never replace - * it. Same {@link InputZone} owner share; use `.right` for a control that - * belongs next to the send button, and the docks for anything taller than - * one row. - */ + /** Compact controls at the left of the composer tool row. */ 'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone } - /** - * The right end of the same tool row, before the primary send button — - * the seat for a control the user reaches on the way to sending (the - * model select sits in its own named seat just left of here). Same - * {@link InputZone} owner share and the same one-row height budget as - * `conversation.input.left`. - */ + /** Compact controls before the composer submit action. */ 'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone } - /** - * The default composer body: a single slot rendered as the composer - * chain's fallback (a real entry, not a chain rider, so a - * takeover election hides rather than unmounts it and the textarea DOM - * survives). Session-maybe: the bar stays mounted across the - * no-session/session transition — the no-workspace hero renders the SAME - * textarea DOM as a read-only Workspace-picker trigger instead of a - * parallel inert tree — with the machine hooks absent until a session is - * current. InputBar registers - * here from this package's apply; its machine state arrives through the - * standard provide channel (useInput + inputActions), the keyboard - * command face through its own inject. - */ + /** Resident composer body, including the no-Session inert state. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } - /** Optional draft-image rail, drop target, and preview surface inside the composer. */ + /** Optional draft-image rail and drop target. */ 'conversation.input.attachments': { kind: 'single' scope: 'session-maybe' owner: ComposerAttachmentsOwnerProps } - /** - * The named plan-status seat in the composer tool row, immediately right - * of the access-mode control — one occupant, so taking it means rendering - * the plan affordance yourself. The owner passes only `locked` (see - * {@link InputControlOwnerProps}): honour it by refusing interaction, and - * take everything else from the framework session kit or your own inject. - * Unoccupied, the seat renders nothing at all — the bar paints no - * placeholder, so an absent plan plugin costs no layout. - */ + /** Plan control inside the composer tool row. */ 'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } - /** - * The named model-select seat at the right end of the composer tool row, - * left of the send button — one occupant, so taking it means rendering the - * whole model affordance yourself. Same `locked`-only owner share and same - * renders-nothing-while-empty contract as the plan seat. Note the composer - * deliberately keeps this seat LIVE while it refuses text for a - * model-related block: every such block is one the user clears by picking - * a model here. - */ + /** Model selector inside the composer tool row. */ 'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } } - /** - * ui-conversation's members of the session standard kit, provided through - * `sessions.provide`: every session-scope slot component - * receives the input machine's state hook and the two public actions. - */ + interface GlobalStandardProps { + /** Workspace selector supplied by the independently loaded Workspace UI. */ + useWorkspaces: SnapshotSelectorHook + } + interface SessionStandardProps { - /** Selector hook over the session's live input machine state. */ + /** Selector hook over target-neutral Conversation assembly. */ + useConversation: UseConversation + /** Selector hook over the Session input machine. */ useInput: SnapshotSelectorHook - /** The public input action face (stable identity per session). */ + /** Stable public input actions for this Session. */ inputActions: InputActions } - /** Input members for the resident composer while current session is optional. */ interface SessionMaybeStandardProps { + /** Selector hook whose values are absent without a current Session. */ + useConversation: MaybeSnapshotSelectorHook + /** Input values are absent without a current Session. */ useInput: MaybeSnapshotSelectorHook + /** Input actions are absent without a current Session. */ inputActions: InputActions | undefined } } -/** Owner share of the hero agent-preset chip: the shell supplies nothing. */ +/** Owner share of the Hero agent-preset control. */ export interface HeroAgentPresetOwnerProps { - /** Marker field: the chip owns its own roster, staging, and menu state. */ + /** Marker field: the occupant owns its roster and staged selection. */ children?: never } -/** Owner share of the strict session content seat. */ -export interface ConversationSessionOwnerProps { - /** - * Wrap the view ring in the transcript scrollport that also hosts the - * sticky composer seat (whole `'conversation.composer'` chain output). - * Supplied for every real session (hero/settling/active) so the composer - * keeps one tree seat across the blank → active flip; the header stays - * outside that wrapper as ordinary column chrome (`flex: none`), while - * active CSS sticks the seat to the bottom of the same scrollport so wheel - * over the footer scrolls the flow. - * @param view - the session view-ring content (null while blank chrome is hidden). - * @returns the scrollport containing `view` and the sticky composer seat. - */ - wrapActiveBody?: (view: ReactNode) => ReactNode +/** Header actions derive their state from standard Session props. */ +export interface ConversationHeaderActionOwnerProps { + /** Marker field: entries receive no owner-specific values. */ + children?: never } -/** Header actions derive their state from the standard session/global kit. */ -export interface ConversationHeaderActionOwnerProps {} - /** Plain breadcrumb data handed to the optional lineage renderer. */ export interface ConversationHeaderLineageOwnerProps { /** Session represented by this breadcrumb title. */ lineageSessionId: SessionId - /** Display title available to a renderer that combines the title with a control. */ + /** Display title available to a combined title/control renderer. */ displayTitle: string - /** Navigate to an ancestor title when its combined control is clicked. */ + /** Navigate to an ancestor title when present. */ openTitle?: () => void } -/** - * The input-region slot currency: dock/left/right entries read - * the conversation snapshot and the live input state as owner props (both - * are point-in-time snapshots — the dispatching skeleton re-renders on - * either store's change, so entries stay current without subscribing). - */ +/** Point-in-time owner values for composer extension entries. */ export interface InputZone { - readonly session: ConversationSnapshot + readonly session: SessionSnapshot readonly input: InputState } -/** - * View-slot owner share: the cross-view inspect handoff (otherwise views need - * nothing from the render site — sessionId and the snapshot hook arrive as - * framework-standard props; tool rows go through each view's own declared - * toolview hole). - */ +/** Conversation View entries obtain their data from registered standard hooks. */ export interface ConvViewOwnerProps { - /** One-shot inspect request from another view (chat's Inspect button); null when idle. */ - inspect?: { callId: CallId } | null - /** Acknowledge the inspect request once applied (clears the store field). */ - onInspectDone?: () => void + /** Focus request addressed to the selected View. */ + viewRequest: import('./views.ts').ConversationViewRequest | null + /** Select a View and address one opaque focus identity to it. */ + openView: (view: string, focus: string) => void + /** Acknowledge the current one-shot focus request. */ + completeViewRequest: () => void } -/** - * Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')` - * (optional-service convention): the chat view asks it for a closing message's - * inline-code vocabulary and threads the result into MarkdownText. Absent - * service — the providing plugin composed out of cordis.yml — turns the - * surface off; the prose renders inert code. - */ -export interface ChatFileMentions { - /** - * Mention vocabulary for the closing message the owner currency names. - * @param owner - Turn-tail owner currency (Turn data, closing seq, opener). - * @returns The resolver MarkdownText consumes, or undefined when the turn - * produced nothing worth linking. - */ - forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */ - chatFileMentions: ChatFileMentions - } -} - -/** - * Owner currency of the chat view's turn-tail hole: the engine-owned Turn and - * the closing assistant's anchor. Registrants read their own typed Turn data - * and open files through the same opener the tool rows use. - */ -export interface TurnTailOwnerProps { - /** Engine-owned closing Turn boundary. */ - turn: TurnLocation - /** The closing assistant's seq — the anchor the tail renders under. */ - seq: number - /** - * Open a filesystem path through the Host (tool-row semantics; the chat - * view resolves relative paths against the session cwd). - */ - openFile: (path: string) => void -} - -/** - * Owner currency of the assistant-message action strip: the durable identity - * of the one finalized message the contributed actions address. Only finalized - * messages reach this slot, so the id is always present. - */ -export interface AssistantActionOwnerProps { - /** Stable identity carried from the `assistant/message` event. */ - messageId: MessageId -} - -/** Hook constrained to business data published on the current Chat Node's Turn. */ -export type UseChatNodeTurnData = >( - key: Key, -) => Readonly | undefined - -/** Slot-level Hook factory used by renderers reading their Node's Turn data. */ -export interface ChatNodeTurnDataInjected { - hooks: { - turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> - } -} - -/** Stable owner currency delivered to one keyed Chat business renderer. */ -export interface ChatNodeOwnerProps { - /** Selected Tool call, when the shared details store names one. */ - selectedCallId?: CallId | undefined - /** Session workspace root; Tool summaries display paths relative to it. */ - cwd?: string | undefined - openFile: (path: string) => void - inspectCall: (callId: CallId) => void - forkAt: (seq: number) => void - /** Render a historical image group through the attachment slot. */ - renderMessageImages: RenderMessageImages - fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined -} - -/** Full props of one registered keyed Chat business renderer. */ -export type ChatNodeViewProps = - PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'conversation'> - -/** Owner currency of the details panel's Tool output renderer. */ -export interface DetailsToolOwnerProps { - /** Frozen selected call slice. */ - block: ToolCallBlock - /** Session workspace root for card cwd and relative-path display. */ - cwd?: string | undefined -} - -/** - * Owner share of the per-command row slot: the frozen {@link CommandNode} - * slice off the snapshot (cache-stable reference — memo premise). The node - * carries the whole lifecycle (structured name/args, pairing id, and - * outcome-or-executing). A successful domain command may also carry the - * explicitly linked projection node needed to fold two log records into one - * presentation row. - */ -export interface CommandRowOwnerProps { - /** Folded command lifecycle node (run + optional done). */ - node: CommandNode - /** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */ - compaction?: CompactionSummaryNode -} - -/** Full props of a registered command-row component. */ -export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'> - -/** - * Base props of a conversation view entry: the framework standard kit for the - * session-scope 'conversation.view' slot (useSession narrowed to the - * conversation snapshot by the runtime merge, sessionId, useSessions). - * Entries declaring the shared store or an inject face compose their shares - * on top (the chat entry's {@link ChatViewSlotProps}); store-less pure - * readers (ui-trajectory) take this base alone. - */ +/** Base props of one target-owned Conversation View entry. */ export type ConvViewProps = PropsRuntime<'conversation.view'> -/** The shared chat store handle type declared by the Session header/body, details, and chat-view registrations. */ -export type ChatStore = ReturnType - -/** Business callbacks injected into the conversation slot. */ +/** Business callbacks injected into the resident Conversation shell. */ export interface ConversationInjected { - /** - * Connect the selected Workspace and open its reusable/new blank session. - * When a blank session is already current, carry its draft to the target. - */ + /** Connect and open a blank Session in the selected Workspace. */ selectWorkspace: (workspaceId: WorkspaceId) => Promise - /** - * Framework-bound sources. `composerBlock` is this session's block when a - * 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 - /** Effective Remote Event interaction for the current Session. */ - sessionPendingInteraction: ObservableSnapshot - } + /** Session-addressed composer block source, or the stable absent source. */ + hooks: { composerBlock: ObservableSnapshot } } -/** Business callbacks injected into the strict Session body seat. */ +/** Business callbacks injected into the strict Session body. */ export interface ConversationSessionInjected { - /** Views projected from the `conversation.view` slot ledger. */ - views: { - list: () => readonly ViewTab[] - subscribe: (fn: () => void) => () => void - version: () => number - } - /** Release historical image URLs when this rendered session scope unmounts. */ - releaseSessionImages: (sessionId: SessionId) => void - /** Bind the input machine's draft persistence mirror to the session store. */ + /** Package-owned View roster source bound only for the Conversation body. */ + readonly hooks: { readonly conversationViews: ObservableSnapshot } + /** Bind input draft persistence to the Session-owned store instance. */ bindDraftMirror: (write: (text: string) => void) => () => void } -/** Business callbacks injected into the strict session header seat. */ +/** Business callbacks injected into the strict Session header. */ export interface ConversationSessionHeaderInjected { - /** Views projected from the `conversation.view` slot ledger. */ - views: { - list: () => readonly ViewTab[] - subscribe: (fn: () => void) => () => void - version: () => number - } - /** Select a real Session through the runtime navigation owner. */ + /** Package-owned View roster source bound only for the Conversation header. */ + readonly hooks: { readonly conversationViews: ObservableSnapshot } + /** Select a Session through the Session Controller. */ open: (sessionId: SessionId) => void } -/** - * Owner share of the composer-bar slot: ConversationRoot's layout-phase - * inputs plus the input-region child-slot content it renders (the region - * slots stay declared/rendered by the conversation entry; the bar hosts the - * results as chrome). - */ +/** Owner share of the resident composer bar. */ export interface ComposerBarOwnerProps { - /** Hero = empty-state centered card; composer = resident bottom bar. */ + /** Hero uses centered placement; composer uses the active bottom placement. */ variant: 'hero' | 'composer' - /** - * A block another plugin raised for this session: the bar refuses input and - * shows the blocker's reason as the placeholder, but — unlike `disabled` — - * keeps the model seat live. Every block this contract has is one the user - * clears by choosing a model, so locking that seat too would leave the - * composer telling them to do the one thing it prevents. - */ + /** A feature-owned reason that makes message input inert while leaving model selection live. */ blocked?: { readonly reason: string } - /** - * Inert no-workspace state: the bar locks message actions while preserving - * its normal DOM so the Workspace pick transitions in place. - */ + /** Lock all message actions while preserving the resident textarea. */ disabled?: boolean - /** Whether the shared Workspace picker menu is expanded, regardless of which trigger opened it. */ + /** Whether the shared Workspace picker is expanded. */ workspacePickerOpen?: boolean - /** Open the existing Workspace picker from the inert textarea. */ + /** Open the Workspace picker from the inert textarea. */ onRequestWorkspace?: () => void placeholder?: string /** Optional content rendered above the textarea. */ accessory?: ReactNode - /** Floating overlay anchor content (menu / popup shell entries), rendered inside the card. */ + /** Floating overlay content rendered inside the composer card. */ overlay?: ReactNode - /** input.left slot entries (tool row, beside the resident chrome). */ + /** Left-side input controls. */ leftItems?: ReactNode - /** input.right slot entries (tool row, before the primary button). */ + /** Right-side input controls. */ rightItems?: ReactNode - /** composer.dock entries (stats line), rendered under the card inside the bar's width column. */ + /** Ambient content below the card. */ footer?: ReactNode } -/** Injected share of the composer-bar entry (package-internal faces). */ +/** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { - /** The InputBar-exclusive keyboard/DOM command face (private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined - /** Create previews and append image ids to the session input. */ addImages: ((files: readonly File[]) => string | null) | undefined - /** Release one preview and remove its id from session input. */ removeImage: ((id: DraftAttachmentId) => void) | undefined - /** Resolve ordered input ids to browser-owned draft images. */ draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined - /** Resolve one keyboard submission gesture against the current running state and persisted preference. */ resolveSubmitMode: ( running: boolean, gesture: ComposerSubmitGesture, steeringAvailable: boolean, ) => InputSubmitMode - /** Toggle the shared slash menu with only its command source; absent without ui-input-trigger or a session. */ toggleCommandMenu: ((selection: EditSelection) => void) | undefined - /** Cancel the in-flight turn; absent with the session. */ stop: (() => void) | undefined - /** - * Submit one slash-command line against this session's agent (the chrome - * controls' write path — the permission chip submits `/permission `); - * absent with the session. - * Resolves admission: false = rejected/unmatched/transport failure. - */ command: ((line: string) => Promise) | undefined - /** - * Registrant hooks compartment: the renderer binds these to - * useNotices/useLexicon (static absent sources without a session — hook - * order stays constant). - */ hooks: { - /** Latest surfaced notice (null after none; seq keys re-render of repeats). */ notices: ObservableSnapshot - /** Hot plain-text reference lexicon for the decoration scan (plain-text-reference decision; - * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md). */ lexicon: ObservableSnapshot> - /** Source name opened by the programmatic menu launcher, or null. */ menuLauncher: ObservableSnapshot } } -/** - * Owner share of the two named composer control seats (plan / model): the - * bar passes its disable state; the filling entry owns everything else. - */ +/** Owner share of the named plan and model controls. */ export interface InputControlOwnerProps { - /** Session-removed lock (the bar's chrome disable state). */ + /** Whether the composer currently refuses interaction. */ locked: boolean } -/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ +/** Full props of the resident composer bar. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> & PropsRenderSlots< @@ -614,35 +256,28 @@ export type ComposerBarProps = & InjectFace & PropsLocale<'conversation'> -/** - * Composer chain currency: what ConversationRoot dispatches at its - * renderSlotChain site. The owner declares the currency only — never a - * per-entry contract; takeover packages narrow it in their own selectors - * (`interactions.find(i => i.kind === ...)`), so new takeover kinds register - * with zero owner changes. - */ +/** Owner values used to elect a composer takeover. */ export interface ComposerChainProps { - /** Effective domain-owned interaction selected for this Session. */ - pendingInteraction: PendingInteraction | undefined - /** Current conversation facts for feature-owned takeover selectors. */ - session: ConversationSnapshot | undefined + /** Current Session identity used by temporary business-owned entries. */ + sessionId: SessionId | undefined + /** Current Session lifecycle state, absent without a selected Session. */ + session: SessionSnapshot | undefined + /** Effective business-owned interaction awaiting the user in this Session. */ + pendingInteraction: SessionPendingInteraction | undefined } -/** Presentation props supplied to the blank-session brand-mark occupant. */ +/** Presentation props supplied to the blank-session brand mark. */ export interface HeroBrandMarkOwnerProps { /** Requested square edge in pixels. */ size: number - /** Host CSS class for preserving the default hero mark color and hover motion. */ + /** Host class preserving the surrounding mark geometry. */ className?: string | undefined } -/** - * Full conversation-slot component props: runtime & child-render (view ring - * + composer chain/bar + input-region + hero picker slots) & store & injected - * shares & the locale seat. - */ +/** Full props of the resident optional-Session Conversation shell. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> & PropsRenderSlots< + PropsRuntime<'conversation'> + & PropsRenderSlots< | 'conversation.session' | 'conversation.session.header' | 'conversation.composer' | 'conversation.composer.bar' | 'conversation.input.overlay' @@ -655,14 +290,17 @@ export type ConversationSlotProps = & InjectFace & PropsLocale<'conversation'> -/** Full strict-session body props: per-session store, view ring, and draft mirror. */ +/** Shared target-neutral Conversation store handle. */ +export type ConversationStore = ReturnType + +/** Full props of the strict Session body. */ export type ConversationSessionSlotProps = PropsRuntime<'conversation.session'> & PropsRenderSlots<'conversation.view'> - & PropsStore - & ConversationSessionInjected + & PropsStore + & InjectFace -/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */ +/** Full props of the strict Session header. */ export type ConversationSessionHeaderSlotProps = PropsRuntime<'conversation.session.header'> & PropsRenderSlots< @@ -670,156 +308,19 @@ export type ConversationSessionHeaderSlotProps = | 'conversation.session.header.actions' | 'conversation.session.header.utilities' > - & PropsStore - & ConversationSessionHeaderInjected + & PropsStore + & InjectFace & PropsLocale<'conversation'> -/** The pending approval carrier the owner dispatches into the composer chain. */ -export type ApprovalWait = PendingWait<'approval'> - -/** - * Approval domain face over the carrier (the ui-user-questions PendingQuestion - * pattern): render identity and question material forwarded transparently; - * answer owns the Session Controller approval-response value - * with the audit correlation the host reconciles — and turns a rejected - * carrier receipt into a thrown error. Minted per carrier via useMemo. - */ -export class PendingApproval { - /** - * @param wait - the runtime carrier for one pending approval question. - */ - constructor(private readonly wait: ApprovalWait) {} - - /** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */ - get key(): string { - return this.wait.key - } - - /** The tool the question is about (headline fallback), forwarded from the carrier payload. */ - get toolName(): string { - return this.wait.payload.toolName - } - - /** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */ - get reason(): string | undefined { - return this.wait.payload.reason - } - - /** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */ - get callId(): string | undefined { - return this.wait.payload.callId - } - - /** - * Deliver the user's decision; a rejected carrier receipt throws. Panel - * removal stays frame-driven: the broadcast `approval/resolved` settles the - * wait and drops it from the pending list. - * @param outcome - the only two client-answerable outcomes. - */ - async answer(outcome: 'allowed-once' | 'rejected'): Promise { - const receipt = await this.wait.respond({ - ok: true, - value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome }, - }) - if (!receipt.accepted) { - throw new Error(`approval response rejected: ${receipt.reason}`) - } - } -} - -/** - * Full approval-composer props: the framework runtime share (chain currency + - * session/global standard kit) plus the chain `matched` share — the entry's - * selector result, already narrowed to the approval carrier — plus the - * standard locale seat. No injected share: the carrier plus the domain face - * above carry the whole behavior surface; the paired command line derives - * from useSession in-component. - */ -export type ApprovalComposerProps = - PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'> - -/** In-memory reader position resilient to transcript width reflow. */ -export interface ChatScrollPosition { - /** Stable rendered node/call identity nearest the visible reading edge. */ - readonly anchorKey: string - /** Anchor top relative to the transcript scrollport when saved. */ - readonly anchorTop: number - /** Approximate offset used before the semantic anchor is measured. */ - readonly scrollTop: number -} - -/** - * Injected share of the chat view entry: the two callbacks whose targets live - * outside the view (layout orchestration; the session object layer). - */ -export interface ChatViewInjected { - /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ - openDetails: (target: SelectionTarget) => void - /** - * Open a tool-arg filesystem path with the host OS default application - * (relative paths resolve against the session cwd). Always returns a - * promise: fulfills when the Host opens the path, rejects when it cannot - * hand the path off (the chat view shows that reason and a retry). - */ - openFile: (path: string) => Promise - loadOlder: () => void - /** Resolve a session-authorized historical image for inline display. */ - loadImage: (attachment: ImageAttachmentRef) => Promise - /** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */ - inspectCall: (callId: CallId) => void - /** - * Per-session scroll memory surviving view switches (in-memory, never - * persisted): the view saves on every scroll and restores on remount; a - * fresh page load starts empty and keeps the open-jump-to-bottom default. - */ - chatScroll: { - /** Record a semantic reader position; null clears it when pinned. */ - save: (position: ChatScrollPosition | null) => void - /** Last reader position, or null when pinned or never recorded. */ - read: () => ChatScrollPosition | null - } - /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ - forkAt: (seq: number) => void - /** - * Prose file-mention vocabulary for one closing message, from the optional - * {@link ChatFileMentions} service (resolved lazily per call, so composing - * the provider in or out takes effect live). Undefined when the service is - * absent or the turn produced nothing worth linking. - */ - fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined -} - -/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ -export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> - & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'> - & PropsStore & ChatViewInjected & PropsLocale<'conversation'> - -/** Full props of the attachment plugin's composer entry. */ +/** Full props of the draft-image attachment renderer. */ export type ComposerAttachmentsProps = PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'> -/** Full props of the attachment plugin's message-gallery entry. */ -export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'> - -/** - * Injected share of the details slot: the panel is otherwise a pure reader of - * the shared chat store, but its close button is a layout orchestration call. - */ -export interface DetailsInjected { - /** Close the details panel (layout geometry stays with ctx.layout). */ - closeDetails: () => void -} - -/** Full details-slot props: selection store, Tool output seat, injected close callback, and locale. */ -export type DetailsSlotProps = PropsRuntime<'details'> & PropsRenderSlots<'conversation.details.tool'> - & PropsStore & DetailsInjected & PropsLocale<'conversation'> - -/** Owner share common to the hero / New-Session Workspace pickers. */ +/** Owner share common to blank-session Workspace pickers. */ export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject - /** Currently active workspace (renders a trailing check in the picker list). */ + /** Currently selected Workspace, when available. */ selectedId?: WorkspaceId | undefined onPick: (workspaceId: WorkspaceId) => void onClose: () => void diff --git a/packages/client/ui-conversation/src/client/contract/snapshot.ts b/packages/client/ui-conversation/src/client/contract/snapshot.ts new file mode 100644 index 0000000000..4b3fa13a9f --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/snapshot.ts @@ -0,0 +1,34 @@ +/** Target-neutral Conversation state assembled from one Session event window. */ +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ConversationViewSnapshotStore } from './conversation.ts' + +/** Latest registered target snapshots and their shell-level activity. */ +export interface ConversationSnapshot { + readonly views: ConversationViewSnapshotStore + readonly activeTargets: ReadonlySet +} + +/** Empty Conversation value used before a Session binding is available. */ +export const EMPTY_CONVERSATION_SNAPSHOT: ConversationSnapshot = { + views: { get: () => undefined }, + activeTargets: new Set(), +} + +/** Shell phase derived from Session lifecycle and registered target activity. */ +export type ConversationPhase = 'blank' | 'engaging' | 'active' + +/** + * Resolve the shell phase without adding Conversation data to the Session snapshot. + * @param session - current Session lifecycle state. + * @param conversation - current target-neutral Conversation state. + * @returns the phase used by the header, View ring, and composer layout. + */ +export function conversationPhase( + session: SessionSnapshot, + conversation: ConversationSnapshot, +): ConversationPhase { + const active = conversation.activeTargets.size > 0 + || (!session.blank && !session.awaitingFirstTurn) + || session.running + return active ? 'active' : session.promptAttempted ? 'engaging' : 'blank' +} diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index 1680e0322d..979680fe81 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -1,10 +1,4 @@ -/** Shared conversation view, selection, and store-state contracts. */ - -/** Tool call identity as carried on the wire (branded upstream in connection). */ -export type CallId = string - -/** Selection target for the details linkage channel (toolcall is the step special case). */ -export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string } +/** Conversation view and session-local presentation state. */ /** * One conversation view tab, projected from a 'conversation.view' slot @@ -12,21 +6,20 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C */ export interface ViewTab { id: string; label: string } -/** - * Per-session state shared by conversation, chat-view, and details slots. - * Unknown persisted view ids fall back to the stable Chat view. - */ -export interface ChatStoreState { - /** Details-linkage channel (conversation writes, details reads). */ - selection: SelectionTarget | null +/** One-shot focus request addressed to a Conversation View. */ +export interface ConversationViewRequest { + /** Target `conversation.view` entry id. */ + readonly view: string + /** Target-owned opaque focus identity. */ + readonly focus: string +} + +/** Per-session state owned by the target-neutral Conversation shell. */ +export interface ConversationStoreState { /** Composer draft (persisted; survives session switches and reloads). */ draft: string - /** Active conversation view id ('conversation.view' entry id); null falls back to Chat. */ + /** Preferred `conversation.view` entry id; null resolves to Chat when registered. */ view: string | null - /** - * One-shot inspect handoff: chat writes the call to reveal, the trajectory - * view consumes it and acknowledges by clearing. Read with `?? null` — - * persisted snapshots from before this field rehydrate without it. - */ - inspect: { callId: CallId } | null + /** Focus request consumed and acknowledged by the addressed View. */ + viewRequest: ConversationViewRequest | null } diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts similarity index 98% rename from packages/client/runtime/src/client/sessions/conversation-assembler.ts rename to packages/client/ui-conversation/src/client/conversation/assembler.ts index 85c59a4053..4fedf47572 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -8,7 +8,7 @@ import type { import { conversationContextKey } from '../contract/conversation.ts' import { ConversationLocationIndex, type ConversationLocationDataChange, -} from './conversation-location-index.ts' +} from './location-index.ts' interface Dependency { readonly kind: string @@ -41,6 +41,7 @@ interface PendingMatch { interface ViewState { readonly target: string readonly builder: ConversationViewBuilder + readonly isActive: ((snapshot: unknown) => boolean) | undefined snapshot: unknown } @@ -330,6 +331,18 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined } + /** + * Read targets whose owners classify their latest snapshot as visible activity. + * @returns active target ids. + */ + activeTargets(): ReadonlySet { + const active = new Set() + for (const view of this.views.values()) { + if (view.isActive?.(view.snapshot) === true) active.add(view.target) + } + return active + } + private sortedInputs(): ConversationEventInput[] { return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq) } @@ -779,6 +792,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.views.set(definition.target, { target: definition.target, builder, + isActive: definition.isActive === undefined + ? undefined + : snapshot => definition.isActive?.(snapshot) === true, snapshot: builder.empty, }) } @@ -800,9 +816,3 @@ function requireState( } return state } - -/** Structural registry pair accepted by Session and SessionManager. */ -export interface ConversationRuntime { - readonly events: ConversationEventDefinitions & { subscribe(listener: () => void): () => void } - readonly views: ConversationViewDefinitions & { subscribe(listener: () => void): () => void } -} diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts new file mode 100644 index 0000000000..2a996d6296 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -0,0 +1,205 @@ +/** Per-Session target-neutral Conversation assembly. */ +import { Service, type Context } from '@deepseek-ai/cordis' +import type { + ISessions, SessionBinding, SessionEventSource, SessionEventWindow, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { + createSnapshotStore, type ObservableSnapshot, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' +import type { + ConversationEventInput, ConversationPublication, ConversationViewSnapshotMap, + ConversationViewSnapshotStore, +} from '../contract/conversation.ts' +import type { ConversationSnapshot } from '../contract/snapshot.ts' +import { ConversationNodeAssembler } from './assembler.ts' +import { ConversationEventRegistry } from './event-registry.ts' +import { ConversationViewRegistry } from './view-registry.ts' + +/** Observable faces published for one Session's Conversation assembly. */ +export interface ConversationBinding { + readonly snapshot: ObservableSnapshot + /** + * Resolve one target-owned snapshot source. + * @param target - registered Conversation target. + * @returns identity-stable source following the target. + */ + target>( + target: Target, + ): ObservableSnapshot +} + +class BoundConversation implements ConversationBinding { + readonly snapshot: SnapshotStore + private readonly viewStore: ConversationViewSnapshotStore + private readonly targetSources = new Map>() + private revision = -1 + private frame: number | undefined + private disposeFeed: () => void = () => {} + + constructor( + feed: SessionEventSource, + private readonly assembler: ConversationNodeAssembler, + ) { + this.viewStore = assembler + this.snapshot = createSnapshotStore(this.currentSnapshot()) + this.replace(feed.getSnapshot()) + this.disposeFeed = feed.subscribe(() => { + this.accept(feed.getSnapshot()) + }) + } + + target>( + target: Target, + ): ObservableSnapshot { + let source = this.targetSources.get(target) + if (source === undefined) { + const views = this.viewStore as unknown as { get(key: string): unknown } + source = { + getSnapshot: () => views.get(target), + subscribe: (listener) => { return this.snapshot.subscribe(listener) }, + } + this.targetSources.set(target, source) + } + return source as ObservableSnapshot + } + + rebuild(): void { this.publish(this.assembler.rebuildRegistry()) } + + dispose(): void { + if (this.frame !== undefined && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(this.frame) + } + this.frame = undefined + this.disposeFeed() + } + + private replace(window: SessionEventWindow): void { + this.revision = window.revision + this.publish(this.assembler.replaceWindow(window.entries.map(conversationInput), window.hasMore)) + } + + private accept(window: SessionEventWindow): void { + if (window.revision === this.revision) return + if (window.revision !== this.revision + 1 || window.change.kind === 'replace') { + this.replace(window) + return + } + this.revision = window.revision + switch (window.change.kind) { + case 'prepend': + this.publish(this.assembler.prepend(window.change.entries.map(conversationInput), window.hasMore)) + return + case 'append': { + let publication: ConversationPublication = 'none' + for (const entry of window.change.entries) { + const next = this.assembler.append(conversationInput(entry)) + if (next === 'immediate' || publication === 'none') publication = next + } + this.publish(publication) + } + } + } + + private publish(publication: ConversationPublication): void { + if (publication === 'none') return + if (publication === 'animation-frame' && typeof requestAnimationFrame === 'function') { + if (this.frame !== undefined) return + this.frame = requestAnimationFrame(() => { + this.frame = undefined + this.flush() + }) + return + } + this.flush() + } + + private flush(): void { + if (this.assembler.flush()) this.snapshot.set(this.currentSnapshot()) + } + + private currentSnapshot(): ConversationSnapshot { + return { + views: this.viewStore, + activeTargets: this.assembler.activeTargets(), + } + } +} + +function conversationInput(entry: SessionEventEntry): ConversationEventInput { + return { + event: entry.event as unknown as SessionEvent, + ...(entry.view === undefined ? {} : { view: entry.view }), + } +} + +interface BindingRecord { + readonly source: SessionBinding + readonly binding: BoundConversation + disposeScope: () => void +} + +/** Root service owning Conversation registries and per-Session bindings. */ +export class UiConversation extends Service { + /** Registry of event matchers and target snapshot builders. */ + readonly events: ConversationEventRegistry + /** Registry of target View definitions. */ + readonly views: ConversationViewRegistry + private readonly bindings = new Map() + + /** + * @param ctx - owning Client context. + * @param sessions - Session Controller object layer. + */ + constructor(ctx: Context, private readonly sessions: ISessions) { + super(ctx, 'uiConversation') + this.events = new ConversationEventRegistry(ctx) + this.views = new ConversationViewRegistry(ctx) + const rebuild = (): void => { + for (const record of this.bindings.values()) record.binding.rebuild() + } + ctx.effect(() => { + const disposeEvents = this.events.subscribe(rebuild) + const disposeViews = this.views.subscribe(rebuild) + return () => { + disposeViews() + disposeEvents() + for (const record of [...this.bindings.values()]) this.drop(record, true) + } + }, 'ui-conversation assembly') + } + + /** + * Resolve the Conversation binding for one Controller binding or Session id. + * @param source - Session binding or identity. + * @returns stable Conversation binding. + */ + binding(source: SessionBinding | SessionId): ConversationBinding { + const sessionId = typeof source === 'string' ? source : source.sessionId + const owner = typeof source === 'string' ? this.sessions.binding(source) : source + if (owner === undefined) throw new Error(`uiConversation.binding: unknown session "${sessionId}"`) + const current = this.bindings.get(owner.sessionId) + if (current?.source === owner) return current.binding + if (current !== undefined) this.drop(current, true) + const binding = new BoundConversation( + owner.eventSource, + new ConversationNodeAssembler(this.events, this.views), + ) + const record: BindingRecord = { source: owner, binding, disposeScope: () => {} } + this.bindings.set(owner.sessionId, record) + const disposeScope = owner.ctx.effect( + () => () => { this.drop(record, false) }, + 'ui-conversation binding', + ) + record.disposeScope = () => { void disposeScope() } + return binding + } + + private drop(record: BindingRecord, releaseScope: boolean): void { + if (this.bindings.get(record.source.sessionId) !== record) return + this.bindings.delete(record.source.sessionId) + record.binding.dispose() + if (releaseScope) record.disposeScope() + } +} diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts similarity index 92% rename from packages/client/runtime/src/client/sessions/assistant-timing.ts rename to packages/client/ui-conversation/src/client/conversation/assistant-timing.ts index 179f76281d..cf6e0d91e8 100644 --- a/packages/client/runtime/src/client/sessions/assistant-timing.ts +++ b/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts @@ -1,13 +1,13 @@ -// Shared assistant step-timing fold: Chat Definitions and the Trajectory +// Shared assistant step-timing fold: target Definitions and Trajectory // history fold derive AssistantTiming from the same step/start -> first token // delta -> assistant/message sequence. import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { AssistantTiming } from './conversation.ts' +import type { AssistantTiming } from '../contract/records.ts' // The first-token predicate lives beside the StreamChunk type in dsh-llm; -// re-exported here so Chat Definitions keep their client-runtime import. +// re-exported here for consumers sharing the Conversation timing fold. export { isTokenDelta } from '@deepseek-ai/dsh-llm/message' /** Pre-finalize timing boundaries for one assistant step (start + first token). */ diff --git a/packages/client/runtime/src/client/conversation/definition-registry.ts b/packages/client/ui-conversation/src/client/conversation/definition-registry.ts similarity index 81% rename from packages/client/runtime/src/client/conversation/definition-registry.ts rename to packages/client/ui-conversation/src/client/conversation/definition-registry.ts index 425f426512..c51722ce0b 100644 --- a/packages/client/runtime/src/client/conversation/definition-registry.ts +++ b/packages/client/ui-conversation/src/client/conversation/definition-registry.ts @@ -1,11 +1,19 @@ -import { Service } from '@deepseek-ai/cordis' +import { Service, type Context } from '@deepseek-ai/cordis' +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' /** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */ -export abstract class ConversationDefinitionRegistry extends Service { +export abstract class ConversationDefinitionRegistry { protected readonly definitions = new Map() private listeners = new Set<() => void>() private cached: readonly Definition[] = [] + /** @param ctx - Context whose effects own contributed Definitions. */ + constructor(protected readonly ctx: Context) { + Object.defineProperty(this, Service.tracker, { + value: { property: 'ctx' }, + }) + } + /** * Return reference-stable Definitions in registration order. * @returns current Definitions. @@ -55,6 +63,6 @@ export abstract class ConversationDefinitionRegistry extends Service /** Refresh cached entries and synchronously invalidate subscribers. */ protected refresh(): void { this.cached = [...this.definitions.values()] - for (const listener of this.listeners) listener() + notifySubscribers(this.listeners, '[ui-conversation] definition registry') } } diff --git a/packages/client/runtime/src/client/conversation/event-registry.ts b/packages/client/ui-conversation/src/client/conversation/event-registry.ts similarity index 84% rename from packages/client/runtime/src/client/conversation/event-registry.ts rename to packages/client/ui-conversation/src/client/conversation/event-registry.ts index d9eabda538..197cf4420a 100644 --- a/packages/client/runtime/src/client/conversation/event-registry.ts +++ b/packages/client/ui-conversation/src/client/conversation/event-registry.ts @@ -1,4 +1,3 @@ -import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition } from '../contract/conversation.ts' import { ConversationDefinitionRegistry } from './definition-registry.ts' @@ -6,11 +5,6 @@ import { ConversationDefinitionRegistry } from './definition-registry.ts' export class ConversationEventRegistry extends ConversationDefinitionRegistry { private fallback: ConversationNodeDefinition | undefined - /** @param ctx - owning Client Runtime context. */ - constructor(ctx: Context) { - super(ctx, 'conversationEvents') - } - /** * Register a uniquely named business Definition for the caller's lifetime. * @param definition - Definition contribution. @@ -22,7 +16,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry { + const dispose = this.ctx.effect(() => { this.fallback = definition this.refresh() return () => { @@ -45,7 +38,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry { void dispose() } } diff --git a/packages/client/runtime/src/client/sessions/failure-display.ts b/packages/client/ui-conversation/src/client/conversation/failure-display.ts similarity index 100% rename from packages/client/runtime/src/client/sessions/failure-display.ts rename to packages/client/ui-conversation/src/client/conversation/failure-display.ts diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts similarity index 100% rename from packages/client/runtime/src/client/sessions/conversation-location-index.ts rename to packages/client/ui-conversation/src/client/conversation/location-index.ts diff --git a/packages/client/runtime/src/client/conversation/view-registry.ts b/packages/client/ui-conversation/src/client/conversation/view-registry.ts similarity index 74% rename from packages/client/runtime/src/client/conversation/view-registry.ts rename to packages/client/ui-conversation/src/client/conversation/view-registry.ts index 5372b4a4db..1abf07a5e3 100644 --- a/packages/client/runtime/src/client/conversation/view-registry.ts +++ b/packages/client/ui-conversation/src/client/conversation/view-registry.ts @@ -1,15 +1,9 @@ -import type { Context } from '@deepseek-ai/cordis' import type { ConversationViewDefinition } from '../contract/conversation.ts' import { ConversationDefinitionRegistry } from './definition-registry.ts' /** Runtime registry of per-target Conversation snapshot builders. */ export class ConversationViewRegistry extends ConversationDefinitionRegistry { - /** @param ctx - owning Client Runtime context. */ - constructor(ctx: Context) { - super(ctx, 'conversationViews') - } - /** * Register a uniquely named view builder factory for the caller's lifetime. * @param definition - target builder contribution. @@ -20,7 +14,7 @@ export class ConversationViewRegistry extends ConversationDefinitionRegistry - /** - * Drop one session's store. The session scope's disposer calls this; a - * blocker never needs to. - * @param sessionId - the session being torn down. - */ - forget(sessionId: SessionId): void -} +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ComposerBlock, ComposerBlocks } from '../contract/composer-blocks.ts' /** The per-session composer-block registry (one instance per plugin fiber). */ export class ComposerBlockRegistry implements ComposerBlocks { diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index cc42d0c1ad..6d48be9371 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -6,16 +6,16 @@ * sink). Package-private; the hub alone constructs it and wires the scoped * event listeners onto it. */ -import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from '@deepseek-ai/cordis' +import { + createSnapshotStore, type ObservableSnapshot, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' import type { - ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan, -} from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { - DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, - PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, -} from './contract.ts' + ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, + EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, + InputTriggerController, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert, + SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan, +} from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import { InputMachine, projectClipboard } from './machine.ts' @@ -32,7 +32,7 @@ export interface PopupDismissFace { */ export interface SessionInputDeps { /** Session-scope ctx handed to claim.submit transactions. */ - actx: ClientContext + actx: Context /** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */ inputTriggers?: (() => InputTriggerController | undefined) | undefined /** PopupSelect shell face resolver (dismissal on submit lock / escape). */ @@ -103,7 +103,7 @@ export class SessionInputShell implements SessionInput { /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ private imageSendInFlight = false private disposed = false - /** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */ + /** Draft persistence mirror (Conversation store write; receives the clipboard projection, never display-only ranges). */ private mirrorFn: ((text: string) => void) | undefined constructor(private readonly deps: SessionInputDeps) { @@ -401,7 +401,7 @@ export class SessionInputShell implements SessionInput { } /** - * Bind the draft persistence mirror (chat store write). Adopt-on-bind: the + * Bind the draft persistence mirror (Conversation store write). Adopt-on-bind: the * store draft may hold a persisted value from a previous mount; the caller * seeds it via setDraft BEFORE binding, and afterwards every machine-adopted * draft mirrors out. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 7e3b95a314..9e53281302 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -1,25 +1,36 @@ /** * InputHub: the SessionInputResolver implementation (`ctx.conversation.input`) — one - * SessionInputShell per session, created inside the sessions provide + * SessionInputShell per session, created inside the uiSession provide * materialization (the 'input' standard-kit entry IS the * creation trigger) and torn down by the scope disposer (instance-and-scope - * share one lifecycle). The hub registers the three scoped input-mutation - * listeners on each session's actx (the sole consumer side of the ui-input-trigger - * bail events) and owns the default-sink choreography: every session is a + * share one lifecycle). The hub registers the scoped input-mutation + * listeners on each Session context and owns the default-sink choreography: every session is a * real host entity, so the sink is one unconditional prompt path. */ -import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { InputTriggerController, SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { Context } from '@deepseek-ai/cordis' +import type { + ISessions, SessionBinding, SessionFace, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' -import { queueReadFaceOf } from '../queue/store.ts' -import type { ComposerKeyboard, DraftAttachmentId, SessionInputResolver, SessionInput } from './contract.ts' +import { queueReadFaceOf } from './queue-store.ts' +import type { + ComposerKeyboard, DraftAttachmentId, InputTriggerController, SessionInputResolver, SessionInput, + SubmitImageAttachment, SubmitOutcome, +} from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' /** Structural command face for per-session popup resolution. */ interface CommandFace { - popupFor(actx: ClientContext): PopupDismissFace + popupFor(actx: Context): PopupDismissFace +} + +/** Optional input-trigger service resolved without importing its implementation. */ +interface InputTriggerServiceFace { + /** @param actx - Session scope. @returns that Session's trigger provider. */ + sessionOf(actx: Context): InputTriggerController } /** Attachment-send face resolved lazily to keep hub/service construction acyclic. */ @@ -44,7 +55,7 @@ export class InputHub implements SessionInputResolver { * @param t - conversation-namespace translate thunk (reads the active locale at call time). */ constructor( - private readonly rootCtx: ClientContext, + private readonly rootCtx: Context, private readonly t: TranslateNS<'conversation'>, ) {} @@ -53,7 +64,7 @@ export class InputHub implements SessionInputResolver { * @param actx - session-scope context. * @returns the resident per-session facade. */ - for(actx: ClientContext): SessionInput { + for(actx: Context): SessionInput { const sessions = this.sessions() const id = sessions.scopeOf(actx) if (id === undefined) throw new Error('conversation.input.for requires a session scope') @@ -149,7 +160,7 @@ export class InputHub implements SessionInputResolver { * Resolve the optional slash controller for composer chrome that launches * the shared candidate menu without typing a trigger. * @param id - session id. - * @returns the resident controller, or undefined when ui-input-trigger is absent. + * @returns the resident controller, or undefined when no trigger provider is installed. */ inputTriggers(id: SessionId): InputTriggerController | undefined { const actx = this.sessions().scope(id) @@ -197,12 +208,12 @@ export class InputHub implements SessionInputResolver { } } - private controller(actx: ClientContext): InputTriggerController | undefined { - const inputTriggers = this.rootCtx.get('inputTriggers') + private controller(actx: Context): InputTriggerController | undefined { + const inputTriggers = this.rootCtx.get('inputTriggers') as InputTriggerServiceFace | undefined return inputTriggers?.sessionOf(actx) } - private popup(actx: ClientContext): PopupDismissFace | undefined { + private popup(actx: Context): PopupDismissFace | undefined { const command = this.rootCtx.get('commandUi') as CommandFace | undefined return command?.popupFor(actx) } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index a1a49004a4..75ae348d8f 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -13,12 +13,12 @@ * as a draftRev advance (begin-command / insert-ref / consume-token / * paste-upgrade all answer their bail events this way). */ -import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { - ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions, - InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, -} from './contract.ts' + CommandClaim, ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, + InputMachineOptions, InputState, Occurrence, PasteAttemptState, PasteComponent, + ReferenceInsert, SubmitAttempt, TokenSpan, +} from '../contract/input.ts' /** Legacy fixed-width object replacement character rejected from pasted text. */ export const PLACEHOLDER = '' diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/input/queue-store.ts similarity index 76% rename from packages/client/ui-conversation/src/client/queue/store.ts rename to packages/client/ui-conversation/src/client/input/queue-store.ts index ca94348b88..09b10da0d0 100644 --- a/packages/client/ui-conversation/src/client/queue/store.ts +++ b/packages/client/ui-conversation/src/client/input/queue-store.ts @@ -1,12 +1,13 @@ /** * Queue read face for the InputState.queue projection (frozen contract in - * ../input/contract.ts): a uSES-compatible observable over one session's + * ../contract/input.ts): a uSES-compatible observable over one session's * transient inbox rows. The Session snapshot already keeps the queue array * reference-stable across unrelated snapshot swaps, so this is a pure * projection — no second store, no copy. */ -import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' -import type { QueuedMessage } from '../input/contract.ts' +import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { QueuedMessage } from '../contract/input.ts' /** * Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot). diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 27b1cff326..0b7f0ad4df 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -4,8 +4,9 @@ * Host and Agent keep the actual delivery-window authority. */ import { - createSnapshotStore, type SettingsScope, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import type { BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, } from '../contract/composer-submission.ts' diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f4e7a7c59a..e151ebec75 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -3,14 +3,12 @@ /** Dictionary namespace owned by this plugin. */ export const NS = 'conversation' -// The claimed /plan hint and the plan-mode textarea placeholder share one -// string: both describe the same next action. +// The claimed /plan hint and the plan-mode textarea placeholder describe the same next action. const PLAN_NEXT_ACTION_ZH = '描述你的任务以生成计划' const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'view.chat': '对话', 'hint.plan': PLAN_NEXT_ACTION_ZH, 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', @@ -20,10 +18,10 @@ export const zh = { 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容', 'placeholder.workspace': '选择一个工作区开始', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', - 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'image.dropTitle': '图片拖动到此处即可添加', 'image.dropDesc': '最多 {count} 张,每张 {size}', @@ -40,7 +38,6 @@ export const zh = { 'image.loading': '图片加载中…', 'image.preview': '原图预览', 'image.closePreview': '关闭原图预览', - 'image.serviceUnavailable': '图片读取服务不可用', 'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片', 'image.tooMany': '一条消息最多添加 {count} 张图片', 'image.fileTooLarge': '单张图片不能超过 {size}', @@ -55,13 +52,6 @@ export const zh = { 'context.system': '系统提示词', 'context.tools': '工具', 'context.messages': '对话消息', - 'stats.counts': '{turns} 轮 · {steps} 步', - 'stats.llm': 'LLM {duration}', - 'stats.toolCall': '工具调用 {duration}', - 'stats.ttftAverage': '首 token 平均 {duration}', - 'stats.tokensPerSecond': '{throughput} tok/s', - 'stats.cacheHit': '缓存命中 {percent}%', - 'stats.tokens': '输入 {input} tok · 输出 {output} tok', 'settings.enter.title': '繁忙时 Enter 键行为', 'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为', 'settings.enter.queue': '排队发送', @@ -75,77 +65,13 @@ export const zh = { 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', - 'details.title': '详情', - 'details.close': '关闭详情', - 'details.empty': '点击消息流中的工具行查看详情', - 'details.notInWindow': '该调用不在当前窗口内', - 'details.input': '输入', - 'details.output': '输出', - 'details.running': '运行中…', 'todo.title': '任务', 'todo.progress.done': '{done} 已完成', 'todo.progress.active': '{active} 进行中', 'todo.progress.pending': '{pending} 待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', - 'chat.loadingHistory': '载入历史…', - 'chat.loadError': '历史加载失败:{message}({code})', - 'chat.loadOlder': '加载更早', - 'chat.toBottom': '回到底部', - 'fileOpen.title': '无法打开文件', - 'fileOpen.unknown': '无法打开此文件', - 'fileOpen.folderTitle': '无法打开文件夹', - 'fileOpen.folderUnknown': '无法打开此文件夹', - 'message.extraBlock': '附加内容块', - 'message.contextInjection': '上下文注入', - 'message.contextRecall': '跨会话召回', - 'message.referenceSummary': '引用会话 · {labels}', - 'message.referenceSeparator': '、', - 'message.context.instructions.loaded': '已载入', - 'message.context.instructions.added': '已新增', - 'message.context.instructions.updated': '已更新', - 'message.context.instructions.removed': '已移除', - 'message.context.catalog.replaced': '替换目录', - 'message.context.catalog.more': '…还有 {count} 条', - 'message.context.snapshot.supersedes': '取代先前的快照', - 'message.context.relay.from': '来自会话 {session}', - 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', - 'message.context.recall.truncated': '已截断', - 'message.compaction': '上下文已压缩', - 'message.compaction.running': '正在压缩…', - 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', - 'message.compaction.expand': '点击查看压缩摘要', - 'message.compaction.unavailable': '压缩摘要不可用', - 'message.unknownSurface': '未知 surface 事件:{type}', - 'message.unknownBlock': '未知内容块', - 'message.stopped': '已停止', - 'message.branch': '在新对话中分支', - 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', - 'message.retry.active': '正在重试模型请求', - 'message.retry.cancelled': '模型请求重试已取消', - 'message.retry.started': '已重试模型请求', - 'message.retry.scheduled': '等待重试模型请求', - 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', - 'message.retry.delay': '重试延迟:', - 'message.retry.failure': '失败原因:', - 'message.turnError': '本轮运行失败', - 'message.maxTokens': '已达到输出 token 上限', - 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', - 'message.ranFor': '用时 {duration}', - 'message.ttft': '首 token {seconds}秒', - 'message.tokensPerSecond': '{tps} tok/s', - 'duration.seconds': '{seconds}秒', - 'duration.minutes': '{minutes}分{seconds}秒', - 'command.running': '执行中…', - 'command.failed': '命令失败', - 'command.done': '已完成', - 'command.title': '命令', 'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', - 'approval.waiting': '等待审批', - 'approval.detail.aria': '审批详情', - 'approval.escalation': '工具 {toolName} 请求越权执行', - 'approval.reject': '拒绝', - 'approval.allowOnce': '允许一次', 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', @@ -157,6 +83,7 @@ export const zh = { 'row.running': '运行中', 'row.failed': '失败', 'row.stopped': '已停止', + 'details.running': '运行中…', 'queue.count': '{n} 条排队消息', 'queue.edit': '编辑排队消息', 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', @@ -177,9 +104,6 @@ export const zh = { 'terminal.collapseAria': '收起输出', 'terminal.expandAria': '展开其余 {n} 行输出', 'terminal.expandRest': '… 其余 {n} 行', - 'json.truncated': '… 已截断,共 {total} 字符', - 'clock.md': '{m}月{d}日', - 'clock.ymd': '{y}年{m}月{d}日', } satisfies Record /** The conversation namespace key union. */ @@ -187,7 +111,6 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - 'view.chat': 'Chat', 'hint.plan': PLAN_NEXT_ACTION_EN, 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', @@ -197,10 +120,10 @@ export const en = { 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build', 'placeholder.workspace': 'Choose a workspace to start', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', - 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', 'image.dropTitle': 'Drag images here to add them', 'image.dropDesc': 'Up to {count} images, {size} each', @@ -217,7 +140,6 @@ export const en = { 'image.loading': 'Loading image…', 'image.preview': 'Original image preview', 'image.closePreview': 'Close original image preview', - 'image.serviceUnavailable': 'Image loading service unavailable', 'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported', 'image.tooMany': 'A message can include up to {count} images', 'image.fileTooLarge': 'Each image must be smaller than {size}', @@ -232,13 +154,6 @@ export const en = { 'context.system': 'System prompt', 'context.tools': 'Tools', 'context.messages': 'Messages', - 'stats.counts': '{turns} turns · {steps} steps', - 'stats.llm': 'LLM {duration}', - 'stats.toolCall': 'Tool call {duration}', - 'stats.ttftAverage': 'TTFT avg {duration}', - 'stats.tokensPerSecond': '{throughput} tok/s', - 'stats.cacheHit': 'Cache hit {percent}%', - 'stats.tokens': 'Input {input} tok · Output {output} tok', 'settings.enter.title': 'Enter behavior while busy', 'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior', 'settings.enter.queue': 'Queue', @@ -252,77 +167,13 @@ export const en = { 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', - 'details.title': 'Details', - 'details.close': 'Close details', - 'details.empty': 'Click a tool row in the message flow to view its details', - 'details.notInWindow': 'This call is outside the current window', - 'details.input': 'Input', - 'details.output': 'Output', - 'details.running': 'Running…', 'todo.title': 'To-dos', 'todo.progress.done': '{done} completed', 'todo.progress.active': '{active} in progress', 'todo.progress.pending': '{pending} pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', - 'chat.loadingHistory': 'Loading history…', - 'chat.loadError': 'Failed to load history: {message} ({code})', - 'chat.loadOlder': 'Load earlier', - 'chat.toBottom': 'Back to bottom', - 'fileOpen.title': 'Couldn’t open file', - 'fileOpen.unknown': 'Couldn’t open this file', - 'fileOpen.folderTitle': 'Couldn’t open folder', - 'fileOpen.folderUnknown': 'Couldn’t open this folder', - 'message.extraBlock': 'Extra content block', - 'message.contextInjection': 'Context injection', - 'message.contextRecall': 'Session recall', - 'message.referenceSummary': 'Referenced session · {labels}', - 'message.referenceSeparator': ', ', - 'message.context.instructions.loaded': 'loaded', - 'message.context.instructions.added': 'added', - 'message.context.instructions.updated': 'updated', - 'message.context.instructions.removed': 'removed', - 'message.context.catalog.replaced': 'Replacement catalog', - 'message.context.catalog.more': '… {count} more', - 'message.context.snapshot.supersedes': 'Supersedes earlier snapshots', - 'message.context.relay.from': 'From session {session}', - 'message.context.recall.counts': '{retained} kept · {omitted} omitted', - 'message.context.recall.truncated': 'truncated', - 'message.compaction': 'Context compacted', - 'message.compaction.running': 'Compacting context…', - 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', - 'message.compaction.expand': 'View compaction summary', - 'message.compaction.unavailable': 'Compaction summary unavailable', - 'message.unknownSurface': 'Unknown surface event: {type}', - 'message.unknownBlock': 'Unknown content block', - 'message.stopped': 'Stopped', - 'message.branch': 'Branch into a new conversation', - 'message.branchUnavailable': 'Available only on the last message of a completed turn', - 'message.retry.active': 'Retrying model request', - 'message.retry.cancelled': 'Model request retry cancelled', - 'message.retry.started': 'Retried model request', - 'message.retry.scheduled': 'Waiting to retry model request', - 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', - 'message.retry.delay': 'Retry delay: ', - 'message.retry.failure': 'Failure reason: ', - 'message.turnError': 'This turn failed', - 'message.maxTokens': 'Output token limit reached', - 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', - 'message.ranFor': 'Ran for {duration}', - 'message.ttft': 'TTFT {seconds}s', - 'message.tokensPerSecond': '{tps} tok/s', - 'duration.seconds': '{seconds}s', - 'duration.minutes': '{minutes}m {seconds}s', - 'command.running': 'Running…', - 'command.failed': 'Command failed', - 'command.done': 'Completed', - 'command.title': 'Command', 'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first', - 'approval.waiting': 'Waiting for approval', - 'approval.detail.aria': 'Approval details', - 'approval.escalation': 'Tool {toolName} requests privileged execution', - 'approval.reject': 'Reject', - 'approval.allowOnce': 'Allow once', 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', @@ -334,6 +185,7 @@ export const en = { 'row.running': 'Running', 'row.failed': 'Failed', 'row.stopped': 'Stopped', + 'details.running': 'Running…', 'queue.count': '{n} queued messages', 'queue.edit': 'Edit queued message', 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', @@ -354,7 +206,4 @@ export const en = { 'terminal.collapseAria': 'Collapse output', 'terminal.expandAria': 'Expand the remaining {n} output lines', 'terminal.expandRest': '… {n} more lines', - 'json.truncated': '… truncated, {total} characters total', - 'clock.md': '{m}/{d}', - 'clock.ymd': '{y}-{m}-{d}', } satisfies Record diff --git a/packages/client/ui-conversation/src/client/pending-composer.ts b/packages/client/ui-conversation/src/client/pending-composer.ts new file mode 100644 index 0000000000..bb8be56766 --- /dev/null +++ b/packages/client/ui-conversation/src/client/pending-composer.ts @@ -0,0 +1,18 @@ +/** Shared settlement mechanics for composer takeovers backed by a pending waterfall. */ + +/** + * Run one pending composer settlement and preserve non-Error rejection causes. + * @param settle - synchronous Promise resolver or rejector invocation. + * @param failureMessage - message used when the resolver throws a non-Error value. + * @returns completion or a rejection carrying the original failure. + */ +export function settlePendingComposer(settle: () => void, failureMessage: string): Promise { + try { + settle() + return Promise.resolve() + } catch (error) { + return Promise.reject(error instanceof Error + ? error + : new Error(failureMessage, { cause: error })) + } +} diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index a78ba74689..5948cae9a9 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,7 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip, diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index e6fb86fd2b..24c0303b6a 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,15 +13,17 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. -import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' 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 { ComposerBlocks } from './contract/composer-blocks.ts' +import type { + DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome, +} from './contract/input.ts' import type { InputSubmitMode } from './contract/composer-submission.ts' -import type { PendingInteractionPresentation } from './pending-interactions.ts' +import { bytesToBase64 } from './browser-bytes.ts' /** * The outward conversation face (`ctx.conversation`): the scope-addressed @@ -36,8 +38,6 @@ 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. @@ -73,12 +73,6 @@ function browserDraftAttachment(file: File): ComposerAttachment { } } -interface ImageUrlEntry { - readonly sessionId: SessionId - readonly generation: number - readonly pending: Promise -} - /** Unsupported browser-declared image type, localized by the UI boundary. */ export class UnsupportedImageMediaTypeError extends Error { /** Browser-declared MIME value, possibly empty. */ @@ -98,13 +92,7 @@ export class ConversationController extends Service implements IConversation { readonly input: SessionInputResolver /** The per-session composer-block registry. */ readonly blocks: ComposerBlocks - /** Presentation-only pending Remote Event waits. */ - readonly pendingInteractions: PendingInteractionPresentation private readonly draftAttachments = new Map() - private readonly imageUrls = new Map() - private readonly imageGenerations = new Map() - private readonly createdImageUrls = new Set() - private disposed = false /** * @param ctx - owning root context (the plugin apply context; the service @@ -113,23 +101,16 @@ 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 - pendingInteractions: PendingInteractionPresentation - }) { + constructor(ctx: Context, config: { input: SessionInputResolver; blocks: ComposerBlocks }) { 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) - this.createdImageUrls.clear() + for (const attachment of this.draftAttachments.values()) { + revokePreview(attachment.previewUrl) + } this.draftAttachments.clear() - this.imageUrls.clear() - this.imageGenerations.clear() - }, 'conversation attachment URL cache') + }, 'conversation draft attachments') } /** @@ -182,7 +163,6 @@ export class ConversationController extends Service implements IConversation { return files.map((file) => { const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) - this.createdImageUrls.add(attachment.previewUrl) return attachment }) } @@ -224,7 +204,6 @@ export class ConversationController extends Service implements IConversation { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return this.draftAttachments.delete(id) - this.createdImageUrls.delete(attachment.previewUrl) revokePreview(attachment.previewUrl) } @@ -236,63 +215,6 @@ export class ConversationController extends Service implements IConversation { for (const attachment of attachments) this.releaseDraftImage(attachment.id) } - /** - * Resolve and cache one session-authorized historical image URL. - * @param sessionId - owning session authorization scope. - * @param attachment - durable image reference. - * @returns browser URL valid until its rendered session is released. - */ - resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { - if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed')) - const key = `${sessionId}:${attachment.attachmentId}` - const cached = this.imageUrls.get(key) - if (cached !== undefined) return cached.pending - const generation = this.imageGenerations.get(sessionId) ?? 0 - const session = this.requireSessions().binding(sessionId)?.session - if (session === undefined) { - return Promise.reject(new Error(`conversation.resolveImage: unknown session "${sessionId}"`)) - } - const pending = session.readAttachment(attachment.attachmentId) - .then((result) => { - if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) - if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed') - if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) { - throw new Error('historical image scope was released before loading completed') - } - if (typeof URL.createObjectURL !== 'function') { - return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` - } - const bytes = Uint8Array.from(result.value.data) - const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) - this.createdImageUrls.add(url) - return url - }) - .catch((error: unknown) => { - if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key) - throw error - }) - this.imageUrls.set(key, { sessionId, generation, pending }) - return pending - } - - /** - * Release every historical image URL owned by one rendered session. - * @param sessionId - rendered session scope. - */ - releaseSessionImages(sessionId: SessionId): void { - this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1) - for (const [key, entry] of this.imageUrls) { - if (entry.sessionId !== sessionId) continue - this.imageUrls.delete(key) - void entry.pending.then((url) => { - if (!this.createdImageUrls.delete(url)) return - revokePreview(url) - }, () => { - // A failed or invalidated load owns no object URL. - }) - } - } - /** Apply one operation to a pending queue occurrence. */ async updateQueue(itemId: QueueItemId, action: QueueAction): Promise { const session = this.scopedSession('updateQueue') @@ -370,15 +292,6 @@ function imageMediaType(value: string): ImageMediaType { } } -function bytesToBase64(data: Uint8Array): string { - let binary = '' - const chunk = 0x8000 - for (let offset = 0; offset < data.length; offset += chunk) { - binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) - } - return btoa(binary) -} - function revokePreview(url: string): void { if (url.startsWith('blob:')) URL.revokeObjectURL(url) } diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx index 2b5552c3cc..07ebd615b3 100644 --- a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx @@ -1,6 +1,6 @@ /** General Settings row for the Composer's busy-state Enter preference. */ import { useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { BusyEnterBehavior } from '../contract/composer-submission.ts' diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css deleted file mode 100644 index 3c6cc58187..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css +++ /dev/null @@ -1,97 +0,0 @@ -.root { - display: flex; - flex-direction: column; - align-items: center; - /* Sides = clearance + 16px so the card lands on the shared content width - (input card - 32) at every viewport. */ - padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px; -} - -.card { - overflow: hidden; - width: 100%; - max-width: var(--dsh-chat-content-width); - border: 1px solid var(--dsw-alias-state-warn-secondary); - border-radius: 20px; - background: var(--dsw-specific-input-major); - box-shadow: var(--dsw-shadow-lv2); - /* Elevated surface in dark, same as the menus: `.body` inside scrolls once - the justification or command passes the cap, so the thumb takes the l2 - pair. Declared on the card because the elevation belongs to the surface, - and the custom properties inherit down to the region that actually - scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */ - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); -} - -/* Tinted full-width header band. */ -.strip { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - background: var(--dsw-alias-state-warn-tertiary); - color: var(--dsw-alias-state-warn-primary); - font-size: 13px; - line-height: 18px; -} - -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--dsw-alias-state-warn-primary); -} - -/* Scroll region: an agent's justification and its command are unbounded model - text (a one-line `cd` or a 40-line heredoc), and the seat sits in a - fixed-height column — uncapped, a long command pushed the action row past - the viewport and the approval could not be answered at all. The strip and - the action row stay outside, so the buttons are always on screen. */ -.body { - display: flex; - flex-direction: column; - gap: 6px; - /* border-box so the cap is the region's OUTER height: the composer's draft - area counts its padding inside the same number, and the two seats are - only interchangeable if they occupy the same box. */ - box-sizing: border-box; - max-height: var(--dsh-composer-text-max-height); - overflow-y: auto; - padding: 12px 16px 0; -} - -/* The model's justification is the panel's message, not a footnote. */ -.headline { - color: var(--dsw-alias-label-primary); - font-size: 15px; - font-weight: 500; - line-height: 24px; -} - -.command { - color: var(--dsw-alias-label-tertiary); - font-family: var(--ds-font-family-code); - font-size: 13px; - line-height: 20px; - word-break: break-all; -} - -/* Card-level row, not body content. Its padding reproduces the metrics the row - had inside the body: 14px above (the flex gap of 6 plus the row's 8px top - margin, neither of which reaches it out here) and the body's former 14px - bottom pad below, so the resting card is unchanged. Buttons are the shared - outline/primary capsules (Button atom, matching QuestionComposer's footer); - only the reject's danger hover is local. */ -.actionRow { - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 14px 16px 14px; -} - -.reject:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); - border-color: transparent; -} diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx deleted file mode 100644 index 32f7e64b92..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useMemo, useState } from 'react' -import { Button } from '@deepseek-ai/dsh-client-ui-primitives' -import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' -import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' -import { rootToolCall } from '../chat/tool-node-reader.ts' -import css from './ApprovalPanel.module.css' - -/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */ -export function commandOf(call: RunningToolCall | undefined): string | undefined { - if (call === undefined) return undefined - try { - const args = JSON.parse(call.argsRaw) as Record - return typeof args.command === 'string' ? args.command : undefined - } catch { - return undefined - } -} - -/** - * Render one pending approval and remount local answer state per request. - * @param props - the selector-matched pending approval carrier plus the framework standard kit. - * @returns The approval prompt for this request. - */ -export function ApprovalPanel(props: ApprovalComposerProps) { - const approval = useMemo(() => new PendingApproval(props.matched), [props.matched]) - const command = props.useSession((snapshot) => { - if (approval.callId === undefined) return undefined - const root = rootToolCall(snapshot, approval.callId) - if (root === undefined) return undefined - return root.callId === approval.callId && !('kind' in root) ? commandOf(root) : undefined - }) - return -} - -function ApprovalFlow({ pending, command, t }: { - pending: PendingApproval - command?: string - t: ApprovalComposerProps['t'] -}) { - // Keep actions disabled until the resolved frame arrives; failed answers - // re-enable them for retry. - const [answered, setAnswered] = useState(false) - const answer = (outcome: 'allowed-once' | 'rejected'): void => { - setAnswered(true) - void pending.answer(outcome).catch(() => { setAnswered(false) }) - } - return ( -
-
-
{t('approval.waiting')}
- {/* Tab stop: the region scrolls once the command passes the cap and - holds nothing focusable of its own, so without one a keyboard-only - user cannot reach the command's tail before answering. */} -
-
{pending.reason ?? t('approval.escalation', { toolName: pending.toolName })}
- {command !== undefined &&
{command}
} -
-
- - -
-
-
- ) -} diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx index b6b62468d1..06c063c91a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx @@ -5,12 +5,12 @@ * capacity. */ import { useEffect, useRef, useState } from 'react' -import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client' +import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: the `contextPressure` / `contextBreakdown` projection key merges. import type {} from '@deepseek-ai/dsh-token-meter/client' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' -import { contextOccupancy, formatTokens } from '../chat/StatsLine.tsx' +import { contextOccupancy } from '../context-occupancy.ts' import css from './ContextMeter.module.css' /** Ring geometry: 14px viewBox, 2px stroke. */ @@ -31,6 +31,20 @@ const ROWS = [ { key: 'messageTokens', label: 'context.messages', color: css.colorMessages }, ] as const +/** + * Format a token count for the compact context panel. + * @param value - token count. + * @returns compact count using K or M when needed. + */ +function formatTokens(value: number): string { + const scaled = (candidate: number): string => candidate >= 100 + ? String(Math.round(candidate)) + : String(Math.round(candidate * 10) / 10) + if (value < 1_000) return String(value) + if (value < 1_000_000) return `${scaled(value / 1_000)}K` + return `${scaled(value / 1_000_000)}M` +} + export interface ContextMeterProps { useProjection: UseProjection /** The owning bar's locale seat, passed down as a plain prop. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 588f93d057..6357c8cb8b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -7,8 +7,8 @@ /* Shared width axis for the whole column: one content width W (--dsh-chat-content-width) for the transcript, the dock cards - (todo/goal/queue: card minus four insets, 4 x 8 = 32), and the takeover - cards (question/approval/plan review); the input card alone is W + 32px. + (todo/goal/queue: card minus four insets, 4 x 8 = 32), and business-owned + takeover cards; the input card alone is W + 32px. The relation also holds when a narrow viewport shrinks everything: the chat scroller and the takeover frames pad clearance + 16px per side while the input card clears the bare clearance, so the input card stays exactly diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 1cbe99ac82..458c15e2e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -4,8 +4,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' -import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' +import { conversationPhase } from '../contract/snapshot.ts' import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import css from './ConversationRoot.module.css' @@ -13,14 +14,18 @@ import css from './ConversationRoot.module.css' export type ConversationRootProps = ConversationSlotProps export function ConversationRoot({ - sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock, - useSessionPendingInteraction, + sessionId, useSession, useSessions, useSessionPendingInteraction, + useWorkspaces, useConversation, useInput, useComposerBlock, renderSlot, renderSlotChain, selectWorkspace, t, }: ConversationRootProps) { - const openState = useSession(s => s.openState) - const composerPhase = useSession(s => s.composerPhase) - const pendingInteraction = useSessionPendingInteraction(interactions => interactions[0]) const session = useSession(s => s) + const pendingInteraction = useSessionPendingInteraction(snapshot => + sessionId === undefined ? undefined : snapshot.get(sessionId)) + const conversation = useConversation(s => s) + const shellPhase = session === undefined || conversation === undefined + ? 'blank' + : conversationPhase(session, conversation) + const openState = session?.openState const inputState = useInput(s => s) const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank) @@ -34,7 +39,7 @@ export function ConversationRoot({ const pickerAnchor = useRef(null) // Publishes the seat's live height as --dsh-composer-height on the scroll - // body so floating controls (ChatView back-to-bottom) clear the composer as + // body so floating View controls clear the composer as // it grows. Callback ref, not an effect; stable identity prevents observer // churn while the first blank session fills the resident body outlet. const seatObserver = useRef(null) @@ -75,10 +80,10 @@ export function ConversationRoot({ // The exemption is deliberately open-state-wide, not loading-only: a // summary-blank session is the hero before its open starts (`cold`) and // after one fails (`error`) for the same reason — there is no history. - const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading' + const settling = sessionId !== undefined && shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true const hero = sessionId === undefined - || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true)) + || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)) const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } @@ -149,11 +154,10 @@ export function ConversationRoot({ // user clears it. ? { blocked: composerBlock, placeholder: composerBlock.reason } : hero ? { placeholder: t('placeholder.hero') } : {}), - overlay: renderSlot('conversation.input.overlay', {}), + overlay: sessionId === undefined ? undefined : renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), - // Stats band under the card, inside the bar's width column so both - // share one constraint (composer.dock = stats-line family). + // Ambient dock under the card shares the composer's width constraint. footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null, }) @@ -170,13 +174,13 @@ export function ConversationRoot({ const phase = settling ? 'settling' : hero ? 'hero' : 'active' const composer = renderSlotChain( 'conversation.composer', - { pendingInteraction, session }, - { fallback: composerBar, overlay: true }, + { sessionId, session, pendingInteraction }, + { fallback: composerBar, fallbackOnly: sessionId === undefined, overlay: true }, ) // Sticky wraps the whole chain output (fallback + elected overlay), not // only `.composerStack`: overlay:true renders those as siblings, and sticky - // on the fallback alone would leave Question/Approval panels at the content + // on the fallback alone would leave a business-owned takeover at the content // end off-screen when the user is not pinned to the floor. const composerSeat = (
@@ -186,9 +190,9 @@ export function ConversationRoot({ return (
- {renderSlot('conversation.session.header', {})} + {sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
- {renderSlot('conversation.session', {})} + {sessionId === undefined ? null : renderSlot('conversation.session', {})} {composerSeat}
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index c726bcc753..ce989f076e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,11 +1,13 @@ /** Strict per-session header/body content inserted into the resident conversation layout. */ -import { useEffect, useSyncExternalStore } from 'react' +import { useEffect } from 'react' import clsx from 'clsx' -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, } from '../contract/slots.ts' +import { conversationPhase } from '../contract/snapshot.ts' import type { ViewTab } from '../contract/views.ts' import css from './ConversationRoot.module.css' @@ -23,11 +25,10 @@ interface Breadcrumb { const DEFAULT_VIEW_ID = 'chat' -/** Resolve by id and keep stale persisted selections on the stable Chat fallback. */ +/** Resolve a persisted selection, then registered Chat, without choosing another View. */ function resolveActiveView(tabs: readonly ViewTab[], selectedId: string | null): ViewTab | undefined { - const requestedId = selectedId ?? DEFAULT_VIEW_ID - return tabs.find(view => view.id === requestedId) - ?? tabs.find(view => view.id === DEFAULT_VIEW_ID) + const selected = selectedId === null ? undefined : tabs.find(view => view.id === selectedId) + return selected ?? tabs.find(view => view.id === DEFAULT_VIEW_ID) } function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] { @@ -64,17 +65,16 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum * @returns the hidden blank-session header or visible title and tabs. */ export function ConversationSessionHeader({ - sessionId, useSession, useSessions, useStore, actions, - renderSlot, views, open, t, + sessionId, useSession, useSessions, useConversation, useConversationViews, useStore, actions, + renderSlot, open, t, }: ConversationSessionHeaderProps) { - useSyncExternalStore(views.subscribe, views.version) - const tabs = views.list() + const tabs = useConversationViews(value => value) const selectedId = useStore(s => s.view) const active = resolveActiveView(tabs, selectedId) const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs) - const composerPhase = useSession(s => s.composerPhase) - const blank = useSession(s => s.blank) - const hideChrome = blank && composerPhase === 'blank' + const session = useSession(s => s) + const conversation = useConversation(s => s) + const hideChrome = session.blank && conversationPhase(session, conversation) === 'blank' return (
value) const selectedId = useStore(s => s.view) const active = resolveActiveView(tabs, selectedId) - const composerPhase = useSession(s => s.composerPhase) - const blank = useSession(s => s.blank) + const session = useSession(s => s) + const conversation = useConversation(s => s) const inputState = useInput(s => s) const storedDraft = useStore(s => s.draft) - // `?? null`: persisted snapshots from before the inspect field rehydrate without it. - const inspect = useStore(s => s.inspect ?? null) + const viewRequest = useStore(s => s.viewRequest ?? null) useEffect(() => { if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) @@ -193,16 +191,13 @@ export function ConversationSession({ // the machine mirror, not this seed effect. }, [inputActions]) - useEffect(() => () => { - releaseSessionImages(sessionId) - }, [releaseSessionImages, sessionId]) - - if (blank && composerPhase === 'blank') return null + if (session.blank && conversationPhase(session, conversation) === 'blank') return null return (
{active !== undefined && renderSlot('conversation.view', { - inspect, - onInspectDone: () => { actions.setInspect(null) }, + viewRequest, + openView: actions.openView, + completeViewRequest: actions.completeViewRequest, }, { only: active.id })}
) diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index c10cec74f9..2fdfe5e304 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -6,7 +6,7 @@ import type { ReactNode, RefObject } from 'react' import { FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, } from '@deepseek-ai/dsh-client-ui-primitives' -import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client' +import { workspaceTitleOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { ConversationSlotProps } from '../contract/slots.ts' import css from './HeroShell.module.css' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 03a4116b8c..d22049b370 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -17,16 +17,14 @@ import { import type {} from '@deepseek-ai/dsh-plan-mode/client' // Type-only: the `goal` projection key merge (hint disambiguation). import type {} from '@deepseek-ai/dsh-goal/client' -// The `imageLimits` projection key merge (intake pre-check) arrives with the -// wire types: apiproxy's sessions contract declares it, and client-runtime's -// api-remotes import already places it in every client program. +// The `imageLimits` projection key merge supplies the intake pre-check. import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerBarProps } from '../contract/slots.ts' -import { deriveDecorations } from '../input/decorations.ts' -import type { DraftDecorations } from '../input/decorations.ts' -import type { EditRange } from '../input/contract.ts' +import { deriveDecorations } from './decorations.ts' +import type { DraftDecorations } from './decorations.ts' +import type { EditRange } from '../contract/input.ts' import { attachmentErrorText, imageSizeText } from '../image-labels.ts' -import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' +import { ReferenceIcon } from './ReferenceIcon.tsx' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' @@ -502,7 +500,7 @@ export function InputBar({ keyboard.track(keyboard.snapshot.draft, caret) } - // Intake pre-check (DeepSeek Chat semantics): an addition that would break + // Intake pre-check: an addition that would break // a projected limit is refused as a whole batch, announced immediately, and // never enters the rail — no more submit-time failure rolling the rail // back. The host enforces the same limits at submit for callers that bypass @@ -511,7 +509,7 @@ export function InputBar({ if (addImages === undefined || files.length === 0) return const rejected = ((): string | null => { if (imageLimits !== undefined) { - // Format precedes limits (DeepSeek Chat's filter order): a batch with + // Format precedes limits: a batch with // a non-image must announce the format problem, not a count or size // it could never pass anyway — addImages rejects it authoritatively. if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) { @@ -785,13 +783,13 @@ export function InputBar({
{accessSelect} - {renderSlot('conversation.input.plan', { locked })} + {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })}
{leftItems}
{rightItems} - {renderSlot('conversation.input.model', { locked: modelSeatLocked })} + {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} {interruptible && ( diff --git a/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx b/packages/client/ui-conversation/src/client/skeleton/ReferenceIcon.tsx similarity index 100% rename from packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx rename to packages/client/ui-conversation/src/client/skeleton/ReferenceIcon.tsx diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/skeleton/decorations.ts similarity index 98% rename from packages/client/ui-conversation/src/client/input/decorations.ts rename to packages/client/ui-conversation/src/client/skeleton/decorations.ts index 1ae5e9404b..46dcfda594 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/skeleton/decorations.ts @@ -4,7 +4,7 @@ * highlight, the claim hint as ghost text). Zero React — the skeleton renders * the instructions; tests drive this directly. */ -import type { InputState } from './contract.ts' +import type { InputState } from '../contract/input.ts' /** The claim-token highlight range (always draft-leading while the watch holds). */ export interface TokenRange { diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index 5a4913f0fa..6cbbb9523b 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -1,34 +1,31 @@ -/** - * Per-session chat store shared by conversation and details registrations. - * The plugin creates its handle at apply time so identity follows the fiber. - */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts' +/** Per-session Conversation store shared by the shell body and header. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { ConversationStoreState } from './contract/views.ts' -/** Declared action shape used to give the exported factory a stable return type. */ -type ChatActions = { - select: (draft: ChatStoreState, target: SelectionTarget | null) => void - setDraft: (draft: ChatStoreState, text: string) => void - setView: (draft: ChatStoreState, view: string) => void - setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void +/** Declared write set for the Conversation shell. */ +type ConversationActions = { + setDraft: (draft: ConversationStoreState, text: string) => void + setView: (draft: ConversationStoreState, view: string) => void + openView: (draft: ConversationStoreState, view: string, focus: string) => void + completeViewRequest: (draft: ConversationStoreState) => void } /** - * Declares the per-session chat state and write surface. + * Declare per-session draft persistence and View selection. * @returns the store handle. */ -export function createChatStore(): EngineStoreHandle { +export function createConversationStore(): EngineStoreHandle { return defineStore({ - // Anchored to the contract shape: consumers read the store through - // PropsStore's SnapshotSelectorHook, so init - // and the contract cannot drift. - init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }), - persist: 'dsh.conversation.chat', + init: (): ConversationStoreState => ({ draft: '', view: null, viewRequest: null }), + persist: 'dsh.conversation', actions: { - select: (d, target: SelectionTarget | null) => { d.selection = target }, setDraft: (d, text: string) => { d.draft = text }, setView: (d, view: string) => { d.view = view }, - setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target }, + openView: (d, view: string, focus: string) => { + d.view = view + d.viewRequest = { view, focus } + }, + completeViewRequest: (d) => { d.viewRequest = null }, }, }) } diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts index 7e0b5b9a9b..db950ebd65 100644 --- a/packages/client/ui-conversation/src/invariant.ts +++ b/packages/client/ui-conversation/src/invariant.ts @@ -15,10 +15,8 @@ export const name = 'client-ui-conversation-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the conversation service emits no cordis events, and - * both rings this package owns (the 'conversation.view' tab ring and the - * 'conversation.chat.node' business renderer seat) ride the slot system, whose ledger - * invariants live with the runtime slots package. + * No runtime invariant: Conversation Definitions, target builders, and Views + * are already validated by their owning registries and the Slot ledger. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx deleted file mode 100644 index c4c18f2623..0000000000 --- a/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx +++ /dev/null @@ -1,124 +0,0 @@ -// @vitest-environment jsdom - -import { describe, expect, it, vi } from 'vitest' -import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' - -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') - -const ROOT = 'root-1' as SessionId -const CHILD = 'child-1' as SessionId - -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: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) - await runtime.sessions.add( - { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) - runtime.slots.installLocale(locale) - - // Declared by ui-layout's root entry in production; the test root declares - // them here so the contributions land. - await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - 'settings.general.item': { kind: 'list', scope: 'root' }, - }, (_p: { renderSlot?: unknown }) => null) - - const feature = await runtime.mount({ inject: [...inject], apply }) - return { runtime, feature, slots: runtime.slots } -} - -/** First stored entry for a key (inject/store live directly on StoredEntry). */ -function renderEntryOf(slots: Awaited>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') { - return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown } -} - -describe('apply wiring', () => { - it('provides the conversation service', async () => { - const b = await bench() - expect(b.runtime.ctx.get('conversation')).toBeDefined() - await b.runtime.dispose() - }) - - it('registers the chat view and its keyed business-node seat', async () => { - const b = await bench() - const entries = b.slots.entries('conversation.view') - expect(entries.map(e => e.options.id)).toEqual(['chat']) - // Label is a locale thunk resolving through the zh dictionary. - expect(resolveSlotLabel(entries[0]?.options.label)).toBe('对话') - expect(entries[0]?.options.order).toBe(0) - // Declaring is claiming: the chat entry's registration put the hole on - // the ledger with the contract's kind/scope. - const nodeSlot = b.slots.spec('conversation.chat.node') - expect(nodeSlot).toMatchObject({ kind: 'keyed', scope: 'session' }) - expect(nodeSlot?.inject?.hooks?.turnData).toBeTypeOf('function') - await b.runtime.dispose() - }) - - it('occupies the slots + the ring; session entries share one store handle', async () => { - const b = await bench() - const conversation = renderEntryOf(b.slots, 'conversation') - const conversationSession = renderEntryOf(b.slots, 'conversation.session') - const conversationHeader = renderEntryOf(b.slots, 'conversation.session.header') - const chatView = renderEntryOf(b.slots, 'conversation.view') - const details = renderEntryOf(b.slots, 'details') - expect(conversation?.inject).toBeTypeOf('function') - expect(chatView?.inject).toBeTypeOf('function') - expect(details?.inject).toBeTypeOf('function') - // The shared handle: one apply-built store value on ALL session entries - // (the session-maybe 'conversation' shell carries no store by design). - expect(conversationSession?.store).toBeDefined() - expect(conversationHeader?.store).toBe(conversationSession?.store) - expect(details?.store).toBe(conversationSession?.store) - expect(chatView?.store).toBe(conversationSession?.store) - // The hero holes ride the conversation entry's children declaration (the - // empty-state occupant is gone). Both are root-scoped: the new-session - // screen precedes the session either would belong to. - expect(b.slots.spec('conversation.hero.brand.mark')).toEqual({ kind: 'single', scope: 'root' }) - expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) - expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' }) - expect(b.slots.spec('conversation.session.header.lineage')) - .toEqual({ kind: 'single', scope: 'session' }) - expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter']) - await b.runtime.dispose() - }) - - it('leaves per-Tool rows to the ui-tool plugin', async () => { - const b = await bench() - // The actual toolview declaration activates every registrant. The - // file-mutation registrant claims both write and edit for the diff card; the - // one search row registers under both grep and glob; the web rows register - // one component under both web tool names. - expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).not.toContain('tool-call') - // Stats stick with the composer (not inside ChatView). - expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) - await b.runtime.dispose() - }) - - it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => { - const b = await bench() - await b.feature.dispose() - expect(b.slots.entries('conversation')).toHaveLength(0) - // The declared ring collapses with its declaring entry, and the chat - // entry's keyed hole (with the sample's registration) collapses with it. - expect(b.slots.entries('conversation.view')).toHaveLength(0) - expect(b.slots.entries('conversation.chat.node')).toHaveLength(0) - expect(b.slots.spec('conversation.chat.node')).toBeUndefined() - expect(b.slots.entries('details')).toHaveLength(0) - expect(b.slots.entries('settings.general.item')).toHaveLength(0) - expect(b.runtime.ctx.get('conversation')).toBeUndefined() - await b.runtime.dispose() - }) -}) diff --git a/packages/client/ui-conversation/tests/chat-store.client.spec.ts b/packages/client/ui-conversation/tests/chat-store.client.spec.ts deleted file mode 100644 index 79bdd6ecaa..0000000000 --- a/packages/client/ui-conversation/tests/chat-store.client.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -// @vitest-environment jsdom -/** Chat-store actions, scoped persistence, and instance isolation. */ -import { beforeEach, describe, expect, it } from 'vitest' -import { createChatStore } from '../src/client/stores.ts' - -const KEY = 'dsh.conversation.chat' - -beforeEach(() => { - localStorage.clear() -}) - -describe('createChatStore', () => { - it('init shape: empty selection/draft/view', () => { - const store = createChatStore().create() - expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null }) - }) - - it('actions cover the declared write set', () => { - const store = createChatStore().create() - - store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) - expect(store.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) - store.actions.select(null) - expect(store.store.getSnapshot().selection).toBeNull() - - store.actions.setDraft('hello') - expect(store.store.getSnapshot().draft).toBe('hello') - - store.actions.setView('chat') - expect(store.store.getSnapshot().view).toBe('chat') - - store.actions.setInspect({ callId: 'c1' }) - expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' }) - store.actions.setInspect(null) - expect(store.store.getSnapshot().inspect).toBeNull() - }) - - it('persists per scope key and rehydrates a fresh instance', () => { - const handle = createChatStore() - const s1 = handle.create('sess-1') - s1.actions.setDraft('draft for one') - s1.actions.select({ turnSeq: 1 }) - - // Scope-suffixed key: each session persists separately. - expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull() - expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull() - - // A rebuilt instance under the same scope key rehydrates the state. - const again = createChatStore().create('sess-1') - expect(again.store.getSnapshot().draft).toBe('draft for one') - expect(again.store.getSnapshot().selection).toEqual({ turnSeq: 1 }) - - // A sibling scope starts clean. - const other = createChatStore().create('sess-2') - expect(other.store.getSnapshot().draft).toBe('') - }) - - it('clearPersisted removes the scope entry (session-death cleanup hook)', () => { - const store = createChatStore().create('sess-9') - store.actions.setDraft('doomed') - expect(localStorage.getItem(`${KEY}.sess-9`)).not.toBeNull() - store.clearPersisted() - expect(localStorage.getItem(`${KEY}.sess-9`)).toBeNull() - }) - - it('every create() is an independent instance; the factory holds no singleton', () => { - const handle = createChatStore() - const a = handle.create() - const b = handle.create() - a.actions.setDraft('only in a') - expect(b.store.getSnapshot().draft).toBe('') - // Two factory calls likewise share no LIVE state (identity is per handle - // VALUE, not per module — the sharing contract lives in the framework's - // handle x scope-key resolution, not in module state). Persistence is the - // one sanctioned cross-instance channel: clear it so this assertion sees - // memory identity, not rehydration (covered by the persist case above). - localStorage.clear() - const c = createChatStore().create() - expect(c.store.getSnapshot().draft).toBe('') - }) -}) diff --git a/packages/client/runtime/tests/context-provenance.client.spec.ts b/packages/client/ui-conversation/tests/context-provenance.client.spec.ts similarity index 97% rename from packages/client/runtime/tests/context-provenance.client.spec.ts rename to packages/client/ui-conversation/tests/context-provenance.client.spec.ts index b64902719c604e52f96a5f81c255583dab6a6898..3259aac23e68b0aad326df29f318f92cee14ef47 100644 GIT binary patch delta 33 pcmZ3fu|{LUZ6+>-iMLIdbhsu9Fp6;`=jW9aB_@|_)?@rF1OUE=3nTyl delta 46 zcmZ3Zu~K8g?TL3yGxPI`Hybkk76JgJ CL=mF^ diff --git a/packages/client/runtime/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts similarity index 92% rename from packages/client/runtime/tests/conversation-assembler.client.spec.ts rename to packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index ea349d6dbe..ed2c25bd15 100644 --- a/packages/client/runtime/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConversationEventInput, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, -} from '../src/client/contract/conversation.ts' +} from '@deepseek-ai/dsh-client-ui-conversation/client' interface ScopeProbeStepData { readonly value: number @@ -14,7 +14,7 @@ interface ScopeProbeTurnData { readonly valueSeenFromStep: number } -declare module '../src/client/contract/conversation.ts' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationStepDataMap { 'scope-probe': ScopeProbeStepData } @@ -62,7 +62,7 @@ function testView( apply = vi.fn(), ): ConversationViewDefinition { return { - target: 'chat', + target: 'test', create: () => { let current: TestSnapshot = { order: [], nodes: new Map() } return { @@ -92,11 +92,11 @@ function at(seq: number, type: string, data: unknown): SessionEvent { } function input(event: SessionEvent): ConversationEventInput { - return { event, view: undefined } + return { event } } -function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined { - return assembler.snapshot('chat') as TestSnapshot | undefined +function testSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined { + return assembler.snapshot('test') as TestSnapshot | undefined } function node( @@ -107,7 +107,7 @@ function node( key: context.key, kind: context.kind, id: context.id, - target: 'chat', + target: 'test', data, } } @@ -115,7 +115,7 @@ function node( function fallbackDefinition(start: () => string): ConversationNodeDefinition { return { kind: 'fallback', - target: 'chat', + target: 'test', match: event => ({ id: String(event.seq), role: 'start' }), start, update: context => context.state, @@ -142,7 +142,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -165,7 +165,7 @@ describe('ConversationNodeAssembler', () => { expect(starts).not.toHaveBeenCalled() expect(updates).toHaveBeenCalledOnce() - const snapshot = chatSnapshot(assembler) + const snapshot = testSnapshot(assembler) expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([ { callSeq: 1, results: 1 }, { callSeq: 2, results: 0 }, @@ -194,7 +194,7 @@ describe('ConversationNodeAssembler', () => { matchCollections.add(context.matches) return updates(context) }, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -212,7 +212,7 @@ describe('ConversationNodeAssembler', () => { expect(starts).not.toHaveBeenCalled() expect(updates).toHaveBeenCalledTimes(1_000) expect(matchCollections.size).toBe(1) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000) }) it('merges an older page and replays its affected Context once', () => { @@ -230,7 +230,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -256,7 +256,7 @@ describe('ConversationNodeAssembler', () => { expect(starts).toHaveBeenCalledOnce() expect(updates).toHaveBeenCalledTimes(200) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200) }) it('collects an update before its start and replays it once prepend supplies the start', () => { @@ -270,7 +270,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => ({ settled: false }), update: updates, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state ?? { pendingStart: true }), } const assembler = new ConversationNodeAssembler( @@ -283,7 +283,7 @@ describe('ConversationNodeAssembler', () => { message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, }))], true) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ pendingStart: true }) assembler.prepend([input(at(5, 'tool/call', { @@ -292,7 +292,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(updates).toHaveBeenCalledOnce() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ settled: true }) }) @@ -304,7 +304,7 @@ describe('ConversationNodeAssembler', () => { : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: () => null, } const assembler = new ConversationNodeAssembler( @@ -326,7 +326,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0), update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: () => null, } const consumerStart = vi.fn(( @@ -341,7 +341,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -352,7 +352,7 @@ describe('ConversationNodeAssembler', () => { turn: 2, step: 1, message: { role: 'assistant', content: [] }, }))], true) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) assembler.prepend([input(at(5, 'user/message', { id: 'm1', value: 7, content: [], source: { kind: 'user' }, @@ -360,7 +360,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(consumerStart).toHaveBeenCalledTimes(2) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7) }) it('keeps the predecessor index ordered across prepend and append', () => { @@ -371,7 +371,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => match.event.seq, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: () => null, } const consumer: ConversationNodeDefinition = { @@ -381,7 +381,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, _match, reader) => reader.previous('source')?.state ?? -1, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -409,7 +409,7 @@ describe('ConversationNodeAssembler', () => { }))) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) .toEqual([40, 60]) }) @@ -426,7 +426,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -442,7 +442,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(consumerStart).toHaveBeenCalledTimes(2) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) }) it('replays direct dependents when an append revises their predecessor Context', () => { @@ -455,7 +455,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 1, update: (_context, match) => (match.event.data as unknown as { value: number }).value, - target: 'chat', + target: 'test', buildViewNode: () => null, } const consumerStart = vi.fn(( @@ -470,7 +470,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -487,7 +487,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(consumerStart).toHaveBeenCalledTimes(2) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) }) it('replays a transitive dependency closure in start order', () => { @@ -500,7 +500,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 1, update: (_context, match) => (match.event.data as unknown as { value: number }).value, - target: 'chat', + target: 'test', buildViewNode: () => null, } const sourceX: ConversationNodeDefinition = { @@ -512,7 +512,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 10, update: (_context, match) => (match.event.data as unknown as { value: number }).value, - target: 'chat', + target: 'test', buildViewNode: () => null, } const middle: ConversationNodeDefinition = { @@ -525,7 +525,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous('diamond-x')?.state ?? 0) ), update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const consumer: ConversationNodeDefinition = { @@ -538,7 +538,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous('diamond-b')?.state ?? 0) ), update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -556,7 +556,7 @@ describe('ConversationNodeAssembler', () => { assembler.append(input(at(6, 'diamond/a', { value: 2 }))) assembler.flush() - const value = [...chatSnapshot(assembler)?.nodes.values() ?? []] + const value = [...testSnapshot(assembler)?.nodes.values() ?? []] .find(candidate => candidate.kind === 'diamond-c') expect(value?.data).toBe(222) }) @@ -574,7 +574,7 @@ describe('ConversationNodeAssembler', () => { : null, start: starts, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -586,14 +586,14 @@ describe('ConversationNodeAssembler', () => { input(at(2, 'step/start', { turn: 1, step: 1 })), ], false) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open') + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open') assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 }))) assembler.flush() expect(starts).toHaveBeenCalledTimes(2) expect(apply).toHaveBeenCalledOnce() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed') + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed') }) it('lets one Context publish Step and Turn data in phase order', () => { @@ -646,7 +646,7 @@ describe('ConversationNodeAssembler', () => { value: { valueSeenFromStep: stepValue ?? -1 }, } }, - target: 'chat', + target: 'test', buildViewNode: (context) => { const location = context.start?.location if (location?.kind !== 'step') return null @@ -665,13 +665,13 @@ describe('ConversationNodeAssembler', () => { input(at(2, 'step/start', { turn: 1, step: 1 })), ], false) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ step: 1, turn: 1 }) assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 }))) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ step: 2, turn: 2 }) }) @@ -684,7 +684,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.start?.location.kind === 'turn' ? context.start.location.turn.steps.length : -1), @@ -695,13 +695,13 @@ describe('ConversationNodeAssembler', () => { ) assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0) assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 }))) assembler.flush() expect(apply).toHaveBeenCalledOnce() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) }) it('publishes a changed timeline even when no business Definition claims the boundary', () => { @@ -717,7 +717,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(apply).toHaveBeenCalledOnce() - expect(chatSnapshot(assembler)?.order).toEqual([]) + expect(testSnapshot(assembler)?.order).toEqual([]) }) it('clears the prior Step at a new Turn and honors explicit session ownership', () => { @@ -740,7 +740,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: (context) => { const location = context.start?.location const data = location?.kind === 'step' @@ -762,7 +762,7 @@ describe('ConversationNodeAssembler', () => { ], false) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) .toEqual(['turn:2', 'session']) }) @@ -774,7 +774,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.start?.location.kind), } const assembler = new ConversationNodeAssembler( @@ -790,7 +790,7 @@ describe('ConversationNodeAssembler', () => { assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } }))) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn') + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn') }) it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => { @@ -801,7 +801,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: (context) => { const location = context.start?.location return node(context, location?.kind === 'step' @@ -822,7 +822,7 @@ describe('ConversationNodeAssembler', () => { assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) .toEqual(['2:3', '2:3']) }) @@ -834,7 +834,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: (context) => { const location = context.start?.location return node(context, location?.kind === 'step' @@ -853,7 +853,7 @@ describe('ConversationNodeAssembler', () => { ], true) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toBe('closed:closed') }) @@ -869,7 +869,7 @@ describe('ConversationNodeAssembler', () => { : null, start: seen, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -904,7 +904,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(fallbackStart).toHaveBeenCalledOnce() - expect(chatSnapshot(assembler)?.order).toHaveLength(1) + expect(testSnapshot(assembler)?.order).toHaveLength(1) }) it('invokes the fallback when only another target claims an event', () => { @@ -928,14 +928,14 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(fallbackStart).toHaveBeenCalledOnce() - expect(chatSnapshot(assembler)?.order).toHaveLength(1) + expect(testSnapshot(assembler)?.order).toHaveLength(1) }) it('suppresses the fallback when the same target claims an event', () => { const fallbackStart = vi.fn(() => 'fallback') const claimed: ConversationNodeDefinition = { kind: 'claimed', - target: 'chat', + target: 'test', match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null, start: () => null, update: context => context.state, @@ -949,7 +949,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(fallbackStart).not.toHaveBeenCalled() - expect(chatSnapshot(assembler)?.order).toEqual([]) + expect(testSnapshot(assembler)?.order).toEqual([]) }) it('rejects withdrawing a previously materialized Node during an incremental update', () => { @@ -962,7 +962,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => false, - target: 'chat', + target: 'test', buildViewNode: context => context.state === true ? node(context, true) : null, } const assembler = new ConversationNodeAssembler( @@ -971,12 +971,12 @@ describe('ConversationNodeAssembler', () => { ) assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) assembler.flush() - expect(chatSnapshot(assembler)?.order).toHaveLength(1) + expect(testSnapshot(assembler)?.order).toHaveLength(1) assembler.append(input(at(2, 'toggle/hide', {}))) - expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/) + expect(() => assembler.flush()).toThrow(/withdrew materialized target "test"/) - expect(chatSnapshot(assembler)?.order).toHaveLength(1) + expect(testSnapshot(assembler)?.order).toHaveLength(1) }) it('fails loud when a Definition returns undefined State', () => { @@ -985,7 +985,7 @@ describe('ConversationNodeAssembler', () => { match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, start: () => undefined, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: () => null, } const startAssembler = new ConversationNodeAssembler( @@ -1005,7 +1005,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => undefined as never, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const updateAssembler = new ConversationNodeAssembler( @@ -1026,7 +1026,7 @@ describe('ConversationNodeAssembler', () => { match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, start: (_context, match) => match.event.seq, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -1042,6 +1042,6 @@ describe('ConversationNodeAssembler', () => { input(at(2, 'command/run', { commandId: 'two', name: 'x' })), )).toThrow(/received more than one start Match/) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) }) }) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts new file mode 100644 index 0000000000..06e76ce8c2 --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -0,0 +1,241 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { + createScope, MutableSessionEventSource, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { + ISessions, SessionBinding, SessionFace, SessionListState, SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import { + ConversationEventRegistry, ConversationNodeAssembler, ConversationViewRegistry, UiConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +const SESSION_ID = 'resident' as SessionId + +function sessionSnapshot(): SessionSnapshot { + return { + sessionId: SESSION_ID, + queue: [], + running: false, + subagent: null, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: true, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + } +} + +function fakeSession(): SessionFace { + const snapshot = createSnapshotStore(sessionSnapshot()) + return { + sessionId: SESSION_ID, + projections: { faceOf: () => createSnapshotStore(undefined) }, + getSnapshot: () => snapshot.getSnapshot(), + subscribe: listener => snapshot.subscribe(listener), + prompt: () => Promise.reject(new Error('unused fake Session operation')), + readAttachment: () => Promise.reject(new Error('unused fake Session operation')), + updateQueue: () => Promise.reject(new Error('unused fake Session operation')), + cancel: () => Promise.reject(new Error('unused fake Session operation')), + rename: () => Promise.reject(new Error('unused fake Session operation')), + loadOlder: () => Promise.reject(new Error('unused fake Session operation')), + command: () => Promise.reject(new Error('unused fake Session operation')), + } +} + +function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBinding } { + const scope = createScope(ctx, SESSION_ID) + const binding: SessionBinding = { + sessionId: SESSION_ID, + session: fakeSession(), + eventSource: new MutableSessionEventSource(), + ctx: scope.ctx, + } + const list = createSnapshotStore({ + ids: [], + byId: {}, + current: undefined, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) + const sessions = { + list, + searchResultLimit: 50, + create: () => Promise.reject(new Error('unused fake Sessions operation')), + open: () => {}, + openSubagent: () => {}, + subagentAddress: () => undefined, + setSubagentCatalogOpen: () => {}, + refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')), + noteAgentPreset: () => {}, + clear: () => {}, + refresh: () => Promise.reject(new Error('unused fake Sessions operation')), + search: () => Promise.reject(new Error('unused fake Sessions operation')), + fork: () => Promise.reject(new Error('unused fake Sessions operation')), + scope: id => id === SESSION_ID ? binding.ctx : undefined, + scopeOf: candidate => candidate === binding.ctx ? SESSION_ID : undefined, + sessionOf: candidate => candidate === binding.ctx ? binding.session : undefined, + binding: id => id === SESSION_ID ? binding : undefined, + } satisfies ISessions + return { sessions, binding } +} + +function eventDefinition(kind: string): ConversationNodeDefinition { + return { + kind, + target: 'chat', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } +} + +function viewDefinition(target: string): ConversationViewDefinition { + return { + target, + create: () => ({ + empty: null, + replace: () => null, + apply: () => null, + }), + } +} + +async function bootRegistries(): Promise<{ + ctx: Context + uiConversation: UiConversation + binding: SessionBinding + events: ConversationEventRegistry + views: ConversationViewRegistry +}> { + const ctx = new Context() + const { sessions, binding } = fakeSessions(ctx) + const uiConversation = new UiConversation(ctx, sessions) + return { + ctx, + uiConversation, + binding, + events: uiConversation.events, + views: uiConversation.views, + } +} + +describe('Conversation registries', () => { + it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => { + const { events } = await bootRegistries() + const definition = eventDefinition('message') + const dispose = events.register(definition) + + expect(events.entries()).toEqual([definition]) + expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.entries()).toEqual([]) + }) + + it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => { + const { events } = await bootRegistries() + const fallback = eventDefinition('unknown') + const dispose = events.registerFallback(fallback) + + expect(events.fallbackEntry()).toBe(fallback) + expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.fallbackEntry()).toBeUndefined() + }) + + it('rejects rendering Definitions that omit either target or builder', async () => { + const { events } = await bootRegistries() + const targetOnly: ConversationNodeDefinition = { + kind: 'target-only', + target: 'chat', + match: () => null, + start: () => null, + update: context => context.state, + } + const builderOnly: ConversationNodeDefinition = { + kind: 'builder-only', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + + expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/) + expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/) + }) + + it('rejects a State-only Definition as the unmatched-event fallback', async () => { + const { events } = await bootRegistries() + const fallback: ConversationNodeDefinition = { + kind: 'state-only-fallback', + match: () => null, + start: () => null, + update: context => context.state, + } + + expect(() => events.registerFallback(fallback)) + .toThrow('conversation fallback Definition must declare a target') + }) + + it('rejects duplicate view targets and disposes a view registration once', async () => { + const { views } = await bootRegistries() + const definition = viewDefinition('chat') + const dispose = views.register(definition) + + expect(views.entries()).toEqual([definition]) + expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/) + + dispose() + dispose() + expect(views.entries()).toEqual([]) + }) + + it('removes Event, fallback, and view contributions with their caller fiber', async () => { + const { ctx, events, views } = await bootRegistries() + const feature = ctx.inject(['uiConversation'], (featureCtx) => { + featureCtx.uiConversation.events.register(eventDefinition('message')) + featureCtx.uiConversation.events.registerFallback(eventDefinition('unknown')) + featureCtx.uiConversation.views.register(viewDefinition('chat')) + }) + await feature.await() + + expect(events.entries()).toHaveLength(1) + expect(events.fallbackEntry()).toBeDefined() + expect(views.entries()).toHaveLength(1) + + await feature.dispose() + expect(events.entries()).toEqual([]) + expect(events.fallbackEntry()).toBeUndefined() + expect(views.entries()).toEqual([]) + }) + + it('rebuilds every resident Conversation binding after each registry change', async () => { + const { uiConversation, binding, events, views } = await bootRegistries() + uiConversation.binding(binding) + const rebuild = vi.spyOn(ConversationNodeAssembler.prototype, 'rebuildRegistry') + + events.register(eventDefinition('message')) + expect(rebuild).toHaveBeenCalledOnce() + + views.register(viewDefinition('chat')) + expect(rebuild).toHaveBeenCalledTimes(2) + rebuild.mockRestore() + }) +}) diff --git a/packages/client/ui-conversation/tests/conversation-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-store.client.spec.ts new file mode 100644 index 0000000000..6ba0b43f4b --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-store.client.spec.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest' +import { createConversationStore } from '../src/client/stores.ts' + +const KEY = 'dsh.conversation' + +beforeEach(() => { + localStorage.clear() +}) + +describe('createConversationStore', () => { + it('owns draft, selected View, and one-shot View requests', () => { + const store = createConversationStore().create() + expect(store.store.getSnapshot()).toEqual({ draft: '', view: null, viewRequest: null }) + + store.actions.setDraft('hello') + store.actions.setView('chat') + expect(store.store.getSnapshot()).toEqual({ + draft: 'hello', + view: 'chat', + viewRequest: null, + }) + + store.actions.openView('trajectory', 'call-1') + expect(store.store.getSnapshot()).toMatchObject({ + view: 'trajectory', + viewRequest: { view: 'trajectory', focus: 'call-1' }, + }) + store.actions.completeViewRequest() + expect(store.store.getSnapshot().viewRequest).toBeNull() + }) + + it('persists per Session scope and clears the persisted value', () => { + const first = createConversationStore().create('sess-1') + first.actions.setDraft('draft for one') + first.actions.setView('chat') + expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull() + expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull() + + const restored = createConversationStore().create('sess-1') + expect(restored.store.getSnapshot()).toMatchObject({ + draft: 'draft for one', + view: 'chat', + }) + + first.clearPersisted() + expect(localStorage.getItem(`${KEY}.sess-1`)).toBeNull() + }) + + it('creates independent live instances', () => { + const handle = createConversationStore() + const first = handle.create() + const second = handle.create() + first.actions.setDraft('only first') + expect(second.store.getSnapshot().draft).toBe('') + }) +}) diff --git a/packages/client/ui-conversation/tests/coverage-tails.client.spec.ts b/packages/client/ui-conversation/tests/coverage-tails.client.spec.ts new file mode 100644 index 0000000000..4c5ebcb2f6 --- /dev/null +++ b/packages/client/ui-conversation/tests/coverage-tails.client.spec.ts @@ -0,0 +1,9 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import { apply as nodeApply } from '../src/index.ts' + +describe('node apply tail', () => { + it('tolerates a Host without settings', () => { + expect(() => { nodeApply(new Context()) }).not.toThrow() + }) +}) diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 1351e85649..fea7fa315b 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx' import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx' @@ -21,17 +24,21 @@ function emptySessions() { } function emptyWorkspaces() { - return bindSnapshotSelector(createSnapshotStore({ + return bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, })) } +function noPendingInteraction() { + return bindSnapshotSelector(createSnapshotStore(new Map())) +} + function mount() { const policy = new ComposerSubmissionPolicy() const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) }) const props: EnterBehaviorRowProps = { useSessions: emptySessions(), + useSessionPendingInteraction: noPendingInteraction(), useWorkspaces: emptyWorkspaces(), useBusyEnter: bindSnapshotSelector(policy.busyEnter), setBusyEnter, diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.ts b/packages/client/ui-conversation/tests/image-labels.client.spec.ts new file mode 100644 index 0000000000..8e1ff4409d --- /dev/null +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts' +import { en, zh } from '../src/client/locales.ts' + +const t = makeTranslate(zh, commonZh) +const enT = makeTranslate(en, commonZh) + +describe('attachment rejection copy', () => { + const limits = { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'] as const, + } + + it('renders megabytes without a trailing fraction unless one exists', () => { + expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB') + expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB') + }) + + it('maps user-solvable reasons to limit-naming copy', () => { + expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型') + expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片') + expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试') + expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') + expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') + expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片') + expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB') + expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片') + expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE', limits)).toBe('图片宽高不能超过 2000px,请缩小后重试') + expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images') + }) + + it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => { + expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试') + expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE')).toBe('图片发送失败(IMAGE_DIMENSION_TOO_LARGE),请重新添加图片后再试') + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 7f1c0a8f38..f36b1aa1c4 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -2,19 +2,21 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { Context } from '@deepseek-ai/cordis' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' + bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' import type { ComposerAttachment, ComposerAttachmentsOwnerProps, } from '../src/client/contract/slots.ts' -import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' @@ -33,18 +35,11 @@ Range.prototype.getBoundingClientRect = ZERO_RECT const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')! .value as (this: Range, node: Node, offset: number) => void -const SCTX = {} as ClientContext +const SCTX = {} as Context const SID = 's1' as SessionId -function snapshotOf(overrides: Partial = {}): ConversationSnapshot { - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, - ...overrides, - } +function snapshotOf(overrides: Partial = {}): SessionSnapshot { + return { ...sessionSnapshot(SID), ...overrides } } interface BenchOptions { @@ -66,14 +61,14 @@ interface BenchOptions { } draft?: string running?: boolean - subagent?: Exclude + subagent?: Exclude disabled?: boolean inert?: boolean workspacePickerOpen?: boolean onRequestWorkspace?: () => void - promptError?: ConversationSnapshot['promptError'] + promptError?: SessionSnapshot['promptError'] /** Authoritative queue rows served to the machine overlay (empty = none). */ - queue?: ConversationSnapshot['queue'] + queue?: SessionSnapshot['queue'] /** The hub's steer-all face (empty-draft accelerated Enter). */ steerQueue?: () => void variant?: 'hero' | 'composer' @@ -92,7 +87,7 @@ interface BenchOptions { } /** One pending queue row (the runtime snapshot shape, as the dock tests build it). */ -function row(id: string): ConversationSnapshot['queue'][number] { +function row(id: string): SessionSnapshot['queue'][number] { return { id: id as never, messageId: `message-${id}` as never, placement: 'queued', content: [{ type: 'text', text: id }], preview: id, text: id, @@ -108,7 +103,7 @@ function bench(over?: BenchOptions) { signal: AbortSignal, ) => Promise>(() => Promise.resolve({ kind: 'success' })) const lex = over?.lexicon - const session = createSnapshotStore(snapshotOf({ + const session = createSnapshotStore(snapshotOf({ running: over?.running ?? false, subagent: over?.subagent ?? null, removed: over?.disabled ?? false, @@ -149,12 +144,15 @@ function bench(over?: BenchOptions) { }) as InputBarProps['renderSlot'] const props: InputBarProps = { sessionId: SID, - SessionProvider: ({ children }) => children(SID), + SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, })), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, @@ -163,6 +161,7 @@ function bench(over?: BenchOptions) { (selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)), + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -326,7 +325,7 @@ describe('image draft rail', () => { }) it('announces server attachment rejections as product copy, other codes as developer text', () => { - const attachmentError = (reason: string): ConversationSnapshot['promptError'] => ({ + const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({ op: 'send', error: { code: 'attachment-error', message: 'raw wire text', details: { reason } }, }) diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 01df9588a8..28e7cc4430 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -9,11 +9,11 @@ */ import { describe, expect, it } from 'vitest' import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts' +import type { InputEffect, SubmitAttempt } from '../src/client/contract/input.ts' import { InputMachine, PLACEHOLDER, projectClipboard, referenceDraftText, } from '../src/client/input/machine.ts' -import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts' +import { deriveDecorations, scanTextRefs } from '../src/client/skeleton/decorations.ts' const LEGACY_PLACEHOLDER = PLACEHOLDER diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index bdaae95846..fd00297bee 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -7,15 +7,18 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { Context } from '@deepseek-ai/cordis' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' @@ -23,31 +26,33 @@ import { zh } from '../src/client/locales.ts' afterEach(cleanup) -const SCTX = {} as ClientContext +const SCTX = {} as Context const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { - const session = createSnapshotStore({ - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - 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, + const session = createSnapshotStore({ + ...sessionSnapshot(SID), + running: over?.running ?? false, + removed: over?.disabled ?? false, }) const props: InputBarProps = { sessionId: SID, - SessionProvider: ({ children }) => children(SID), + SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, })), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index 876f992af8..b2d1d34335 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -4,10 +4,10 @@ * accepted prompt. */ import { describe, expect, it, vi } from 'vitest' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from '@deepseek-ai/cordis' import type { InputTriggerController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' -import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import type { DraftAttachmentId } from '../src/client/contract/input.ts' const mention = '@[Research](dsh-session:InNvdXJjZSI)' const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)' @@ -36,7 +36,7 @@ describe('reference submission', () => { it('mirrors canonical reference text so a persisted draft remains resolvable after remount', async () => { const mirror = vi.fn() const first = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: vi.fn(), commandImages, }) @@ -58,7 +58,7 @@ describe('reference submission', () => { const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const restored = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: sink, commandImages, }) @@ -84,7 +84,7 @@ describe('reference submission', () => { track: vi.fn(), } as unknown as InputTriggerController const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, commandImages, @@ -126,7 +126,7 @@ describe('reference submission', () => { track: vi.fn(), } as unknown as InputTriggerController const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, commandImages, @@ -148,7 +148,7 @@ describe('reference submission', () => { it('aborts Host-side preparation when the input shell is disposed', () => { let signal: AbortSignal | undefined const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: (_text, _imageIds, _mode, received) => { signal = received return new Promise(() => {}) @@ -166,7 +166,7 @@ describe('reference submission', () => { it('retains a rejected default message without duplicating its prompt error notice', async () => { const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: () => Promise.resolve({ kind: 'error' }), commandImages, }) @@ -185,7 +185,7 @@ describe('submit transaction hardening', () => { let settle!: (outcome: SubmitOutcome) => void const sink = vi.fn(() => new Promise((resolve) => { settle = resolve })) const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: sink, commandImages, }) @@ -206,7 +206,7 @@ describe('submit transaction hardening', () => { it('retains an image-only rejection without duplicating its prompt error notice', async () => { const sink = vi.fn(() => Promise.resolve({ kind: 'error' })) const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: sink, commandImages, }) @@ -222,7 +222,7 @@ describe('submit transaction hardening', () => { it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => { const track = vi.fn() const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, inputTriggers: () => ({ track } as unknown as InputTriggerController), defaultSink: vi.fn(), commandImages, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 2961ae8e17..5bf96849e5 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -1,34 +1,32 @@ // @vitest-environment jsdom /** * Scenario-chain integration (scenarios A/C/D/H/I): the real per-session - * InputTriggerController pipeline over a real session scope (SessionRuntime over + * InputTriggerController pipeline over a real session scope (Client Sessions over * a listed host session) + a command source implementing the decision * table's relevant cells + the real SessionInput machine (scoped-event * listeners wired the way the hub does) + the real InputBar. ui-commands * itself is not a dependency of this package; the source below is the * decision-table contract at the `InputTriggerSource` boundary. */ -import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { - EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionRuntime, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.client.ts' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { + bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' afterEach(cleanup) @@ -102,21 +100,17 @@ const COMMANDS: FakeCommand[] = [ const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } -/** Real scope bench: SessionRuntime over one listed session + InputTriggerController + shell listeners (the hub wiring shape). */ +/** Real scope bench: a Controller-owned Session scope + InputTriggerController + shell listeners. */ async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) { - const ctx = new Context() - const api = new FakeApiClient() - const sessionId = 'scenario-s1' as Parameters[0] - api.onList = () => Promise.resolve(ok({ - items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }], - }) as never) - const sessions = new SessionRuntime(ctx, api, fakeRemote(api)) // provides 'sessions' itself - await sessions.refresh() - await Promise.resolve() // manager notifier flush + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const ctx = runtime.ctx + const sessionId = 'scenario-s1' as SessionId + await runtime.sessions.add({ id: sessionId, summary: { cwd: '/w/a' } }) await ctx.plugin(InputTriggerService).await() const inputTriggers = ctx.get('inputTriggers') as InputTriggerService register?.(inputTriggers) - const actx = sessions.scope(sessionId)! + const actx = runtime.sessions.scope(sessionId)! const controller = inputTriggers.sessionOf(actx) const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG))) @@ -127,26 +121,24 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined) actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell - const sessionStore = createSnapshotStore({ - sessionId, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, - }) + const sessionStore = createSnapshotStore(sessionSnapshot(sessionId)) const barProps: InputBarProps = { sessionId, - SessionProvider: ({ children }) => children(sessionId), + SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, })), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -183,7 +175,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const type = (text: string): void => { fireEvent.change(textarea, { target: { value: text } }) } - return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release } + return { runtime, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release } } async function bench(executeImpl?: (line: string) => Promise) { diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index aa80eb86f2..6a30ed4d8a 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -6,17 +6,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' -import { - EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, QueuedMessage, SessionId, SessionListState, -} from '@deepseek-ai/dsh-client-runtime/client' + QueuedMessage, SessionListState, SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { + bindSnapshotSelector, conversationSnapshot, makeTranslate, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { QueueItemId } from '../src/client/contract/queue.ts' -import type { InputState } from '../src/client/input/contract.ts' +import type { InputState } from '../src/client/contract/input.ts' import { zh } from '../src/client/locales.ts' import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx' @@ -33,20 +35,19 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu } } -function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { +function snapshotWith(queue: QueuedMessage[]): SessionSnapshot { return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, + sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null, + hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, + lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false, } } /** Minimal live source backing the useSession stub. */ -function liveSession(initial: ConversationSnapshot) { +function liveSession(initial: SessionSnapshot) { let snapshot = initial const listeners = new Set<() => void>() - const useSession: SnapshotSelectorHook = selector => + const useSession: SnapshotSelectorHook = selector => useSyncExternalStore( (listener) => { listeners.add(listener) @@ -56,7 +57,7 @@ function liveSession(initial: ConversationSnapshot) { ) return { useSession, - push(next: ConversationSnapshot): void { + push(next: SessionSnapshot): void { snapshot = next for (const listener of [...listeners]) listener() }, @@ -67,13 +68,19 @@ const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: ' const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) -function kitFor(snapshot: ConversationSnapshot, injected: Partial = {}) { +function kitFor(snapshot: SessionSnapshot, injected: Partial = {}) { return { sessionId: SID, t, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: (() => { throw new Error('unused') }) as never, useProjection: (() => undefined) as never, + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), + useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'], + useTrajectory: (() => { throw new Error('unused') }) as QueueDockProps['useTrajectory'], useInput: (() => { throw new Error('unused') }) as never, inputActions: { setDraft: () => {}, submit: () => {} } as never, session: snapshot, diff --git a/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx index 76ed4e0de5..403d0a072a 100644 --- a/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx +++ b/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx @@ -1,110 +1,88 @@ // @vitest-environment jsdom -/** - * Exercises selection persistence through the real SlotRegistry store axis; - * component stubs cannot prove per-session identity or disposal. - */ +/** Exercises Conversation persistence through the real SlotRegistry store axis. */ import { beforeEach, describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import { createChatStore } from '../src/client/stores.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { createConversationStore } from '../src/client/stores.ts' -const sid = (s: string): SessionId => s as SessionId +const sid = (value: string): SessionId => value as SessionId -type ChatInstance = ReturnType['create']> +type ConversationInstance = ReturnType['create']> -async function bench() { +async function createBench() { const runtime = await SlotTestRuntime.create() - const chat = createChatStore() - // The apply.ts shape: one shared handle across the strict Session header, - // body, and details registrations; the session-maybe 'conversation' shell - // carries no store by design. The slots must first exist in the ledger. + const conversation = createConversationStore() await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, 'conversation.session': { kind: 'single', scope: 'session' }, 'conversation.session.header': { kind: 'single', scope: 'session' }, - 'details': { kind: 'single', scope: 'session' }, - }, (_p: { renderSlot?: unknown }) => null) - runtime.slots.register({ name: 'conversation.session', store: chat }, () => null) - runtime.slots.register({ name: 'conversation.session.header', store: chat }, () => null) - runtime.slots.register({ name: 'details', store: chat }, () => null) - runtime.renderRoot() // materializes the host face storeOf resolves through - return { runtime, chat } + }, (_props: PropsRenderSlots<'conversation.session' | 'conversation.session.header'>) => null) + runtime.slots.register({ name: 'conversation.session', store: conversation }, () => null) + runtime.slots.register({ name: 'conversation.session.header', store: conversation }, () => null) + runtime.renderRoot() + return { runtime } } -/** Resolve the store instance the renderer would hand a slot's component for a session. */ -function storeFor(b: Awaited>, slot: 'conversation.session' | 'details', sessionId: SessionId) { - return b.runtime.storeOf(slot, sessionId) as ChatInstance +function storeFor( + current: Awaited>, + slot: 'conversation.session' | 'conversation.session.header', + sessionId: SessionId, +): ConversationInstance { + return current.runtime.storeOf(slot, sessionId) as ConversationInstance } beforeEach(() => { localStorage.clear() }) -describe('selection survives on the store seat', () => { - it('one session, two slots: conversation writes, details reads the SAME instance', async () => { - const b = await bench() - - const conv = storeFor(b, 'conversation.session', sid('s1')) - const details = storeFor(b, 'details', sid('s1')) - conv.actions.select({ turnSeq: 3, callId: 'c1' }) - expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - // Identity, not just value: the shared handle resolves one instance per scope key. - expect(details).toBe(conv) - await b.runtime.dispose() - }) - - it('sessions are isolated: s2 selection never bleeds into s1', async () => { - const b = await bench() - - const one = storeFor(b, 'conversation.session', sid('s1')) - const two = storeFor(b, 'conversation.session', sid('s2')) - expect(two).not.toBe(one) - one.actions.select({ turnSeq: 1, callId: 'a' }) - two.actions.select({ turnSeq: 9, callId: 'z' }) - expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) - expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) - await b.runtime.dispose() - }) - - it('a list-projection update keeps instance identity and the selection value', async () => { - const b = await bench() - const id = sid('s1') - - const store = storeFor(b, 'conversation.session', id) - store.actions.select({ turnSeq: 3, callId: 'c1' }) - store.actions.setDraft('half-typed') - - // A projection churn elsewhere (list rows re-projected) must not touch - // store identity: drive the runtime's own list observable. - await b.runtime.sessions.add({ id, summary: { displayTitle: 'proj-a' } }) - expect(b.runtime.sessions.list.getSnapshot().byId[id]?.displayTitle).toBe('proj-a') - - const after = storeFor(b, 'conversation.session', id) - expect(after).toBe(store) - expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - expect(after.store.getSnapshot().draft).toBe('half-typed') - await b.runtime.dispose() - }) - - it('session death buries the instance and its persisted draft', async () => { - const b = await bench() +describe('Conversation state survives on its store seat', () => { + it('shares one instance between the Session body and header', async () => { + const b = await createBench() await b.runtime.sessions.add({ id: 's1' }) + const body = storeFor(b, 'conversation.session', sid('s1')) + const header = storeFor(b, 'conversation.session.header', sid('s1')) + body.actions.setDraft('half-typed') + header.actions.setView('trajectory') + + expect(header).toBe(body) + expect(body.store.getSnapshot()).toMatchObject({ draft: 'half-typed', view: 'trajectory' }) + await b.runtime.dispose() + }) + + it('isolates Session instances and preserves identity across list projection updates', async () => { + const b = await createBench() + const oneId = sid('s1') + await b.runtime.sessions.add({ id: 's1' }) + await b.runtime.sessions.add({ id: 's2' }) + const one = storeFor(b, 'conversation.session', oneId) + const two = storeFor(b, 'conversation.session', sid('s2')) + one.actions.setDraft('only one') + two.actions.setDraft('only two') + + await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' }) + + expect(storeFor(b, 'conversation.session', oneId)).toBe(one) + expect(one.store.getSnapshot().draft).toBe('only one') + expect(two.store.getSnapshot().draft).toBe('only two') + await b.runtime.dispose() + }) + + it('buries the instance and persisted draft with the Session scope', async () => { + const b = await createBench() + await b.runtime.sessions.add({ id: 's1' }) const doomed = storeFor(b, 'conversation.session', sid('s1')) doomed.actions.setDraft('to be buried') - doomed.actions.select({ turnSeq: 1 }) - expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull() + doomed.actions.setView('chat') + expect(localStorage.getItem('dsh.conversation.s1')).not.toBeNull() - // TestSessions.remove drives the same public slot lifecycle contract the - // production SessionRuntime calls when the scope dies (pruneStoreScope). await b.runtime.sessions.remove('s1') - // Persisted residue is gone with the session... - expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull() - // ...and a re-created same-id session starts from a FRESH instance. + expect(localStorage.getItem('dsh.conversation.s1')).toBeNull() + await b.runtime.sessions.add({ id: 's1' }) const reborn = storeFor(b, 'conversation.session', sid('s1')) expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null }) + expect(reborn.store.getSnapshot()).toEqual({ draft: '', view: null, viewRequest: null }) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 0ef1e38061..7aa90e332a 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -5,16 +5,15 @@ // tag probe). import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import type { QueuedMessage, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' +import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/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' -async function bench(readAttachment?: SessionFace['readAttachment']) { +async function bench() { const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) @@ -22,7 +21,7 @@ async function bench(readAttachment?: SessionFace['readAttachment']) { const loadOlder = vi.fn(() => Promise.resolve()) await runtime.sessions.add({ id: 's1', - session: { prompt, updateQueue, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) }, + session: { prompt, updateQueue, cancel, loadOlder }, }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. @@ -117,27 +116,13 @@ describe('ConversationController', () => { await b.runtime.dispose() }) - it('invalidates pending historical image loads when the rendered session is released', async () => { - const read = Promise.withResolvers>>() - const b = await bench(() => read.promise) - const sessionId = b.runtime.sessions.behavior('s1').sessionId - const attachment = { - attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, - } as const - const pending = b.root.resolveImage(sessionId, attachment) - b.root.releaseSessionImages(sessionId) - read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) - await expect(pending).rejects.toThrow('historical image scope was released') - await b.runtime.dispose() - }) - - it('fails loudly from the root scope, on an unbound session, or without SessionRuntime', async () => { + it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => { const b = await bench() await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/) await b.runtime.sessions.remove('s1') await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/) await b.runtime.dispose() - // No SessionRuntime at all: a bare context (the runtime always provides one). + // No Client Sessions service at all: a bare context lacks the assembled controller. const bare = new Context() await bare.plugin(ConversationController, { input: new InputHub(bare, makeTranslate(zh, {})), @@ -161,7 +146,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { it('steers every queued row in FIFO order and leaves steering rows alone', async () => { const b = await bench() - await b.runtime.sessions.updateSnapshot('s1', (draft) => { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')] }) b.shell.steerQueue() @@ -176,7 +161,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { it('converges silently when the turn closes or a row is claimed mid-steer', async () => { const b = await bench() - await b.runtime.sessions.updateSnapshot('s1', (draft) => { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.queue = [row('q-1'), row('q-2')] }) // The turn closes before the second row: the flush stops, silently. @@ -189,7 +174,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { // A row the host already claimed (e.g. a repeated empty-draft chord): // the duplicate strict steer is a silent no-op. - await b.runtime.sessions.updateSnapshot('s1', (draft) => { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.queue = [row('q-3')] }) b.updateQueue.mockResolvedValueOnce({ @@ -203,7 +188,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { it('surfaces one notice on a genuine steer failure and stops', async () => { const b = await bench() - await b.runtime.sessions.updateSnapshot('s1', (draft) => { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.queue = [row('q-1'), row('q-2')] }) b.updateQueue.mockResolvedValueOnce({ diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index dfd62d704f..620b0beedd 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -1,24 +1,28 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ReactNode } from 'react' +import type { ComponentProps, ReactNode } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { Context } from '@deepseek-ai/cordis' +import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { - ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, makeTranslate, sessionSnapshot as sessionFixture, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { createChatStore } from '../src/client/stores.ts' +import { EMPTY_CONVERSATION_SNAPSHOT } from '../src/client/contract/snapshot.ts' +import type { ConversationSnapshot } from '../src/client/contract/snapshot.ts' +import { createConversationStore } from '../src/client/stores.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { en, zh } from '../src/client/locales.ts' import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx' +import { conversationPhase } from '../src/client/contract/snapshot.ts' import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx' import type { HeroShellProps } from '../src/client/skeleton/EmptyHero.tsx' import { InputBar } from '../src/client/skeleton/InputBar.tsx' @@ -30,7 +34,7 @@ import type { ViewTab } from '../src/client/contract/views.ts' function fakeWiring() { const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) return { wiring: shell, sink, shell } } @@ -56,6 +60,11 @@ const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const SID = sid('s1') +type SessionSlotProps = ComponentProps + +const useChat: SessionSlotProps['useChat'] = () => { throw new Error('unused') } +const useTrajectory: SessionSlotProps['useTrajectory'] = () => { throw new Error('unused') } + function workspace(id = 'w1'): WorkspaceView { return { workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [], @@ -63,24 +72,16 @@ function workspace(id = 'w1'): WorkspaceView { } } -const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, }) -function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, - ...overrides, - } +function sessionSnapshotOf(overrides: Partial = {}): SessionSnapshot { + return { ...sessionFixture(SID), ...overrides } } function mount( - snapshot: ConversationSnapshot, + snapshot: SessionSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }], retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}), options: { @@ -125,11 +126,16 @@ function mount( current: SID, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore(workspaceState(workspaceRows)) - const session = createSnapshotStore(snapshot) + const workspaces = createSnapshotStore(workspaceState(workspaceRows)) + const session = createSnapshotStore(snapshot) const useSession = bindSnapshotSelector(session) - const chat = createChatStore().create() - chat.actions.setDraft('ordinary draft') + const conversation = createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT) + const useConversation = bindSnapshotSelector(conversation) + const useSessionPendingInteraction = bindSnapshotSelector( + createSnapshotStore(new Map()), + ) + const store = createConversationStore().create() + store.actions.setDraft('ordinary draft') const { wiring, sink } = fakeWiring() const useInput = bindSnapshotSelector(wiring.state) const inputActions = wiring.actions @@ -141,11 +147,7 @@ function mount( { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, ] - const views = { - list: () => viewTabs, - subscribe: () => () => {}, - version: () => 1, - } + const useConversationViews: SessionSlotProps['useConversationViews'] = selector => selector(viewTabs) /** Owner share handed to the two composer tool-row seats, per render. */ const seatOwners: { key: string; owner: unknown }[] = [] let pickerOwner: unknown @@ -163,17 +165,21 @@ function mount( return ( children(SID)} + SessionProvider={({ children }) => children} useSession={useSession} + useConversation={useConversation} + useConversationViews={useConversationViews} + useChat={useChat} + useTrajectory={useTrajectory} useSessions={props.useSessions} + useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} + useStore={bindSnapshotSelector(store)} + actions={store.actions} renderSlot={renderSlot as never} - views={views} open={open} t={t} /> @@ -183,18 +189,21 @@ function mount( return ( children(SID)} + SessionProvider={({ children }) => children} useSession={useSession} + useConversation={useConversation} + useConversationViews={useConversationViews} + useChat={useChat} + useTrajectory={useTrajectory} useSessions={props.useSessions} + useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} + useStore={bindSnapshotSelector(store)} + actions={store.actions} renderSlot={renderSlot as never} - views={views} - releaseSessionImages={vi.fn()} bindDraftMirror={write => wiring.bindMirror(write)} /> ) @@ -206,9 +215,11 @@ function mount( return ( children(SID)} + SessionProvider={({ children }) => children} useSession={useSession} + useConversation={useConversation} useSessions={props.useSessions} + useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} @@ -251,12 +262,13 @@ function mount( )) as ConversationRootProps['renderSlotChain'] const props: ConversationRootProps = { sessionId: SID, - SessionProvider: ({ children }) => children(SID), + SessionProvider: ({ children }) => children, useSession, + useConversation, useSessions: bindSnapshotSelector(sessions), + useSessionPendingInteraction, useWorkspaces: bindSnapshotSelector(workspaces), useProjection: (() => undefined), - useSessionPendingInteraction: selector => selector([]), useComposerBlock: select => select(options.composerBlock), useInput, inputActions, @@ -267,7 +279,7 @@ function mount( } const view = render() return { - view, chat, sink, retargetWorkspace, session, slotCalls, lineageOwners, seatOwners, open, + view, store, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open, pickerOwner: () => pickerOwner, rerender: () => { view.rerender() }, } @@ -293,7 +305,7 @@ describe('Hero chrome', () => { describe('ConversationRoot resident composer', () => { it('renders the composer inert with the blocker\u2019s own reason', () => { - const b = mount(conversationSnapshot(), undefined, undefined, { + const b = mount(sessionSnapshotOf(), undefined, undefined, { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') as HTMLTextAreaElement @@ -315,7 +327,7 @@ describe('ConversationRoot resident composer', () => { it('lets the no-workspace posture win over a block', () => { // Picking a workspace is the earlier prerequisite; naming a model first // would send the user somewhere they cannot act yet. - const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, { + const b = mount(sessionSnapshotOf({ blank: true }), [], undefined, { summaryBlank: true, composerBlock: { reason: 'select a model first' }, }) @@ -328,12 +340,12 @@ describe('ConversationRoot resident composer', () => { expect(modelSeat).toEqual({ locked: true }) }) - it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => { - const b = mount(conversationSnapshot()) + it('keeps composer text in the machine, mirrors to the Conversation store, and submits through the sink', () => { + const b = mount(sessionSnapshotOf()) const box = b.view.getByRole('textbox') expect((box as HTMLTextAreaElement).value).toBe('ordinary draft') fireEvent.change(box, { target: { value: 'ordinary revised' } }) - expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') + expect(b.store.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal)) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) @@ -341,7 +353,7 @@ describe('ConversationRoot resident composer', () => { }) it('shows hierarchy only for subagents and opens their ordinary owner', () => { - const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' }) + const b = mount(sessionSnapshotOf(), undefined, undefined, { summaryOrigin: 'subagent' }) const root = b.view.getByRole('button', { name: 'Root' }) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) fireEvent.click(root) @@ -349,7 +361,7 @@ describe('ConversationRoot resident composer', () => { }) it('keeps intermediate subagent breadcrumbs at the compact title size', () => { - const b = mount(conversationSnapshot(), undefined, undefined, { + const b = mount(sessionSnapshotOf(), undefined, undefined, { summaryOrigin: 'subagent', nestedSubagent: true, }) @@ -365,7 +377,7 @@ describe('ConversationRoot resident composer', () => { }) it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => { - const b = mount(conversationSnapshot()) + const b = mount(sessionSnapshotOf()) const host = b.view.container.querySelector('[data-conversation-scroll]') const seat = b.view.container.querySelector('[data-composer-seat]') const header = b.view.container.querySelector('header') @@ -383,7 +395,7 @@ describe('ConversationRoot resident composer', () => { }) it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => { - const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: true }) + const b = mount(sessionSnapshotOf(), undefined, undefined, { overlayTakeover: true }) const seat = b.view.container.querySelector('[data-composer-seat]') const takeover = b.view.getByTestId('composer-takeover') const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]') @@ -393,7 +405,7 @@ describe('ConversationRoot resident composer', () => { it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { const b = mount( - conversationSnapshot({ composerPhase: 'blank', blank: true }), + sessionSnapshotOf({ blank: true }), [ { ...workspace('one'), sessionIds: [SID] }, { ...workspace('second'), title: 'Selected Folder' }, @@ -410,11 +422,11 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the // persistence mirror stays bound (ConversationSession mounts chrome-hidden - // for blank sessions): hero typing reaches the chat store. + // for blank sessions): hero typing reaches the Conversation store. const box = b.view.getByRole('textbox') expect(host?.contains(box)).toBe(true) fireEvent.change(box, { target: { value: 'draft in hero' } }) - expect(b.chat.store.getSnapshot().draft).toBe('draft in hero') + expect(b.store.store.getSnapshot().draft).toBe('draft in hero') // Picker: open through the chip; a pick switches to the other // workspace's blank session (draft carry is apply-layer wiring). fireEvent.click(b.view.getByRole('button', { name: '选择工作区' })) @@ -425,8 +437,25 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByText('Selected Folder')).toBeTruthy() }) + it('keeps a rejected first prompt engaging instead of returning to the Hero', () => { + const failed = sessionSnapshotOf({ + blank: true, + promptAttempted: true, + awaitingFirstTurn: true, + promptError: { + op: 'send', + error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, + }, + }) + + expect(conversationPhase(failed, EMPTY_CONVERSATION_SNAPSHOT)).toBe('engaging') + const b = mount(failed, undefined, undefined, { summaryBlank: true }) + expect(b.view.container.querySelector('[data-phase]')?.getAttribute('data-phase')).toBe('active') + expect(b.view.queryByText('探索未至之境')).toBeNull() + }) + it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) + const b = mount(sessionSnapshotOf({ blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') expect(b.view.queryByText('探索未至之境')).toBeNull() @@ -434,7 +463,7 @@ describe('ConversationRoot resident composer', () => { it('settling phase: a session the list has no row for settles conservatively', () => { const b = mount( - conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }), + sessionSnapshotOf({ blank: true, openState: 'loading' }), undefined, undefined, { omitSummaryRow: true }, @@ -445,7 +474,7 @@ describe('ConversationRoot resident composer', () => { it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => { const b = mount( - conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }), + sessionSnapshotOf({ blank: true, openState: 'loading' }), undefined, undefined, { summaryBlank: true }, @@ -459,18 +488,18 @@ describe('ConversationRoot resident composer', () => { }) it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) + const b = mount(sessionSnapshotOf({ blank: true })) const before = b.view.getByRole('textbox') fireEvent.change(before, { target: { value: 'kept across flip' } }) // First message landed: content exists, phase leaves blank. Composer // already sat in the resident scrollport during hero, so the textarea // node and InputHub draft both survive. - b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false })) + b.session.set(sessionSnapshotOf({ blank: false })) b.rerender() const after = b.view.getByRole('textbox') as HTMLTextAreaElement expect(after).toBe(before) expect(after.value).toBe('kept across flip') - expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') + expect(b.store.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) expect(b.view.queryByText('探索未至之境')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() @@ -481,10 +510,10 @@ describe('ConversationRoot resident composer', () => { { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, ] - const b = mount(conversationSnapshot(), undefined, undefined, { viewTabs }) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs }) // A removed dynamic view leaves its persisted id behind. The visible // fallback is Chat and must stay Chat when another lower-order view lands. - act(() => { b.chat.actions.setView('removed-view') }) + act(() => { b.store.actions.setView('removed-view') }) expect(b.view.getByTestId('view-chat')).toBeTruthy() viewTabs.unshift({ id: 'new-view', label: 'New view' }) @@ -499,7 +528,7 @@ describe('ConversationRoot resident composer', () => { it('rolls the pending workspace label back when switching fails', async () => { const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') }) const b = mount( - conversationSnapshot({ composerPhase: 'blank', blank: true }), + sessionSnapshotOf({ blank: true }), [ { ...workspace('one'), sessionIds: [SID] }, { ...workspace('second'), title: 'Selected Folder' }, @@ -515,7 +544,7 @@ describe('ConversationRoot resident composer', () => { }) it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) + const b = mount(sessionSnapshotOf({ blank: true })) const chip = b.view.getByRole('button', { name: '选择工作区' }) expect((chip as HTMLButtonElement).disabled).toBe(false) expect(b.slotCalls).toContain('conversation.hero.workspace') @@ -525,7 +554,7 @@ describe('ConversationRoot resident composer', () => { }) it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => { - const b = mount(conversationSnapshot({ + const b = mount(sessionSnapshotOf({ promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never }, })) expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)') diff --git a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx index b2a2bb5dca..a1853ef0c7 100644 --- a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx @@ -7,8 +7,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx' diff --git a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx index 6f90658c5f..0f1c0045fa 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx @@ -1,11 +1,9 @@ -// View-ring type-chain samples. This spec pins the conversation-owned SlotMap -// row, list-kind registration shape, composed view props, and the runtime -// ledger projection consumed by ConversationRoot. +// Target-neutral View-ring type chain and runtime ledger projection. import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatViewSlotProps, ConvViewProps } from '../src/client/contract/slots.ts' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { ConvViewProps } from '../src/client/contract/slots.ts' describe('view-ring type negatives (compile-time; body never runs)', () => { it('holds the negative samples as expect-error sites', () => { @@ -31,24 +29,6 @@ describe('view-ring type negatives (compile-time; body never runs)', () => { return null } void renderless - // 5. The chat entry's face is its own: openDetails does not exist on the - // base view props (store-less riders never see it). - const baseOnly = (props: ConvViewProps): ReactNode => { - // @ts-expect-error openDetails lives on ChatViewSlotProps, not the base - void props.openDetails - return null - } - void baseOnly - // 6. ChatViewSlotProps carries the full composition (standard kit + - // store + inject face) — a handler with a wrong signature is red. - const chatProps = (props: ChatViewSlotProps): ReactNode => { - // @ts-expect-error openDetails takes a SelectionTarget, not a string - props.openDetails('nope') - // @ts-expect-error openFile takes a path string, not a SelectionTarget - void props.openFile({ turnSeq: 1, callId: 'c' }) - return null - } - void chatProps return null as ReactNode } expect(negatives).toBeTypeOf('function') diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index e3dcebfad7..a851104426 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -8,6 +8,15 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, { "path": "../../attachment/attachment" }, @@ -24,29 +33,35 @@ "path": "../ui-primitives" }, { - "path": "../runtime" + "path": "../store" }, { - "path": "../../core/agent" + "path": "../ui-layout" }, { - "path": "../../core/tools" + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../../core/session" }, { "path": "../../interaction/commands" }, { - "path": "../../session/session-projection" - }, - { - "path": "../../session/session-stats" - }, - { - "path": "../../llm/token-meter" + "path": "../../llm/llm" }, { "path": "../../llm/llm-retry" }, + { + "path": "../../workspace/workspace" + }, + { + "path": "../../llm/token-meter" + }, { "path": "../../plan/plan-mode" }, @@ -56,12 +71,6 @@ { "path": "../../todo/tool-todo" }, - { - "path": "../ui-input-trigger" - }, - { - "path": "../ui-layout" - }, { "path": "../locale" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index afb1ebf40b..e55e7a09df 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -31,10 +31,15 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-client-ui-conversation/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } @@ -51,17 +56,19 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^" + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -73,7 +80,13 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index dab72e6778..c2f63d5789 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -15,7 +15,7 @@ import { import { structuredPatch } from 'diff' import type { AssistantRequestConfig, ConversationPromptSnapshot, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 36727078ef..ceb137795e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,12 +1,11 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { - AssistantBlock, AssistantMessageNode, ConversationSnapshot, - SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' + AssistantBlock, AssistantMessageNode, ConvViewProps, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { TrajectoryTable, type TrajectoryRequestNumber, @@ -25,7 +24,7 @@ import { } from './timeline.ts' import { trajectoryRecordId } from './trajectory-record.ts' import { TrajectorySearchIndex } from './trajectory-search-index.ts' -import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts' +import type { TrajectorySnapshot } from './trajectory-contract.ts' import css from './views.module.css' const EMPTY_TURN_IDS: ReadonlySet = new Set() @@ -57,7 +56,7 @@ function timelineBlock(block: AssistantBlock): AssistantBlock { } } -function partialStructureSignature(partial: ConversationSnapshot['partial']): string { +function partialStructureSignature(partial: TrajectorySnapshot['partial']): string { if (partial === null) return '' return partial.blocks.map(block => block.kind === 'tool-call' ? `${block.kind}:${block.callId}:${block.name}` @@ -118,8 +117,8 @@ function addUsage( } export function TrajectoryView({ - useSession, useDuration, loadOlder, setActualDuration, - inspect, onInspectDone, t, + useSession, useTrajectory, useDuration, loadOlder, setActualDuration, + viewRequest, completeViewRequest, t, }: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = @@ -139,8 +138,7 @@ export function TrajectoryView({ const [timelineRecordFocus, setTimelineRecordFocus] = useState<{ readonly index: number } | null>(null) - const inspection = useSession(snapshot => - snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT) + const inspection = useTrajectory(snapshot => snapshot) const historyLoading = useSession(snapshot => snapshot.openState === 'loading') const olderHistoryLoading = useSession(snapshot => snapshot.loadingOlder) const hasOlderHistory = useSession(snapshot => snapshot.hasMore) @@ -151,6 +149,7 @@ export function TrajectoryView({ const runningCalls = inspection.runningCalls const requests = inspection.requests const callSchemas = inspection.callSchemas + const inspectCallId = viewRequest?.view === 'trajectory' ? viewRequest.focus : null const requestNumbers = useMemo(() => { const assistantsByStep = new Map() for (const node of nodes) { @@ -269,7 +268,7 @@ export function TrajectoryView({ runningCalls, requests, callSchemas, ]) const timelinePartialSignature = partialStructureSignature(partial) - const timelinePartial = useMemo(() => partial === null + const timelinePartial = useMemo(() => partial === null ? null : { turn: partial.turn, @@ -497,8 +496,8 @@ export function TrajectoryView({ onToggleTurn={toggleTurn} collapsedAssistants={collapsedAssistants} onToggleAssistant={toggleAssistant} - inspectCallId={inspect?.callId ?? null} - onInspectApplied={onInspectDone} + inspectCallId={inspectCallId} + onInspectApplied={completeViewRequest} />
diff --git a/packages/client/ui-trajectory/src/client/duration-store.ts b/packages/client/ui-trajectory/src/client/duration-store.ts index f965dff2d8..dbe623332e 100644 --- a/packages/client/ui-trajectory/src/client/duration-store.ts +++ b/packages/client/ui-trajectory/src/client/duration-store.ts @@ -1,6 +1,6 @@ import { createSnapshotStore, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-store' /** * Create the browser-wide trajectory duration preference source. diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index ef789dce30..d481f7ce9d 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -3,24 +3,40 @@ * slot without defining a service. */ import type { Context } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the 'conversation.view' SlotMap row (declared by the slot's // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { en, NS, zh } from './locales.ts' import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts' import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts' import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts' -import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts' +import { + EMPTY_TRAJECTORY_SNAPSHOT, registerTrajectoryConversationView, +} from './trajectory-snapshot-builder.ts' +import type { TrajectorySnapshot } from './trajectory-contract.ts' import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' +export type { TrajectoryKey } from './locales.ts' +export type { + TrajectoryContribution, + TrajectoryConversationViewNode, + TrajectoryRequestHeaderState, + TrajectorySnapshot, + UseTrajectory, +} from './trajectory-contract.ts' + /** Required services: the conversation slot, registries, ordinary Session paging, and the locale service. */ -export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale'] +export const inject = ['slots', 'sessions', 'uiSession', 'uiConversation', 'locale'] /** * Client plugin body: register the trajectory view tab. The registration @@ -28,6 +44,19 @@ export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sess * @param ctx - client root context. */ export function apply(ctx: Context): void { + const trajectorySources = new WeakMap>() + const trajectorySource = (binding: SessionBinding): ObservableSnapshot => { + let source = trajectorySources.get(binding) + if (source === undefined) { + const target = ctx.uiConversation.binding(binding).target('trajectory') + source = { + getSnapshot: () => target.getSnapshot() ?? EMPTY_TRAJECTORY_SNAPSHOT, + subscribe: listener => target.subscribe(listener), + } + trajectorySources.set(binding, source) + } + return source + } ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries') // Registration-time text (the view tab label) reads through the bound // translate as a thunk, so it follows the active locale without @@ -40,6 +69,10 @@ export function apply(ctx: Context): void { registerTrajectoryToolDefinition(ctx) registerTrajectoryCompactionDefinitions(ctx) registerTrajectoryConversationView(ctx) + ctx.uiSession.provide({ + hooks: ['trajectory'], + resolve: binding => ({ hooks: { trajectory: trajectorySource(binding) } }), + }) ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', @@ -51,12 +84,13 @@ export function apply(ctx: Context): void { if (session === undefined) { throw new Error(`ui-trajectory: session "${sessionId}" is unavailable`) } + const trajectory = ctx.uiConversation.binding(sessionId).target('trajectory') return { hooks: { duration }, loadOlder: async () => { - const before = session.getSnapshot().views.get('trajectory') + const before = trajectory.getSnapshot() await session.loadOlder() - return session.getSnapshot().views.get('trajectory') !== before + return trajectory.getSnapshot() !== before }, setActualDuration: (value) => { duration.set(value) }, } diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 265d5ba034..da766a5b9b 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -6,17 +6,17 @@ import type { AssistantBlock, AssistantMessageNode, ConversationLocation, - ConversationSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, ToolCallBlock, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' +import type { TrajectorySnapshot } from './trajectory-contract.ts' import { formatElapsedSeconds } from './trajectory-record.ts' /** One Message or Step group inside a turn. */ @@ -34,10 +34,10 @@ export interface TrajectoryTurnModel { /** Snapshot slice the trajectory view folds. */ export interface TrajectoryLayoutInput { - nodes: ConversationSnapshot['nodes'] + nodes: TrajectorySnapshot['eventNodes'] eventLocations?: ReadonlyMap - partial: ConversationSnapshot['partial'] - runningCalls: ConversationSnapshot['runningCalls'] + partial: TrajectorySnapshot['partial'] + runningCalls: TrajectorySnapshot['runningCalls'] requests?: readonly RequestView[] callSchemas?: RequestInspectionSnapshot['callSchemas'] } @@ -72,7 +72,7 @@ type AssistantRequestView = Extract type CompactionRequestView = Extract type InputNode = Extract< - ConversationSnapshot['nodes'][number], + TrajectorySnapshot['eventNodes'][number], { kind: 'user' | 'steering' | 'context' } > @@ -80,7 +80,7 @@ type OrderedLayoutEntry = | { kind: 'node' seq: number - node: ConversationSnapshot['nodes'][number] + node: TrajectorySnapshot['eventNodes'][number] nodeIndex: number } | { @@ -531,7 +531,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T */ export function appendTrajectoryPartialLayout( turns: readonly TrajectoryTurnModel[], - partial: ConversationSnapshot['partial'], + partial: TrajectorySnapshot['partial'], lastIndex: number, ): readonly TrajectoryTurnModel[] { if (partial === null) return turns @@ -858,7 +858,7 @@ function stringifySourceValue(value: unknown): string { */ function enclosingUserTurn( followingAssistant: AssistantMessageNode | undefined, - partial: ConversationSnapshot['partial'], + partial: TrajectorySnapshot['partial'], lastAssistantTurn: number | null, ): number { if (followingAssistant !== undefined) return followingAssistant.turn @@ -869,7 +869,7 @@ function enclosingUserTurn( function steeringPlacement( followingAssistant: AssistantMessageNode | undefined, - partial: ConversationSnapshot['partial'], + partial: TrajectorySnapshot['partial'], lastAssistantTurn: number | null, location: ConversationLocation | undefined, ): { turn: number; step?: number } { @@ -892,7 +892,7 @@ function steeringPlacement( } function indexFollowingAssistants( - nodes: ConversationSnapshot['nodes'], + nodes: TrajectorySnapshot['eventNodes'], ): readonly (AssistantMessageNode | undefined)[] { const following = new Array(nodes.length) let assistant: AssistantMessageNode | undefined @@ -905,9 +905,9 @@ function indexFollowingAssistants( } function enclosingPromptTurn( - nodes: ConversationSnapshot['nodes'], + nodes: TrajectorySnapshot['eventNodes'], seq: number, - partial: ConversationSnapshot['partial'], + partial: TrajectorySnapshot['partial'], ): number { const next = nodes.find(node => node.seq > seq && node.kind === 'assistant' && node.step > 0) @@ -917,8 +917,8 @@ function enclosingPromptTurn( /** Earliest raw turn represented by the selected trajectory branch. */ function firstVisibleTurn( - nodes: ConversationSnapshot['nodes'], - partial: ConversationSnapshot['partial'], + nodes: TrajectorySnapshot['eventNodes'], + partial: TrajectorySnapshot['partial'], ): number { const turns = nodes.flatMap(node => node.kind === 'assistant' && node.turn > 0 @@ -939,7 +939,7 @@ function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): v if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens } -function indexResults(nodes: ConversationSnapshot['nodes']): Map { +function indexResults(nodes: TrajectorySnapshot['eventNodes']): Map { const map = new Map() for (const node of nodes) { if (node.kind === 'tool-result') map.set(node.callId, node) @@ -947,7 +947,7 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map { +function indexAssistantCallIds(nodes: TrajectorySnapshot['eventNodes']): ReadonlySet { const ids = new Set() for (const node of nodes) { if (node.kind !== 'assistant') continue diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 0013315f96..e64f8350f8 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -1,12 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' -import type { - AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, - ConversationNodeContext, ConversationNodeDefinition, PartialAssistant, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' import { displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks, -} from '@deepseek-ai/dsh-client-runtime/client' + type AssistantBlock, type AssistantMessageNode, type ConversationLocation, + type ConversationMatch, type ConversationNodeContext, type ConversationNodeDefinition, + type PartialAssistant, type RequestView, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { trajectoryNode } from './trajectory-definition-common.ts' /* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event @@ -402,6 +401,6 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition = { * @param ctx - Plugin context receiving the Definitions. */ export function registerTrajectoryAssistantDefinition(ctx: Context): void { - ctx.conversationEvents.register(trajectoryAssistantDefinition) - ctx.conversationEvents.register(trajectoryTurnEndDefinition) + ctx.uiConversation.events.register(trajectoryAssistantDefinition) + ctx.uiConversation.events.register(trajectoryTurnEndDefinition) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts index c48023440b..c5487bbb91 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -1,7 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeDefinition, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' import { trajectoryNode } from './trajectory-definition-common.ts' @@ -138,6 +138,6 @@ const trajectorySessionEndDefinition: ConversationNodeDefinition + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationViewSnapshotMap { /** Independently assembled data consumed by the Trajectory view. */ trajectory: TrajectorySnapshot } } + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SessionStandardProps { + /** Selector hook over the current Conversation binding's Trajectory target. */ + useTrajectory: UseTrajectory + } +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts index d55d5ca542..5d2d897937 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -1,4 +1,4 @@ -import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryContribution, TrajectoryConversationViewNode, } from './trajectory-contract.ts' diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index 4139a318db..2dfc871ad4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -1,11 +1,9 @@ import type { Context } from '@deepseek-ai/cordis' -import type { - ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext, - SteeringMessageNode, UserMessageNode, -} from '@deepseek-ai/dsh-client-runtime/client' import { contextForm, contextProvenance, -} from '@deepseek-ai/dsh-client-runtime/client' + type ContextMessageNode, type ConversationNodeDefinition, type ConversationPreviousContext, + type SteeringMessageNode, type UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-agent/types' import { trajectoryNode } from './trajectory-definition-common.ts' @@ -117,6 +115,6 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { * @param ctx - Plugin context receiving the Definitions. */ export function registerTrajectoryMessageDefinitions(ctx: Context): void { - ctx.conversationEvents.register(trajectoryInboxDefinition) - ctx.conversationEvents.register(trajectoryMessageDefinition) + ctx.uiConversation.events.register(trajectoryInboxDefinition) + ctx.uiConversation.events.register(trajectoryMessageDefinition) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index e4cd6e6e02..fe054dcadb 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -1,7 +1,7 @@ /** Shared trajectory record data and formatting contracts. */ import type { HTMLAttributes } from 'react' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' /** Closed set of trajectory record kinds. */ export type TrajectoryCellKind = diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts index 20a4d437e9..0cbb9fee77 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, - RequestPromptChange, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, RequestPromptChange, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { trajectoryNode } from './trajectory-definition-common.ts' import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts' @@ -76,5 +75,5 @@ const trajectoryRequestHeaderDefinition: ConversationNodeDefinition = { * @param ctx - Plugin context receiving the Definition. */ export function registerTrajectoryToolDefinition(ctx: Context): void { - ctx.conversationEvents.register(trajectoryToolDefinition) + ctx.uiConversation.events.register(trajectoryToolDefinition) } diff --git a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts index 25b13718aa..e1ca41bfb2 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts @@ -11,9 +11,8 @@ import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it } from 'vitest' -import { - ConversationEventRegistry, ConversationViewRegistry, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory' @@ -50,7 +49,8 @@ describe('tsdown client artifact', () => { ['react', await import('react')], ['react/jsx-runtime', await import('react/jsx-runtime')], ['react-dom', await import('react-dom')], - ['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')], + ['@deepseek-ai/dsh-client-store', await import('@deepseek-ai/dsh-client-store')], + ['@deepseek-ai/dsh-client-ui-conversation/client', await import('@deepseek-ai/dsh-client-ui-conversation/client')], ['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')], ]) const exports = handoff!.factory((spec) => { @@ -65,7 +65,7 @@ describe('tsdown client artifact', () => { expect(handoff.id).toBe(PLUGIN_ID) expect(exports.apply).toBeTypeOf('function') expect(exports.inject).toEqual([ - 'slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale', + 'slots', 'sessions', 'uiSession', 'uiConversation', 'locale', ]) }) @@ -73,8 +73,7 @@ describe('tsdown client artifact', () => { const { exports } = await loadArtifact() const ctx = new Context() const slots = new SlotRegistry(ctx) - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() + ctx.provide('uiSession', { provide: () => () => {} } as never) // The conversation entry's role: the ring must be declared before riders land. slots.register({ name: 'root', @@ -84,7 +83,10 @@ describe('tsdown client artifact', () => { // entry, so the binding stays deliberately empty. The locale plugin backs // the locale-aware view tab label (its settings scope needs a connection // handle and the Host-facing settings/remote seams). - ctx.provide('sessions', { binding: () => undefined }) + const sessions = { binding: () => undefined } + ctx.provide('sessions', sessions) + const uiConversation = new UiConversation(ctx, sessions as never) + const { events, views } = uiConversation ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -92,8 +94,6 @@ describe('tsdown client artifact', () => { ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void }) await fiber.await() - const events = ctx.get('conversationEvents') as ConversationEventRegistry - const views = ctx.get('conversationViews') as ConversationViewRegistry expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) expect(events.entries().length).toBeGreaterThan(0) expect(views.entries()).toHaveLength(1) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index 9b095cae7f..8d0d806788 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -2,8 +2,8 @@ import type { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts' import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' @@ -14,10 +14,12 @@ import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool- const DEFINITIONS: ConversationNodeDefinition[] = [] const registrationContext = { - conversationEvents: { - register: (definition: ConversationNodeDefinition) => { - DEFINITIONS.push(definition) - return () => {} + uiConversation: { + events: { + register: (definition: ConversationNodeDefinition) => { + DEFINITIONS.push(definition) + return () => {} + }, }, }, } as unknown as Context @@ -58,7 +60,6 @@ function at( data, ...extra, } as unknown as ConversationEventInput['event'], - view: undefined, } } @@ -73,7 +74,7 @@ function assembler(events: readonly ConversationEventInput[]): ConversationNodeA } function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot { - const current = value.snapshot('trajectory') as TrajectorySnapshot | undefined + const current = value.get('trajectory') if (current === undefined) throw new Error('trajectory view was not registered') return current } diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index ec8924505f..70ff2c2e1b 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -6,8 +6,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' import type { - ConversationLocation, ConversationSnapshot, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationNode, RequestView, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' @@ -15,6 +15,10 @@ import { appendTrajectoryPartialLayout, deriveTrajectoryLayout, } from '../src/client/layout.ts' +interface LegacyConversationSlice { + readonly nodes: readonly ConversationNode[] +} + afterEach(cleanup) describe('TrajectoryTurnHeader', () => { @@ -73,7 +77,7 @@ describe('deriveTrajectoryLayout', () => { call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200, content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns).toHaveLength(1) expect(turns[0]?.turn).toBe(1) @@ -113,7 +117,7 @@ describe('deriveTrajectoryLayout', () => { const nodes = [{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'finalized' }], - }] as unknown as ConversationSnapshot['nodes'] + }] as unknown as LegacyConversationSlice['nodes'] const partial = { turn: 2, step: 1, @@ -183,7 +187,7 @@ describe('deriveTrajectoryLayout', () => { ], usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 }, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull() @@ -209,7 +213,7 @@ describe('deriveTrajectoryLayout', () => { call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600, content: [], isError: false, callView: null, resultView: null, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2') }) @@ -226,7 +230,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0, blocks: [{ kind: 'text', text: 'ok2' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns.map(t => t.turn)).toEqual([1, 2]) expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([ @@ -254,7 +258,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'second step' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const data = { get: () => undefined } const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data } const turn = { @@ -286,7 +290,7 @@ describe('deriveTrajectoryLayout', () => { const nodes = [{ kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000, content: [{ type: 'text', text: 'change direction' }], source: null, - }] as unknown as ConversationSnapshot['nodes'] + }] as unknown as LegacyConversationSlice['nodes'] const data = { get: () => undefined } const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data } const turn = { @@ -329,7 +333,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 3, blocks: [{ kind: 'text', text: 'continued' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) @@ -357,7 +361,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'after compaction' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const compaction: RequestView = { purpose: 'compaction', startSeq: 3, @@ -395,7 +399,7 @@ describe('deriveTrajectoryLayout', () => { blocks: [{ kind: 'reasoning', text: '…' }], usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 }, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') expect(message).toMatchObject({ @@ -408,7 +412,7 @@ describe('deriveTrajectoryLayout', () => { const nodes = [{ kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, blocks: [{ kind: 'reasoning', text: thinking }], - }] as unknown as ConversationSnapshot['nodes'] + }] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [], @@ -447,7 +451,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0, blocks: [{ kind: 'text', text: 'done' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] const message = cells.find(c => c.kind === 'message' && c.previewMarkdown === 'done') @@ -465,7 +469,7 @@ describe('deriveTrajectoryLayout', () => { blocks: [{ kind: 'text', text: 'done' }], timing: { stepStartTime: 3_000, firstTokenTime: 3_500, completedTime: 4_000 }, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [], }) @@ -489,7 +493,7 @@ describe('run_code sub-dispatch cells', () => { content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null, subCalls: [], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const settledSub = (n: number, name: string, start: number, end: number) => ({ kind: 'tool-result' as const, seq: 100 + n, time: end, @@ -500,7 +504,7 @@ describe('run_code sub-dispatch cells', () => { }) const withSubCalls = (subCalls: readonly ReturnType[] | readonly object[]) => - runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as ConversationSnapshot['nodes'] + runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as LegacyConversationSlice['nodes'] it('nests settled sub-cells after their parent Tool cell with real durations', () => { const subCalls = [ diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts index c0058b75c6..8d6da1f04f 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client' +import type { RequestView } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryRequestHeaderState, } from '../src/client/trajectory-contract.ts' diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 1bc74af54d..6d63267583 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -7,27 +7,36 @@ * event ledger with its timing overview, and fiber disposal removes the tab. * Timeline projection and inclusive focus edge cases ride along. */ -import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { - ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, - EMPTY_CHAT_SNAPSHOT, -} from '@deepseek-ai/dsh-client-runtime/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' + EMPTY_CONVERSATION_SNAPSHOT, UiConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { - ConversationSnapshot, RequestView, - SessionId, SessionListState, SnapshotStore, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' + ConversationBinding, ConversationSnapshot, ConversationViewSnapshotMap, ConvViewProps, + InputActions, InputState, RequestView, ViewTab, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { + ChatSnapshot, LegacyConversationSlice, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { + SessionBinding, SessionListState, SessionProjectionMap, SessionSnapshot, UseProjection, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { ConversationSession, ConversationSessionHeader, type ConversationSessionHeaderProps, type ConversationSessionProps, } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createConversationStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' @@ -41,23 +50,28 @@ import { TrajectoryView, type TrajectoryViewInjected, } from '../src/client/TrajectoryView.tsx' import { createTrajectoryDurationStore } from '../src/client/duration-store.ts' +import { EMPTY_TRAJECTORY_SNAPSHOT } from '../src/client/trajectory-snapshot-builder.ts' import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId -const sessionSnapshots = new WeakMap>() const tConversation: ConversationSessionHeaderProps['t'] = key => (conversationZh as Record)[key] ?? key -afterEach(cleanup) -// The chat store persists under its declared key; clear so one case's active +const runtimes: SlotTestRuntime[] = [] + +afterEach(async () => { + cleanup() + for (const runtime of runtimes.splice(0)) await runtime.dispose() +}) +// The Conversation store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ -const NODES = [ +const NODES: LegacyConversationSlice['nodes'] = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [], @@ -65,19 +79,19 @@ const NODES = [ }, { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, callView: null, resultView: null, subCalls: [], }, { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [], timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 }, }, -] as unknown as ConversationSnapshot['nodes'] +] function historySnapshot( - nodes: ConversationSnapshot['nodes'], + nodes: LegacyConversationSlice['nodes'], inspection: Partial = {}, -): ConversationSnapshot { - const trajectory: TrajectorySnapshot = { +): TrajectorySnapshot { + return { eventNodes: nodes, eventLocations: new Map(), requests: [], @@ -86,21 +100,14 @@ function historySnapshot( runningCalls: [], ...inspection, } +} + +function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapshot { return { sessionId: SID, - views: { - get: target => target === 'trajectory' ? trajectory : undefined, - }, - chat: EMPTY_CHAT_SNAPSHOT, - nodes, - turnTimings: new Map(), - turnEnds: new Map(), - partial: trajectory.partial, - runningCalls: trajectory.runningCalls, queue: [], running: false, subagent: null, - composerPhase: 'active', removed: false, openState: 'open', openError: null, @@ -109,18 +116,33 @@ function historySnapshot( promptError: null, blank: nodes.length === 0, lastAgentError: null, + promptAttempted: nodes.length > 0, + awaitingFirstTurn: false, + } +} + +function conversationSnapshot( + trajectory: TrajectorySnapshot, +): ConversationSnapshot { + return { + views: EMPTY_CONVERSATION_SNAPSHOT.views, + activeTargets: trajectory.eventNodes.length === 0 + ? new Set() + : new Set(['trajectory']), } } function standaloneHistory( - snapshot: ConversationSnapshot, + snapshot: TrajectorySnapshot, ): Pick< ComponentProps, - 'useSession' | 'loadOlder' + 'useSession' | 'useTrajectory' | 'loadOlder' > { - const store = createSnapshotStore(snapshot) + const session = createSnapshotStore(sessionSnapshot(snapshot.eventNodes)) + const trajectory = createSnapshotStore(snapshot) return { - useSession: bindSnapshotSelector(store), + useSession: bindSnapshotSelector(session), + useTrajectory: bindSnapshotSelector(trajectory), loadOlder: () => Promise.resolve(false), } } @@ -135,11 +157,6 @@ function standaloneDuration(): Pick< } } -function fakeSession(nodes: ConversationSnapshot['nodes']) { - const store = createSnapshotStore(historySnapshot(nodes)) - return { store, useSession: bindSnapshotSelector(store) } -} - /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( @@ -148,50 +165,104 @@ function emptySessions() { } function emptyWorkspaces() { - const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, - recentWorkspaceId: undefined, + const store = createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } +function emptyProjection>( + key: Key, +): SessionProjectionMap[Key] | undefined +function emptyProjection, Selected>( + key: Key, + selector: (value: SessionProjectionMap[Key] | undefined) => Selected, + eq?: (left: Selected, right: Selected) => boolean, +): Selected +function emptyProjection, Selected>( + _key: Key, + selector?: (value: SessionProjectionMap[Key] | undefined) => Selected, +): SessionProjectionMap[Key] | Selected | undefined { + return selector === undefined ? undefined : selector(undefined) +} + +const useProjection: UseProjection = emptyProjection + +type StandaloneBaseProps = Omit< + ComponentProps, + 'useSession' | 'useTrajectory' | 'useDuration' | 'loadOlder' | 'setActualDuration' +> + /** Standalone view props: the session-scope standard kit the outlet would bake. */ function standaloneProps( - nodes: ConversationSnapshot['nodes'], -): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { + nodes: LegacyConversationSlice['nodes'], +): StandaloneBaseProps { + const trajectory = historySnapshot(nodes) + const input = createSnapshotStore({ + draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + }) + const inputActions: InputActions = { + setDraft: () => {}, + addImages: () => false, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + } return { sessionId: SID, - useSession: fakeSession(nodes).useSession, + useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)), useSessions: emptySessions(), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: emptyWorkspaces(), - useProjection: (() => undefined) as never, + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot(trajectory))), + useInput: bindSnapshotSelector(input), + inputActions, + useProjection, + viewRequest: null, + openView: () => {}, + completeViewRequest: () => {}, // The locale seat the outlet would inject for the declared namespace. t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, - } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } + } +} + +type ConversationTargetSources = { + [Target in Extract]: + ObservableSnapshot } /** Real-stack bench: root Context + real SlotRegistry ring + the plugin fiber. */ async function bench(snapshot = historySnapshot(NODES)) { - const ctx = new Context() - const slots = new SlotRegistry(ctx) + const runtime = await SlotTestRuntime.create() + runtimes.push(runtime) + const ctx = runtime.ctx + const slots = runtime.slots const loadOlder = vi.fn(() => Promise.resolve()) - const sessionStore = createSnapshotStore(snapshot) - const session = { - getSnapshot: () => sessionStore.getSnapshot(), - subscribe: (listener: () => void) => sessionStore.subscribe(listener), - loadOlder, - } - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() - ctx.provide('sessions', { - binding: () => ({ session }), + await runtime.sessions.add({ + id: SID, + snapshot: { blank: false }, + session: { loadOlder }, }) - sessionSnapshots.set(slots, sessionStore) + const trajectoryStore = createSnapshotStore(snapshot) + const conversationStore = createSnapshotStore(conversationSnapshot(snapshot)) + const uiConversation = new UiConversation(ctx, runtime.sessions) + const { events, views } = uiConversation + const targetSources: ConversationTargetSources = { + chat: createSnapshotStore(undefined), + trajectory: trajectoryStore, + } + const binding: ConversationBinding = { + snapshot: conversationStore, + target: target => targetSources[target], + } + vi.spyOn(uiConversation, 'binding').mockReturnValue(binding) // The conversation entry's role: declare the ring, then seed the chat entry. - slots.register({ - name: 'root', - children: { 'conversation.view': { kind: 'list', scope: 'session' } }, - }, (_p: { renderSlot?: unknown }) => null) + await runtime.root.declare( + { 'conversation.view': { kind: 'list', scope: 'session' } }, + (_p: { renderSlot?: unknown }) => null, + ) const chatBody = vi.fn(() =>
) slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) @@ -201,10 +272,15 @@ async function bench(snapshot = historySnapshot(NODES)) { ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - ctx.plugin({ inject: [...localeInject], apply: localeApply }) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - return { ctx, slots, fiber, loadOlder, sessionStore } + await runtime.mount({ inject: [...localeInject], apply: localeApply }) + const provide = vi.spyOn(ctx.uiSession, 'provide') + const feature = await runtime.mount({ inject: [...inject], apply }) + const sourceDescriptor = provide.mock.calls[0]?.[0] + if (sourceDescriptor === undefined) throw new Error('ui-trajectory did not provide its standard source') + return { + runtime, ctx, slots, feature, loadOlder, trajectoryStore, conversationStore, + events, views, sourceDescriptor, + } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -213,28 +289,63 @@ function tabsOf(slots: SlotRegistry): ViewTab[] { .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! })) } +type ConvViewOwner = Pick + +function isConvViewOwner(owner: object): owner is ConvViewOwner { + return 'viewRequest' in owner + && 'openView' in owner && typeof owner.openView === 'function' + && 'completeViewRequest' in owner && typeof owner.completeViewRequest === 'function' +} + /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */ -function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES) { - const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes)) - const useSession = bindSnapshotSelector(sessionSnapshot) - const chat = createChatStore().create() - const views = { - list: () => tabsOf(slots), - subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), - version: () => slots.getVersion('conversation.view'), - } - const useInput = bindSnapshotSelector(createSnapshotStore({ +function mount(fixture: Awaited>) { + const { runtime, slots, trajectoryStore, conversationStore } = fixture + const session = runtime.sessions.binding(SID)?.session + if (session === undefined) throw new Error('trajectory fixture session is unavailable') + const useSession = bindSnapshotSelector(session) + const useTrajectory = bindSnapshotSelector(trajectoryStore) + const useConversation = bindSnapshotSelector(conversationStore) + const useChat = bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)) + const useSessions = emptySessions() + const useSessionPendingInteraction = bindSnapshotSelector( + createSnapshotStore(new Map()), + ) + const useWorkspaces = emptyWorkspaces() + const conversation = createConversationStore().create() + const useConversationViews = bindSnapshotSelector( + createSnapshotStore(tabsOf(slots)), + ) + const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], - })) as never - const inputActions = { - setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(), + })) + const inputActions: InputActions = { + setDraft: vi.fn(), + addImages: vi.fn(() => false), + removeImage: vi.fn(), + pruneImages: vi.fn(), + submit: vi.fn(), + } + const standardProps = { + sessionId: SID, + useSession, + useTrajectory, + useChat, + useConversation, + useConversationViews, + useSessions, + useSessionPendingInteraction, + useWorkspaces, + useProjection, + useInput, + inputActions, } // Minimal outlet twin: resolve the ring entry by the `only` filter and // render it with the session standard kit (what SlotOutlet does for a // list-kind session slot, minus machinery). - const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => { + const renderSlot: ConversationSessionProps['renderSlot'] = (key, owner, opts): ReactNode => { const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only) if (entry === undefined) return null + if (!isConvViewOwner(owner)) throw new Error('trajectory fixture expected Conversation view owner props') const View = entry.component as FC const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined const injected = injectEntry === undefined @@ -251,46 +362,32 @@ function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES } })() : injected + const viewProps: ConvViewProps = { ...owner, ...standardProps } return ( ) - }) as unknown as ConversationSessionProps['renderSlot'] + } return render( <> children(SID)} - useSession={useSession} - useSessions={emptySessions()} - useWorkspaces={emptyWorkspaces()} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} + {...standardProps} + SessionProvider={({ children }) => children} + useStore={bindSnapshotSelector(conversation)} + actions={conversation.actions} renderSlot={() => null} - views={views} - useInput={useInput} - inputActions={inputActions} open={vi.fn()} t={tConversation} /> children(SID)} - useSession={useSession} - useSessions={emptySessions()} - useWorkspaces={emptyWorkspaces()} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} + {...standardProps} + SessionProvider={({ children }) => children} + useStore={bindSnapshotSelector(conversation)} + actions={conversation.actions} renderSlot={renderSlot} - views={views} - releaseSessionImages={vi.fn()} - useInput={useInput} - inputActions={inputActions} bindDraftMirror={() => () => {}} /> , @@ -308,16 +405,36 @@ describe('plugin registration', () => { it('fiber disposal removes the tab and leaves chat standing', async () => { const b = await bench() - const events = b.ctx.get('conversationEvents') as ConversationEventRegistry - const views = b.ctx.get('conversationViews') as ConversationViewRegistry - expect(events.entries().length).toBeGreaterThan(0) - expect(views.entries()).toHaveLength(1) + expect(b.events.entries().length).toBeGreaterThan(0) + expect(b.views.entries()).toHaveLength(1) - await b.fiber.dispose() + await b.feature.dispose() expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat']) - expect(events.entries()).toEqual([]) - expect(views.entries()).toEqual([]) + expect(b.events.entries()).toEqual([]) + expect(b.views.entries()).toEqual([]) + }) + + it('keeps one total standard source for a Session binding', async () => { + const b = await bench() + const binding = b.runtime.sessions.binding(SID) + if (binding === undefined) throw new Error('Trajectory source test Session binding is unavailable') + const resolveSource = (owner: SessionBinding): ObservableSnapshot => { + const contribution = b.sourceDescriptor.resolve(owner) as { + hooks: { trajectory: ObservableSnapshot } + } + return contribution.hooks.trajectory + } + const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.trajectory as + ObservableSnapshot + + expect(resolveSource(binding)).toBe(source) + expect(resolveSource(binding)).toBe(source) + const optionalTrajectory = b.trajectoryStore as unknown as { + set(value: TrajectorySnapshot | undefined): void + } + optionalTrajectory.set(undefined) + expect(source.getSnapshot()).toBe(EMPTY_TRAJECTORY_SNAPSHOT) }) it('shares one browser-wide duration preference across session injections', async () => { @@ -329,6 +446,7 @@ describe('plugin registration', () => { sessionId: SessionId, ) => TrajectoryViewInjected const first = injectEntry(SID) + await b.runtime.sessions.add({ id: 's2' }, { current: false }) const second = injectEntry('s2' as SessionId) expect(second.hooks.duration).toBe(first.hooks.duration) @@ -350,7 +468,7 @@ describe('plugin registration', () => { expect(await injected.loadOlder()).toBe(false) b.loadOlder.mockImplementationOnce(async () => { - b.sessionStore.set(historySnapshot([...NODES])) + b.trajectoryStore.set(historySnapshot([...NODES])) }) expect(await injected.loadOlder()).toBe(true) }) @@ -359,7 +477,7 @@ describe('plugin registration', () => { describe('tab switching in ConversationRoot', () => { it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => { const b = await bench() - const view = mount(b.slots) + const view = mount(b) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory']) @@ -393,7 +511,7 @@ describe('tab switching in ConversationRoot', () => { it('opens a local record inspector and switches payload tabs without opening chat details', async () => { const b = await bench() - mount(b.slots) + mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' }) @@ -407,7 +525,7 @@ describe('tab switching in ConversationRoot', () => { }) it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => { - const nodes = [ + const nodes: LegacyConversationSlice['nodes'] = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, @@ -418,7 +536,7 @@ describe('tab switching in ConversationRoot', () => { kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'after' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] const compaction: RequestView = { purpose: 'compaction', startSeq: 3, @@ -430,7 +548,7 @@ describe('tab switching in ConversationRoot', () => { summary: [{ type: 'text', text: 'standalone summary' }], } const b = await bench(historySnapshot(nodes, { requests: [compaction] })) - const view = mount(b.slots, nodes) + const view = mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByText('Between turns')).toBeTruthy() @@ -442,7 +560,7 @@ describe('tab switching in ConversationRoot', () => { }) it('activates only the selected standalone compaction section', async () => { - const nodes = [ + const nodes: LegacyConversationSlice['nodes'] = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, @@ -458,7 +576,7 @@ describe('tab switching in ConversationRoot', () => { kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1, blocks: [{ kind: 'text', text: 'after second compaction' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] const compactions: RequestView[] = [ { purpose: 'compaction', @@ -482,7 +600,7 @@ describe('tab switching in ConversationRoot', () => { }, ] const b = await bench(historySnapshot(nodes, { requests: compactions })) - mount(b.slots, nodes) + mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' }) @@ -507,7 +625,7 @@ describe('tab switching in ConversationRoot', () => { it('dragging the overview focuses overlapping records without filtering the ledger', async () => { const b = await bench() - mount(b.slots) + mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ @@ -539,7 +657,7 @@ describe('tab switching in ConversationRoot', () => { it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => { const b = await bench() - const view = mount(b.slots) + const view = mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ @@ -577,7 +695,7 @@ describe('tab switching in ConversationRoot', () => { it('empty window keeps the toolbar and reports no timing data', async () => { const b = await bench(historySnapshot([])) - mount(b.slots) + mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy() @@ -1169,20 +1287,21 @@ describe('TrajectoryView state', () => { it('keeps ledger and timeline selection on the same event after prepend', () => { - const older = { + const older: LegacyConversationSlice['nodes'][number] = { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'older prompt' }], source: null, - } as unknown as ConversationSnapshot['nodes'][number] - const current = { + } + const current: LegacyConversationSlice['nodes'][number] = { kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'selected current response' }], - } as unknown as ConversationSnapshot['nodes'][number] + } const store = createSnapshotStore(historySnapshot([current])) const view = render( Promise.resolve(false))} />, ) diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 028c60be8e..f18a03de31 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -14,15 +14,30 @@ { "path": "../locale" }, + { + "path": "../store" + }, { "path": "../ui-conversation" }, { - "path": "../runtime" + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, { "path": "../../core/tools" },