Bladeren bron

refactor(conversation): separate Conversation, Chat, and Trajectory owners

imccyu 2 weken geleden
bovenliggende
commit
c7d8e32aec
100 gewijzigde bestanden met toevoegingen van 2147 en 581 verwijderingen
  1. 0 161
      packages/client/runtime/tests/conversation-registry.client.spec.ts
  2. 7 3
      packages/client/ui-attachment/package.json
  3. 1 1
      packages/client/ui-attachment/src/client/MessageImages.tsx
  4. 3 1
      packages/client/ui-attachment/src/client/index.ts
  5. 23 1
      packages/client/ui-attachment/tests/message-image.client.spec.tsx
  6. 1 1
      packages/client/ui-attachment/tests/plugin.client.spec.ts
  7. 4 1
      packages/client/ui-attachment/tsconfig.json
  8. 115 0
      packages/client/ui-chat/package.json
  9. 141 0
      packages/client/ui-chat/src/client/apply.ts
  10. 40 0
      packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx
  11. 0 0
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css
  12. 1 1
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  13. 0 0
      packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx
  14. 3 3
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  15. 0 0
      packages/client/ui-chat/src/client/chat/ChatView.module.css
  16. 9 6
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  17. 0 0
      packages/client/ui-chat/src/client/chat/CommandNodeView.tsx
  18. 0 0
      packages/client/ui-chat/src/client/chat/CompactionCommandCard.tsx
  19. 1 1
      packages/client/ui-chat/src/client/chat/CompactionItem.tsx
  20. 0 0
      packages/client/ui-chat/src/client/chat/ContextBody.module.css
  21. 2 1
      packages/client/ui-chat/src/client/chat/ContextBody.tsx
  22. 0 0
      packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css
  23. 2 2
      packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx
  24. 0 0
      packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css
  25. 0 0
      packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx
  26. 0 0
      packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
  27. 0 0
      packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
  28. 0 0
      packages/client/ui-chat/src/client/chat/MessageItem.module.css
  29. 2 4
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  30. 0 0
      packages/client/ui-chat/src/client/chat/ReasoningRow.module.css
  31. 0 0
      packages/client/ui-chat/src/client/chat/ReasoningRow.tsx
  32. 0 0
      packages/client/ui-chat/src/client/chat/StatsLine.module.css
  33. 13 39
      packages/client/ui-chat/src/client/chat/StatsLine.tsx
  34. 0 0
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
  35. 3 3
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
  36. 0 0
      packages/client/ui-chat/src/client/chat/accessibility.module.css
  37. 0 0
      packages/client/ui-chat/src/client/chat/message-chrome.ts
  38. 1 1
      packages/client/ui-chat/src/client/chat/register-node-renderers.ts
  39. 1 1
      packages/client/ui-chat/src/client/chat/turn-assistant.ts
  40. 0 0
      packages/client/ui-chat/src/client/chat/use-calendar-day.ts
  41. 0 0
      packages/client/ui-chat/src/client/chat/use-throttled-visual-update.ts
  42. 14 3
      packages/client/ui-chat/src/client/contract/chat-nodes.ts
  43. 205 0
      packages/client/ui-chat/src/client/contract/slots.ts
  44. 79 0
      packages/client/ui-chat/src/client/contract/snapshot.ts
  45. 17 0
      packages/client/ui-chat/src/client/contract/store.ts
  46. 1 1
      packages/client/ui-chat/src/client/contract/turn-metrics.ts
  47. 10 9
      packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
  48. 12 9
      packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
  49. 6 6
      packages/client/ui-chat/src/client/conversation-nodes/command.ts
  50. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/common.ts
  51. 5 4
      packages/client/ui-chat/src/client/conversation-nodes/compaction.ts
  52. 5 6
      packages/client/ui-chat/src/client/conversation-nodes/fallback.ts
  53. 3 3
      packages/client/ui-chat/src/client/conversation-nodes/inbox.ts
  54. 6 8
      packages/client/ui-chat/src/client/conversation-nodes/message.ts
  55. 2 16
      packages/client/ui-chat/src/client/conversation-nodes/partial.ts
  56. 0 0
      packages/client/ui-chat/src/client/conversation-nodes/register.ts
  57. 5 4
      packages/client/ui-chat/src/client/conversation-nodes/retry.ts
  58. 5 5
      packages/client/ui-chat/src/client/conversation-nodes/tool.ts
  59. 6 5
      packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts
  60. 5 4
      packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts
  61. 7 6
      packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts
  62. 0 0
      packages/client/ui-chat/src/client/details/DetailsPanel.module.css
  63. 6 6
      packages/client/ui-chat/src/client/details/DetailsPanel.tsx
  64. 7 9
      packages/client/ui-chat/src/client/details/tool-node-reader.ts
  65. 107 0
      packages/client/ui-chat/src/client/historical-images.ts
  66. 56 0
      packages/client/ui-chat/src/client/index.ts
  67. 159 0
      packages/client/ui-chat/src/client/locale.ts
  68. 2 2
      packages/client/ui-chat/src/client/model/conversation-context.ts
  69. 0 0
      packages/client/ui-chat/src/client/model/steering-history.ts
  70. 1 1
      packages/client/ui-chat/src/client/model/tool-call-tree.ts
  71. 20 0
      packages/client/ui-chat/src/client/stores.ts
  72. 6 0
      packages/client/ui-chat/src/css-modules.d.ts
  73. 4 0
      packages/client/ui-chat/src/index.ts
  74. 21 0
      packages/client/ui-chat/src/invariant.ts
  75. 176 0
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  76. 71 0
      packages/client/ui-chat/tests/approval-command.client.spec.tsx
  77. 157 0
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  78. 5 5
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  79. 7 5
      packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts
  80. 10 41
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  81. 26 0
      packages/client/ui-chat/tests/chat-store.client.spec.ts
  82. 153 92
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  83. 27 5
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  84. 1 1
      packages/client/ui-chat/tests/conversation.client.spec.ts
  85. 1 7
      packages/client/ui-chat/tests/coverage-tails.client.spec.tsx
  86. 61 39
      packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
  87. 28 0
      packages/client/ui-chat/tests/historical-images.client.spec.ts
  88. 1 40
      packages/client/ui-chat/tests/image-labels.client.spec.tsx
  89. 1 1
      packages/client/ui-chat/tests/partial.client.spec.ts
  90. 1 1
      packages/client/ui-chat/tests/reasoning-row.client.spec.tsx
  91. 79 0
      packages/client/ui-chat/tests/selection-survival.client.spec.tsx
  92. 2 2
      packages/client/ui-chat/tests/tool-call-tree.client.spec.ts
  93. 4 2
      packages/client/ui-chat/tests/turn-metrics.client.spec.ts
  94. 22 0
      packages/client/ui-chat/tests/views-type-chain.client.spec.tsx
  95. 87 0
      packages/client/ui-chat/tsconfig.json
  96. 3 0
      packages/client/ui-chat/tsdown.config.ts
  97. 13 0
      packages/client/ui-conversation/src/client/browser-bytes.ts
  98. 25 0
      packages/client/ui-conversation/src/client/context-occupancy.ts
  99. 29 0
      packages/client/ui-conversation/src/client/contract/composer-blocks.ts
  100. 1 1
      packages/client/ui-conversation/src/client/contract/context-provenance.ts

+ 0 - 161
packages/client/runtime/tests/conversation-registry.client.spec.ts

@@ -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<null> {
-  return {
-    kind,
-    target: 'chat',
-    match: () => null,
-    start: () => null,
-    update: context => context.state,
-    buildViewNode: () => null,
-  }
-}
-
-function viewDefinition(target: string): ConversationViewDefinition<ConversationViewNode, null> {
-  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<null> = {
-      kind: 'target-only',
-      target: 'chat',
-      match: () => null,
-      start: () => null,
-      update: context => context.state,
-    }
-    const builderOnly: ConversationNodeDefinition<null> = {
-      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<null> = {
-      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()
-  })
-})

+ 7 - 3
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:^"
   }
 }

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

+ 3 - 1
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'
 

+ 23 - 1
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<Parameters<MessageImagesProps['useSessionPendingInteraction']>[0]>[0]
+type TrajectorySnapshot = Parameters<Parameters<MessageImagesProps['useTrajectory']>[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(),

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

+ 4 - 1
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"

+ 115 - 0
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"
+  ]
+}

+ 141 - 0
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<SessionBinding, ObservableSnapshot<ChatSnapshot>>()
+  const chatSource = (binding: SessionBinding): ObservableSnapshot<ChatSnapshot> => {
+    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<SessionId, ChatScrollPosition>()
+  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<typeof chatStore>): 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))
+}

+ 40 - 0
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<string, unknown>
+    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
+}

+ 0 - 0
packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css → packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css


+ 1 - 1
packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx → 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'
 

+ 0 - 0
packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx → packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx


+ 3 - 3
packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx → 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<ChatNodeOwnerProps | null>(() => node === undefined
     ? null

+ 0 - 0
packages/client/ui-conversation/src/client/chat/ChatView.module.css → packages/client/ui-chat/src/client/chat/ChatView.module.css


+ 9 - 6
packages/client/ui-conversation/src/client/chat/ChatView.tsx → 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({
             <ChatNodeSeat
               key={nodeKey}
               nodeKey={nodeKey}
-              useSession={useSession}
+              useChat={useChat}
               selectedCallId={selectedCallId}
               cwd={cwd}
               openFile={requestOpenFile}

+ 0 - 0
packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx → packages/client/ui-chat/src/client/chat/CommandNodeView.tsx


+ 0 - 0
packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx → packages/client/ui-chat/src/client/chat/CompactionCommandCard.tsx


+ 1 - 1
packages/client/ui-conversation/src/client/chat/CompactionItem.tsx → packages/client/ui-chat/src/client/chat/CompactionItem.tsx

@@ -2,7 +2,6 @@
 // expandable only when the current window includes its cited summary.
 
 import { memo, useState } from 'react'
-import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
 import {
   IconApiOutline14,
   IconChevronDownOutline14,
@@ -10,6 +9,7 @@ import {
   MarkdownText,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
+import type { CompactionSummaryNode } from '../contract/snapshot.ts'
 import css from './MessageItem.module.css'
 
 interface CompactionItemProps {

+ 0 - 0
packages/client/ui-conversation/src/client/chat/ContextBody.module.css → packages/client/ui-chat/src/client/chat/ContextBody.module.css


+ 2 - 1
packages/client/ui-conversation/src/client/chat/ContextBody.tsx → packages/client/ui-chat/src/client/chat/ContextBody.tsx

@@ -5,9 +5,10 @@
 // even when this UI version has never seen its producer.
 
 import type { ReactNode } from 'react'
-import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client'
 import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
+import type { ContextMessageNode } from '../contract/snapshot.ts'
+import type { KnownContextForm } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import css from './ContextBody.module.css'
 
 /** Model-facing text stays bounded at the disclosure, not at the producer. */

+ 0 - 0
packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css → packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css


+ 2 - 2
packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx → packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx

@@ -1,8 +1,8 @@
 import { useState } from 'react'
-import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
+import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
-import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
+import type { ContextMessageNode } from '../contract/snapshot.ts'
 import { contextBody } from './ContextBody.tsx'
 import css from './ContextInjectionRow.module.css'
 

+ 0 - 0
packages/client/ui-conversation/src/client/chat/GenericCommandCard.module.css → packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css


+ 0 - 0
packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx → packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx


+ 0 - 0
packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css → packages/client/ui-chat/src/client/chat/MessageIconActions.module.css


+ 0 - 0
packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx → packages/client/ui-chat/src/client/chat/MessageIconActions.tsx


+ 0 - 0
packages/client/ui-conversation/src/client/chat/MessageItem.module.css → packages/client/ui-chat/src/client/chat/MessageItem.module.css


+ 2 - 4
packages/client/ui-conversation/src/client/chat/MessageItem.tsx → packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -1,11 +1,9 @@
 import { memo, useEffect, useMemo, useState } from 'react'
 import type { ReactNode } from 'react'
-import type {
-  ModelRetryNode, TurnErrorNode, UserMessageNode,
-} from '@deepseek-ai/dsh-client-runtime/client'
+import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
-import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
+import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
 import { CompactionItem } from './CompactionItem.tsx'
 import { ContextInjectionRow } from './ContextInjectionRow.tsx'
 import { MessageIconActions } from './MessageIconActions.tsx'

+ 0 - 0
packages/client/ui-conversation/src/client/chat/ReasoningRow.module.css → packages/client/ui-chat/src/client/chat/ReasoningRow.module.css


+ 0 - 0
packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx → packages/client/ui-chat/src/client/chat/ReasoningRow.tsx


+ 0 - 0
packages/client/ui-conversation/src/client/chat/StatsLine.module.css → packages/client/ui-chat/src/client/chat/StatsLine.module.css


+ 13 - 39
packages/client/ui-conversation/src/client/chat/StatsLine.tsx → packages/client/ui-chat/src/client/chat/StatsLine.tsx

@@ -3,17 +3,21 @@
 // active conversation scrollport (see ConversationRoot data-conversation-scroll).
 
 import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { contextOccupancy } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
+import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
 import type {} from '@deepseek-ai/dsh-session-stats/client'
-import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
-import type { ComposerBarProps } from '../contract/slots.ts'
+import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
+import type { ChatSnapshot } from '../contract/snapshot.ts'
 import { formatTokensPerSecond } from './message-chrome.ts'
-import { assistantStepReading } from './turn-metrics.ts'
+import { assistantStepReading } from '../contract/turn-metrics.ts'
 import css from './StatsLine.module.css'
 
+export { contextOccupancy }
+
 interface WindowStats {
   turns: number
   steps: number
@@ -43,7 +47,7 @@ interface WindowStats {
  * @param nodes - snapshot nodes.
  * @returns fallback counts and summed wall times.
  */
-export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
+export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats {
   const turns = new Set<number>()
   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<ConversationSnapshot>
+  useChat: SnapshotSelectorHook<ChatSnapshot>
   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

+ 0 - 0
packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css → packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css


+ 3 - 3
packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx → 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

+ 0 - 0
packages/client/ui-conversation/src/client/chat/accessibility.module.css → packages/client/ui-chat/src/client/chat/accessibility.module.css


+ 0 - 0
packages/client/ui-conversation/src/client/chat/message-chrome.ts → packages/client/ui-chat/src/client/chat/message-chrome.ts


+ 1 - 1
packages/client/ui-conversation/src/client/chat/register-node-renderers.ts → 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 {

+ 1 - 1
packages/client/ui-conversation/src/client/chat/turn-assistant.ts → 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.

+ 0 - 0
packages/client/ui-conversation/src/client/chat/use-calendar-day.ts → packages/client/ui-chat/src/client/chat/use-calendar-day.ts


+ 0 - 0
packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts → packages/client/ui-chat/src/client/chat/use-throttled-visual-update.ts


+ 14 - 3
packages/client/ui-conversation/src/client/contract/chat-nodes.ts → 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 {}

+ 205 - 0
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<ChatSnapshot>
+
+/** Historical image group handed to the optional attachment presentation plugin. */
+export interface MessageImagesOwnerProps {
+  images: readonly { readonly attachment: ImageAttachmentRef }[]
+  loadImage: (attachment: ImageAttachmentRef) => Promise<string>
+  align: 'start' | 'end'
+}
+
+/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */
+export type RenderMessageImages = (owner: Omit<MessageImagesOwnerProps, 'loadImage'>) => 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 extends Extract<keyof ConversationTurnDataMap, string>>(
+  key: Key,
+) => Readonly<ConversationTurnDataMap[Key]> | 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<Kind extends ChatNodeKind = ChatNodeKind> =
+  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<typeof createChatStore>
+
+/** 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<void>
+  loadOlder: () => void
+  loadImage: (attachment: ImageAttachmentRef) => Promise<string>
+  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<ChatStore>
+  & InjectFace<ChatViewInjected>
+  & 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<ChatStore>
+  & InjectFace<DetailsInjected>
+  & 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<Kind> } }
+      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 }
+  }
+}

+ 79 - 0
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<number, { readonly startTime: number; readonly endTime?: number }>
+  readonly turnEnds: ReadonlyMap<number, number>
+  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,
+  },
+}

+ 17 - 0
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
+}

+ 1 - 1
packages/client/ui-conversation/src/client/chat/turn-metrics.ts → 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 {

+ 10 - 9
packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts → 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<AssistantState> = {
  * @param ctx - owning UI Conversation context.
  */
 export function registerAssistantConversationNode(ctx: Context): void {
-  ctx.conversationEvents.register(assistantDefinition)
+  ctx.uiConversation.events.register(assistantDefinition)
 }

+ 12 - 9
packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts → 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<ChatConversationViewNode, ChatSnapshot> = {
   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<ChatConversationView
  * @param ctx - owning UI Conversation context.
  */
 export function registerChatConversationView(ctx: Context): void {
-  ctx.conversationViews.register(chatViewDefinition)
+  ctx.uiConversation.views.register(chatViewDefinition)
 }

+ 6 - 6
packages/client/ui-conversation/src/client/conversation-nodes/command.ts → packages/client/ui-chat/src/client/conversation-nodes/command.ts

@@ -1,16 +1,16 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {
-  CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext,
-  ConversationNodeDefinition,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
+  ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint'
 import type {} from '@deepseek-ai/dsh-compaction/types'
 import type {} from '@deepseek-ai/dsh-commands/types'
+import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { ManualCompactionChatData } from '../contract/chat-nodes.ts'
+import type { CommandNode, CompactionSummaryNode } 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 {
     /** Ordinary slash-command lifecycle. */
     command: CommandNode
@@ -222,7 +222,7 @@ export const commandDefinition: ConversationNodeDefinition<CommandState> = {
  * @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. */

+ 1 - 1
packages/client/ui-conversation/src/client/conversation-nodes/common.ts → 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'

+ 5 - 4
packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts → 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<CompactionState> =
  * @param ctx - owning UI Conversation context.
  */
 export function registerCompactionConversationNode(ctx: Context): void {
-  ctx.conversationEvents.register(compactionDefinition)
+  ctx.uiConversation.events.register(compactionDefinition)
 }

+ 5 - 6
packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts → 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<UnknownSurfac
  * @param ctx - owning UI Conversation context.
  */
 export function registerUnknownConversationFallback(ctx: Context): void {
-  ctx.conversationEvents.registerFallback(unknownFallbackDefinition)
+  ctx.uiConversation.events.registerFallback(unknownFallbackDefinition)
 }

+ 3 - 3
packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts → packages/client/ui-chat/src/client/conversation-nodes/inbox.ts

@@ -1,7 +1,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {
   ConversationNodeDefinition, ConversationPreviousContext,
-} from '@deepseek-ai/dsh-client-runtime/client'
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
 
 interface InboxIdentity {
@@ -64,6 +64,6 @@ export const nextStepInboxDefinition = inboxDefinition('next-step')
  * @param ctx - owning UI Conversation context.
  */
 export function registerInboxConversationNodes(ctx: Context): void {
-  ctx.conversationEvents.register(nextTurnInboxDefinition)
-  ctx.conversationEvents.register(nextStepInboxDefinition)
+  ctx.uiConversation.events.register(nextTurnInboxDefinition)
+  ctx.uiConversation.events.register(nextStepInboxDefinition)
 }

+ 6 - 8
packages/client/ui-conversation/src/client/conversation-nodes/message.ts → packages/client/ui-chat/src/client/conversation-nodes/message.ts

@@ -1,10 +1,8 @@
 import type { Context } from '@deepseek-ai/cordis'
-import type {
-  ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import {
-  contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent,
-} from '@deepseek-ai/dsh-client-runtime/client'
+import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
+import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts'
+import { contextForm, contextProvenance } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { InboxState } from './inbox.ts'
 import { chatNode } from './common.ts'
 
@@ -20,7 +18,7 @@ interface ReferencedSteeringMessageNode extends SteeringMessageNode {
 
 type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode
 
-declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
+declare module '../contract/chat-nodes.ts' {
   interface ChatNodeDataMap {
     /** Ordinary turn-opening user message. */
     user: ReferencedUserMessageNode
@@ -90,5 +88,5 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
  * @param ctx - owning UI Conversation context.
  */
 export function registerMessageConversationNode(ctx: Context): void {
-  ctx.conversationEvents.register(messageDefinition)
+  ctx.uiConversation.events.register(messageDefinition)
 }

+ 2 - 16
packages/client/runtime/src/client/sessions/partial.ts → 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 }
-  }
-}

+ 0 - 0
packages/client/ui-conversation/src/client/conversation-nodes/register.ts → packages/client/ui-chat/src/client/conversation-nodes/register.ts


+ 5 - 4
packages/client/ui-conversation/src/client/conversation-nodes/retry.ts → 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<RetryState> = {
  * @param ctx - owning UI Conversation context.
  */
 export function registerRetryConversationNode(ctx: Context): void {
-  ctx.conversationEvents.register(retryDefinition)
+  ctx.uiConversation.events.register(retryDefinition)
 }

+ 5 - 5
packages/client/ui-conversation/src/client/conversation-nodes/tool.ts → 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<ToolState> = {
  * @param ctx - owning UI Conversation context.
  */
 export function registerToolConversationNode(ctx: Context): void {
-  ctx.conversationEvents.register(toolDefinition)
+  ctx.uiConversation.events.register(toolDefinition)
 }

+ 6 - 5
packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts → 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<TurnErrorState> = {
  * @param ctx - owning UI Conversation context.
  */
 export function registerTurnErrorConversationNode(ctx: Context): void {
-  ctx.conversationEvents.register(turnErrorDefinition)
+  ctx.uiConversation.events.register(turnErrorDefinition)
 }

+ 5 - 4
packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts → 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<TurnMaxTokensSt
  * @param ctx - owning UI Conversation context.
  */
 export function registerTurnMaxTokensConversationNode(ctx: Context): void {
-  ctx.conversationEvents.register(turnMaxTokensDefinition)
+  ctx.uiConversation.events.register(turnMaxTokensDefinition)
 }

+ 7 - 6
packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts → packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts

@@ -1,23 +1,24 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {
   ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client'
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
+import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type {
   AssistantChatData, FinalAssistantChatData, TurnTailChatData,
 } from '../contract/chat-nodes.ts'
-import { deriveTurnMetrics } from '../chat/turn-metrics.ts'
+import { toAssistantBlocks } from '../contract/snapshot.ts'
+import { deriveTurnMetrics } from '../contract/turn-metrics.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 {
     /** Completed-turn actions and extension tail. */
     'turn-tail': TurnTailChatData
   }
 }
 
-declare module '@deepseek-ai/dsh-client-runtime/client' {
+declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
   interface ConversationTurnDataMap {
     /** Closing Assistant and footer facts derived for this completed Turn. */
     'turn-tail': TurnTailChatData
@@ -192,5 +193,5 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
  * @param ctx - owning UI Conversation context.
  */
 export function registerTurnTailConversationNode(ctx: Context): void {
-  ctx.conversationEvents.register(turnTailDefinition)
+  ctx.uiConversation.events.register(turnTailDefinition)
 }

+ 0 - 0
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css → packages/client/ui-chat/src/client/details/DetailsPanel.module.css


+ 6 - 6
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx → 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))
 

+ 7 - 9
packages/client/ui-conversation/src/client/chat/tool-node-reader.ts → 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<ConversationSnapshot['chat']['nodes']['get']>): ChatNode<'tool-call'> | undefined {
+function toolNode(node: ReturnType<ChatNodeStore['get']>): ChatNode<'tool-call'> | undefined {
   return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined
 }
 
@@ -15,10 +13,10 @@ function toolNode(node: ReturnType<ConversationSnapshot['chat']['nodes']['get']>
  * @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)

+ 107 - 0
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<string>
+}
+
+/** Resolve durable Chat images and release their browser URLs with Session scope. */
+export class HistoricalImageCache {
+  private readonly sessions: ISessions
+  private readonly entries = new Map<string, ImageUrlEntry>()
+  private readonly generations = new Map<SessionId, number>()
+  private readonly scopeDisposers = new Map<SessionId, () => void>()
+  private readonly urls = new Set<string>()
+  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<string> {
+    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)
+}

+ 56 - 0
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 {}
+}

+ 159 - 0
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<string, string>
+
+/** 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<ChatKey, string>

+ 2 - 2
packages/client/runtime/src/client/sessions/conversation-context.ts → 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'

+ 0 - 0
packages/client/runtime/src/client/sessions/steering-history.ts → packages/client/ui-chat/src/client/model/steering-history.ts


+ 1 - 1
packages/client/runtime/src/client/sessions/tool-call-tree.ts → 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

+ 20 - 0
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<ChatStoreState, ChatActions> {
+  return defineStore({
+    init: (): ChatStoreState => ({ selection: null }),
+    actions: {
+      select: (draft, target: SelectionTarget | null) => { draft.selection = target },
+    },
+  })
+}

+ 6 - 0
packages/client/ui-chat/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 4 - 0
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 {}

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

+ 176 - 0
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<ReturnType<typeof createChatStore>['create']>
+type ChatActions = ChatInstance['actions']
+
+function sessionFakeFor() {
+  return {
+    loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
+    readAttachment: vi.fn<ISession['readAttachment']>(() => Promise.resolve({
+      ok: true,
+      value: { attachment: ATTACHMENT, data: Uint8Array.of(1) },
+    })),
+    prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
+    cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
+  } satisfies SessionBehaviorOverrides
+}
+
+async function bench() {
+  const 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<void>>(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()
+  })
+})

+ 71 - 0
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(<ApprovalCommand {...props([
+      { kind: 'assistant-step', data: {} },
+      { kind: 'tool-call', data: { root: { callId: 'other', argsRaw: '{"command":"wrong"}' } } },
+      { kind: 'tool-call', data: { root: { callId: 'call-1', argsRaw: '{"command":"pnpm test"}' } } },
+    ] as never)} />)
+
+    expect(screen.getByText('pnpm test')).toBeTruthy()
+  })
+
+  it('omits absent, uncorrelated, and settled Tool calls', () => {
+    const { container, rerender } = render(<ApprovalCommand {...props([
+      { kind: 'assistant-step', data: {} },
+      { kind: 'tool-call', data: { root: undefined } },
+      { kind: 'tool-call', data: { root: { callId: 'other', argsRaw: '{}' } } },
+      {
+        kind: 'tool-call',
+        data: { root: { kind: 'tool-result', callId: 'call-1', argsRaw: '{"command":"ignored"}' } },
+      },
+    ] as never)} />)
+    expect(container.textContent).toBe('')
+
+    rerender(<ApprovalCommand {...props([
+      { kind: 'tool-call', data: { root: { callId: 'call-1', argsRaw: '{}' } } },
+    ] as never)} />)
+    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()
+  })
+})

+ 157 - 0
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<ChatSnapshot> => {
+      const contribution = b.sourceDescriptor.resolve(owner) as {
+        hooks: { chat: ObservableSnapshot<ChatSnapshot> }
+      }
+      return contribution.hooks.chat
+    }
+    const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.chat as
+      ObservableSnapshot<ChatSnapshot>
+    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<typeof spec.inject.hooks.turnData>[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()
+  })
+})

+ 5 - 5
packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx → 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(
       <StatsLine
         t={t}
-        useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
+        useChat={bindSnapshotSelector(source)}
         useProjection={(key: string) => key === 'tokenUsage'
           ? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
           : undefined}

+ 7 - 5
packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts → 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[] = []
 

+ 10 - 41
packages/client/ui-conversation/tests/chat-stats.client.spec.tsx → 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<LegacyConversationSlice>
 
-function makeSource(init?: Partial<ConversationSnapshot>) {
-  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<ConversationSnapshot>) => {
-      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<string, unknown> = { 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)
   })
 })

+ 26 - 0
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()
+  })
+})

+ 153 - 92
packages/client/ui-conversation/tests/chat-view.client.spec.tsx → 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> = {}): 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<ConversationSnapshot>) {
-  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<SessionSnapshot> = {}) {
+  let snap = sessionSnapshot(init)
+  const subs = new Set<() => void>()
+  return {
+    set: (next: Partial<SessionSnapshot>) => {
+      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<LegacyConversationSlice>
+
+/** 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: Partial<ConversationSnapshot>) => {
-      const merged = { ...snap, ...next }
-      snap = {
-        ...merged,
-        chat: Object.hasOwn(next, 'chat') && next.chat !== undefined
-          ? next.chat
-          : chatSnapshotFixture(merged, snap.chat),
-      }
+    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<WorkspaceListState>({
+  const store = createSnapshotStore<WorkspaceSnapshot>({
     items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
-    baselinesReady: true, recentWorkspaceId: undefined,
   })
   return bindSnapshotSelector(store)
 }
 
-function makeHarness(init?: Partial<ConversationSnapshot>) {
-  const { set, source } = makeSource(init)
+function makeHarness(
+  chatSlice: ChatSlice = {},
+  sessionInit: Partial<SessionSnapshot> = {},
+  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<void>>().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<ChatViewSlotProps['chatScroll']['read']> = null
   const chatScroll: ChatViewSlotProps['chatScroll'] = {
@@ -182,8 +212,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
     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<ConversationSnapshot>) {
     }
   }) 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<SessionPendingInteractionSnapshot>(new Map()),
+    ),
     useWorkspaces: emptyWorkspaces(),
     useProjection: (() => undefined),
     useInput: (() => { throw new Error('unused') }),
@@ -275,11 +311,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
     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<ConversationSnapshot>) {
   }
   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(<h.ChatView {...h.props} />)
     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(<h.ChatView {...h.props} />)
 
     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(<h.ChatView {...h.props} />)
 
     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(<h.ChatView {...h.props} />)
     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(<h.ChatView {...h.props} />)
-    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(<h.ChatView {...h.props} />)
     // 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(<h.ChatView {...h.props} />)
     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(<h.ChatView {...h.props} />)
     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(<h.ChatView {...h.props} />)
     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(<h.ChatView {...h.props} />)
     // 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(<h.ChatView {...h.props} />)
     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(<h.ChatView {...h.props} />)
     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(<h.ChatView {...h.props} />)
     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(<h.ChatView {...h.props} />)
     expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()
-    const loading = makeHarness({ openState: 'loading' })
+    const loading = makeHarness({}, { openState: 'loading' })
     const lv = render(<loading.ChatView {...loading.props} />)
     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: {

+ 27 - 5
packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts → 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 }),

+ 1 - 1
packages/client/runtime/tests/conversation.client.spec.ts → 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', () => {

+ 1 - 7
packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx → 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(
       <AssistantMarkdown

+ 61 - 39
packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx → packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx

@@ -3,20 +3,25 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
-import {
-  createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import type { UseSession } from '@deepseek-ai/dsh-client-test-runtime'
-import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type {
+  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 { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
-import type { DetailsSlotProps, DetailsToolOwnerProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {
+  DetailsSlotProps, DetailsToolOwnerProps, RunningToolCall, SelectionTarget,
+} from '@deepseek-ai/dsh-client-ui-chat/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 { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
 import { StatsLine } from '../src/client/chat/StatsLine.tsx'
-import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
-import { zh } from '../src/client/locales.ts'
+import { DetailsPanel } from '../src/client/details/DetailsPanel.tsx'
+import { zh } from '../src/client/locale.ts'
 import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
 
 const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
@@ -38,7 +43,7 @@ afterEach(() => {
 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<WorkspaceSnapshot>({
+    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(
       <StatsLine
         t={t}
-        useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
+        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<SessionListState>(
       { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
-    const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
-      items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
-      baselinesReady: true, recentWorkspaceId: undefined,
-    })
+    const workspaces = emptyWorkspaces()
     const view = render(
       <DetailsPanel
         SessionProvider={SessionProviderStub}
         renderSlot={renderToolDetailsProbe()}
         sessionId={SID}
-        useSession={bindSnapshotSelector({ getSnapshot: () => 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<SessionPendingInteractionSnapshot>(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<SessionListState>(
       { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
-    const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
-      items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
-      baselinesReady: true, recentWorkspaceId: undefined,
-    })
+    const workspaces = emptyWorkspaces()
     const owners: DetailsToolOwnerProps[] = []
     const view = render(
       <DetailsPanel
         SessionProvider={SessionProviderStub}
         renderSlot={renderToolDetailsProbe(owners)}
         sessionId={SID}
-        useSession={bindSnapshotSelector({ getSnapshot: () => 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<SessionPendingInteractionSnapshot>(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()

+ 28 - 0
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<Awaited<ReturnType<SessionFace['readAttachment']>>>()
+    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()
+  })
+})

+ 1 - 40
packages/client/ui-conversation/tests/image-labels.client.spec.tsx → 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[] = []

+ 1 - 1
packages/client/runtime/tests/partial.client.spec.ts → 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<string, unknown>): StreamChunk => c as unknown as StreamChunk
 

+ 1 - 1
packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx → 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<number, FrameRequestCallback>()

+ 79 - 0
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<ReturnType<typeof createChatStore>['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<ReturnType<typeof createBench>>,
+  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()
+  })
+})

+ 2 - 2
packages/client/runtime/tests/tool-call-tree.client.spec.ts → 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<string, unknown>): SessionEvent =>
   ({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent

+ 4 - 2
packages/client/ui-conversation/tests/turn-metrics.client.spec.ts → 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 {

+ 22 - 0
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')
+  })
+})

+ 87 - 0
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"
+    }
+  ]
+}

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

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

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

+ 29 - 0
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<ComposerBlock | undefined>
+  /**
+   * Drop one Session's store.
+   * @param sessionId - Session being released.
+   */
+  forget(sessionId: SessionId): void
+}

+ 1 - 1
packages/client/runtime/src/client/sessions/context-provenance.ts → 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

Some files were not shown because too many files changed in this diff