Przeglądaj źródła

refactor(chat): open file resources in the Sidebar and remove Details

imccyu 1 tydzień temu
rodzic
commit
7e017046ca

+ 3 - 1
.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md

@@ -18,6 +18,8 @@ Two distinct defects sat behind that. The transcript never said what a turn had
 
 **The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix.
 
+**Superseded for the web client by the [right Sidebar](2026-09-04-right-sidebar-docking-infrastructure.md):** `openFile` now opens a text-preview tab in the right Sidebar, which reads the file's text over the authenticated Remote carrier — no document is served, so the origin questions below do not arise — and the **Show in folder** action is gone; `session/openWorkspacePath` remains on the Host with no web caller. The decision as it shipped in July follows.
+
 **Opening stays the Host's job, and prefers the default browser.** `session/openWorkspacePath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application when the platform can name one, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. macOS reads the LaunchServices `https` handler and desktop Linux reads `$BROWSER`; either falls back to the default application when no browser can be named. Windows uses its registered association, and WSL first translates the path before using that same Windows handoff. When files are hidden, **Show in folder** passes `.` through the same owner `openFile`; it appears only for a loopback page whose current `host.describe.canOpenPath` permits native opening. Other deployments omit it, with `nativeOpen: false` available when desktop detection would be a false positive.
 
 **Serving workspace files over HTTP is out of scope, and so are non-local clients.** Serving files from the harness itself — same-origin with `/api`, behind `CSP: sandbox`, or from a second listener whose own port gives served documents their own origin — was rejected with the product scope: previews for a browser that is not on the Host machine are not supported, so the Host opener answers the supported case completely and the HTTP machinery would answer only the unsupported one.
@@ -33,4 +35,4 @@ Two distinct defects sat behind that. The transcript never said what a turn had
 
 ## Consequences
 
-Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and browser preference apply without per-row changes. The assembled Web test covers single-line CSS overflow and a one-click Host handoff without launching a native application. A produced `file://` document cannot `fetch` its own siblings (while `<script src>`, `<img>`, and CSS `@import` work), the one capability HTTP serving had that this does not. Remote clients keep the chips but omit the folder action; the full path remains in each chip's `title`. Markdown still opens in the platform's `.md` application; in-product rendering is separate work.
+Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and browser preference apply without per-row changes. The assembled Web test covers single-line CSS overflow and a one-click Host handoff without launching a native application. A produced `file://` document cannot `fetch` its own siblings (while `<script src>`, `<img>`, and CSS `@import` work), the one capability HTTP serving had that this does not. Every client keeps the chips and none offers a folder action; the full path remains in each chip's `title`. Markdown still opens in the platform's `.md` application; in-product rendering is separate work.

+ 3 - 1
.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md

@@ -18,6 +18,8 @@ Status: implemented
 
 **路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。
 
+**对 web 客户端已被[右侧 Sidebar](2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代:** `openFile` 现在在右侧 Sidebar 里打开一个文本预览 tab,经已认证的 Remote 载体读取文件文本——不再提供任何文档,下文的同源问题因此不再出现——**Show in folder** 动作已删除;`session/openWorkspacePath` 留在 Host 上,已无 web 调用方。以下是七月交付时的决定原文。
+
 **打开仍然是 Host 的职责,并且优先选用默认浏览器。** `session/openWorkspacePath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`;无法确定浏览器时,两者都会回退到默认应用。Windows 使用其注册的文件关联,WSL 则先转换路径,再使用同一 Windows 交接。存在隐藏文件时,**在文件夹中显示**会把 `.` 经由同一 owner `openFile` 传递;它只在 loopback 页面的当前 `host.describe.canOpenPath` 允许原生打开时出现。其他部署会省略它;桌面探测误报时可配置 `nativeOpen: false`。
 
 **以 HTTP 提供工作区文件不在范围内,非本机客户端亦然。** 由 harness 自己提供文件——与 `/api` 同源、置于 `CSP: sandbox` 之后、或交给一个以自身端口给所服务文档独立源的第二监听器——随产品范围一并否决:不为「浏览器不在 Host 机器上」的场景提供预览,因此 Host 打开器完整回答受支持的场景,而那套 HTTP 机制只会回答不受支持的那个。
@@ -33,4 +35,4 @@ Status: implemented
 
 ## 后果
 
-现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接修复与浏览器优先策略无需逐行改动。组装层 Web 测试覆盖单行 CSS 溢出和单次点击的 Host 交接,且不会启动原生应用。产出的 `file://` 文档无法 `fetch` 同级文件(但 `<script src>`、`<img>` 和 CSS `@import` 可用),这是 HTTP 提供曾有、而此处没有的能力。远程客户端保留 chip,但省略文件夹操作;每个 chip 的 `title` 仍保留完整路径。Markdown 仍由平台的 `.md` 应用打开;产品内渲染属于另一项工作。
+现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接修复与浏览器优先策略无需逐行改动。组装层 Web 测试覆盖单行 CSS 溢出和单次点击的 Host 交接,且不会启动原生应用。产出的 `file://` 文档无法 `fetch` 同级文件(但 `<script src>`、`<img>` 和 CSS `@import` 可用),这是 HTTP 提供曾有、而此处没有的能力。每个客户端都保留 chip,且都不提供文件夹操作;每个 chip 的 `title` 仍保留完整路径。Markdown 仍由平台的 `.md` 应用打开;产品内渲染属于另一项工作。

+ 2 - 2
packages/client/ui-chat/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Browser Chat target that renders Session conversation nodes, details, historical images, actions, localization, and scroll state."
+description: "Browser Chat target that renders Session conversation nodes, historical images, actions, localization, and scroll state."
 kind: "package-reference"
 ---
 # @deepseek-ai/dsh-client-ui-chat
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic.
+The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic.
 
 ## Table of Contents
 

+ 2 - 2
packages/client/ui-chat/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "渲染 Session 对话节点、详情、历史图片、操作、本地化和滚动状态的浏览器 Chat target。"
+description: "渲染 Session 对话节点、历史图片、操作、本地化和滚动状态的浏览器 Chat target。"
 kind: "package-reference"
 ---
 # @deepseek-ai/dsh-client-ui-chat
@@ -8,7 +8,7 @@ kind: "package-reference"
 
 ## 概述
 
-Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。
+Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。
 
 ## 目录
 

+ 6 - 2
packages/client/ui-chat/package.json

@@ -35,6 +35,7 @@
         "@deepseek-ai/dsh-client-ui-layout",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-session",
+        "@deepseek-ai/dsh-client-ui-sidebar-right",
         "@deepseek-ai/dsh-client-ui-settings",
         "@deepseek-ai/dsh-client-ui-workspace"
       ],
@@ -55,6 +56,7 @@
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
@@ -65,6 +67,8 @@
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-textpreview": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
@@ -74,14 +78,14 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0",
-    "@deepseek-ai/dsh-settings": "workspace:^"
+    "react-dom": "^18.2.0"
   },
   "dependencies": {
     "@deepseek-ai/schemastery": "workspace:^"

+ 26 - 23
packages/client/ui-chat/src/client/apply.ts

@@ -3,9 +3,12 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
-import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+// The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below.
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-textpreview/client'
+import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
 // 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'
@@ -15,7 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
 import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
 import type {
-  ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected,
+  ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected,
   TurnTailOwnerProps,
 } from './contract/slots.ts'
 import type { ChatSnapshot } from './contract/snapshot.ts'
@@ -25,7 +28,6 @@ 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 { en, NS, zh } from './locale.ts'
 import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx'
 import { createChatStore } from './stores.ts'
@@ -43,8 +45,8 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
 
 /** Services required by the Chat target and its presentation registrations. */
 export const inject = [
-  'slots', 'sessions', 'uiSession', 'uiConversation', 'layout', 'locale',
-  'settingsScope', 'remote', 'remote.session',
+  'slots', 'sessions', 'uiSession', 'uiConversation', 'locale',
+  'settingsScope', 'remote', 'remote.session', 'sidebarRight',
 ]
 
 /**
@@ -103,7 +105,7 @@ export function apply(ctx: Context): void {
         'conversation.message.images': { kind: 'single', scope: 'session' },
       },
       store: chatStore,
-      inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
+      inject: (sessionId: SessionId): ChatViewInjected => {
         const binding = ctx.sessions.binding(sessionId)
         if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
         const session = binding.session
@@ -114,17 +116,25 @@ export function apply(ctx: Context): void {
             chatNode: key => chat.getSnapshot().nodes.source(key),
             chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key),
           },
-          openDetails: (target) => {
-            actions.select(target)
-            ctx.layout.openDetails()
-          },
           fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner),
-          openFile: async (path) => {
+          // Files open in the right Sidebar, not in a desktop application: the
+          // content stays in the product, beside the conversation that produced
+          // it. A relative path, or an absolute one inside the session's
+          // workspace, is addressed under this session's scope,
+          // `dsh-resource://file/session/<id>/<relative path>`; an absolute path
+          // elsewhere is addressed as `dsh-resource://file/absolute/<path>` and
+          // read through the session on screen. Which tab type claims the
+          // address is the Sidebar's decision, not this call site's.
+          // A line travels as a navigation parameter, not as part of the
+          // address: the file is one piece of content whether it is opened at
+          // its top or at line 400, so the same tab is revealed and told where
+          // to land.
+          openFile: async (path, options) => {
             const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
-            const result = await ctx.remote.session.openWorkspacePath({
-              path: resolveWorkspacePath(cwd, path),
-            })
-            if (!result.ok) throw new Error(`path open failed: ${result.error.message}`)
+            const url = fileAddressFor(sessionId, cwd, path)
+            if (options?.line === undefined) ctx.sidebarRight.openResource(url)
+            else ctx.sidebarRight.openResource(url, { params: { line: options.line } })
+            await Promise.resolve()
           },
           loadOlder: () => { void session.loadOlder() },
           loadThrough: seq => session.loadThrough(seq),
@@ -160,11 +170,4 @@ export function apply(ctx: Context): void {
   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))
 }

+ 2 - 3
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -37,7 +37,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
 /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
 export const ChatNodeSeat = memo(function ChatNodeSeat({
   nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,
-  selectedCallId, cwd, openFile, inspectCall, forkAt,
+  cwd, openFile, inspectCall, forkAt,
   loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
 }: ChatNodeSeatProps) {
   const node = useChatNode(nodeKey)
@@ -103,7 +103,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
   const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
     ? null
     : {
-      selectedCallId,
       cwd,
       openFile,
       inspectCall,
@@ -113,7 +112,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
       fileMentions,
       turnProcess,
     }, [
-    node, selectedCallId, cwd, openFile, inspectCall, forkAt,
+    node, cwd, openFile, inspectCall, forkAt,
     loadImage, renderMessageImages, fileMentions, turnProcess,
   ])
   if (routedNode === undefined || owner === null) return null

+ 6 - 15
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -7,7 +7,7 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ChatViewSlotProps } from '../contract/slots.ts'
+import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
 import type { ChatSnapshot } from '../contract/snapshot.ts'
 import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
 import { ChatNodeSeat } from './ChatNodeSeat.tsx'
@@ -129,11 +129,6 @@ function openFailureMessage(error: unknown, fallback: string): string {
   return message === '' ? fallback : message
 }
 
-/** ProducedFiles opens the session workspace as `.`. */
-function isFolderOpenPath(path: string): boolean {
-  return path === '.'
-}
-
 /**
  * Prompt-RPC identities already rendered by durable material: user/steering
  * node sources plus queue occurrences. A submission echo whose identity
@@ -246,7 +241,6 @@ export function ChatView({
   const openError = useSession(s => s.openError)
   const hasMore = useSession(s => s.hasMore)
   const loadingOlder = useSession(s => s.loadingOlder)
-  const selectedCallId = useStore(s => s.selection?.callId)
   const compactTranscript = useTranscriptView(mode => mode === 'compact')
   const inspectCall = useCallback((callId: string) => {
     openView('trajectory', callId)
@@ -257,10 +251,10 @@ export function ChatView({
   // gesture; otherwise a cancelled in-flight refusal reopens the dialog.
   const fileOpenRequest = useRef(0)
 
-  const requestOpenFile = useCallback((path: string) => {
+  const requestOpenFile = useCallback((path: string, options?: OpenFileOptions) => {
     const id = ++fileOpenRequest.current
     setFileOpenBusy(true)
-    void openFile(path).then(
+    void (options === undefined ? openFile(path) : openFile(path, options)).then(
       () => {
         if (id !== fileOpenRequest.current) return
         setFileOpenError(null)
@@ -272,7 +266,7 @@ export function ChatView({
           path,
           message: openFailureMessage(
             error,
-            t(isFolderOpenPath(path) ? 'fileOpen.folderUnknown' : 'fileOpen.unknown'),
+            t('fileOpen.unknown'),
           ),
         })
         setFileOpenBusy(false)
@@ -795,7 +789,6 @@ export function ChatView({
             compactTranscript={compactTranscript}
             useStore={useStore}
             actions={actions}
-            selectedCallId={selectedCallId}
             cwd={cwd}
             openFile={requestOpenFile}
             inspectCall={inspectCall}
@@ -848,7 +841,6 @@ export function ChatView({
       </div>
       {fileOpenError !== null && (
         <FileOpenErrorDialog
-          path={fileOpenError.path}
           message={fileOpenError.message}
           busy={fileOpenBusy}
           onClose={closeFileOpenError}
@@ -862,9 +854,8 @@ export function ChatView({
 
 /** In-page Host open-path refusal: the wire reason plus a retry of the same path. */
 function FileOpenErrorDialog({
-  path, message, busy, onClose, onRetry, t,
+  message, busy, onClose, onRetry, t,
 }: {
-  path: string
   message: string
   busy: boolean
   onClose: () => void
@@ -876,7 +867,7 @@ function FileOpenErrorDialog({
       open
       onClose={onClose}
       closeLabel={t('close')}
-      title={t(isFolderOpenPath(path) ? 'fileOpen.folderTitle' : 'fileOpen.title')}
+      title={t('fileOpen.title')}
       description={message}
       footer={(
         <>

+ 10 - 31
packages/client/ui-chat/src/client/contract/slots.ts

@@ -3,7 +3,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import type {
   CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap,
-  MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, ToolCallBlock, TurnLocation,
+  MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
@@ -13,7 +13,7 @@ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 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 { ToolCallId, SelectionTarget } from './store.ts'
+import type { ToolCallId } from './store.ts'
 import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts'
 import type {
   ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation,
@@ -30,6 +30,12 @@ export type UseChatNode = KeyedSnapshotSelectorHook<ChatConversationViewNode | u
 /** Per-key selector hook over one Chat Node's Turn-process presentation. */
 export type UseChatNodeProcess = KeyedSnapshotSelectorHook<ChatTurnProcessPresentation | undefined>
 
+/** Where in a file an open should land. */
+export interface OpenFileOptions {
+  /** 1-based line to reveal; absent = the file's beginning. */
+  readonly line?: number
+}
+
 /** Owner currency of the completed-Turn extension chain. */
 export interface TurnTailOwnerProps {
   turn: TurnLocation
@@ -71,9 +77,8 @@ export interface ChatNodeTurnDataInjected {
 
 /** Stable owner currency delivered to a keyed Chat renderer. */
 export interface ChatNodeOwnerProps {
-  selectedCallId?: ToolCallId | undefined
   cwd?: string | undefined
-  openFile: (path: string) => void
+  openFile: (path: string, options?: OpenFileOptions) => void
   inspectCall: (callId: ToolCallId) => void
   forkAt: (seq: number) => void
   /**
@@ -101,12 +106,6 @@ export interface TurnProcessOwnerProps {
 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
@@ -138,8 +137,7 @@ export interface ChatViewInjected {
     /** Resolve the stable Turn-process source for one Chat Node key. */
     chatNodeProcess: (key: string) => ChatNodeProcessSource
   }
-  openDetails: (target: SelectionTarget) => void
-  openFile: (path: string) => Promise<void>
+  openFile: (path: string, options?: OpenFileOptions) => Promise<void>
   loadOlder: () => void
   /** Jump loader: page history back through seq; resolves when the window covers it. */
   loadThrough: (seq: SessionSeq) => Promise<void>
@@ -163,19 +161,6 @@ export type ChatViewSlotProps =
 /** 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. */
@@ -225,11 +210,5 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * 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 }
   }
 }

+ 1 - 10
packages/client/ui-chat/src/client/contract/store.ts

@@ -1,16 +1,8 @@
-/** Chat-owned selection state shared by the transcript and details panel. */
+/** Chat-owned per-Session view state. */
 
 /** Tool call identity as carried by Chat nodes. */
 export type ToolCallId = string
 
-/** Selection target for the Chat details linkage channel. */
-export interface SelectionTarget {
-  turnSeq: number
-  stepSeq?: number
-  callId?: ToolCallId
-  toolName?: string
-}
-
 /** One manually expanded Turn answer generation. */
 export interface TurnProcessViewEntry {
   readonly turn: number
@@ -19,6 +11,5 @@ export interface TurnProcessViewEntry {
 
 /** Per-Session state shared only by the Chat view and details surface. */
 export interface ChatStoreState {
-  selection: SelectionTarget | null
   turnProcesses: TurnProcessViewEntry[]
 }

+ 0 - 95
packages/client/ui-chat/src/client/details/DetailsPanel.module.css

@@ -1,95 +0,0 @@
-/* Details third column, minimal fill: header (name + close) over a
-   scrolling body of Input/Output code sections. Panel width/squeeze belongs
-   to layout; this fills whatever the column gives. */
-
-.root {
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-  min-width: 0;
-  border-left: 0.5px solid var(--dsw-alias-border-l2);
-  background: var(--dsw-alias-bg-base);
-}
-
-/* figma RightSidebar header frame (I54:42735;43:36451): pad 14/12/12/12, gap 8. */
-.header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px;
-  padding: 14px 12px 12px;
-  border-bottom: 0.5px solid var(--dsw-alias-border-l2);
-}
-
-/* figma I54:42735;43:41479: 14/20 wt500. */
-.title {
-  overflow: hidden;
-  font-size: 14px;
-  line-height: 20px;
-  font-weight: 500;
-  color: var(--dsw-alias-label-primary);
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.close {
-  display: grid;
-  flex: none;
-  place-items: center;
-  width: 28px;
-  height: 28px;
-  border: none;
-  border-radius: 999px;
-  corner-shape: round;
-  background: transparent;
-  color: var(--dsw-alias-label-secondary);
-  cursor: pointer;
-}
-
-.close:hover {
-  background: var(--dsw-alias-interactive-bg-hover);
-}
-
-.body {
-  flex: 1;
-  min-height: 0;
-  padding: 12px 16px;
-  overflow-y: auto;
-}
-
-.empty {
-  padding: 8px 0;
-  font-size: 13px;
-  line-height: 20px;
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.section {
-  margin-bottom: 16px;
-}
-
-.sectionLabel {
-  margin-bottom: 6px;
-  font-size: 12px;
-  line-height: 18px;
-  font-weight: 500;
-  color: var(--dsw-alias-label-secondary);
-}
-
-/* figma Code-block (I54:42735;43:41429): r12, pad 16, mono 13/22. */
-.code {
-  margin: 0;
-  padding: 16px;
-  border-radius: 12px;
-  background: var(--dsw-alias-markdown-code-block);
-  font-family: var(--ds-font-family-code);
-  font-size: 13px;
-  line-height: 22px;
-  color: var(--dsw-alias-label-primary);
-  white-space: pre-wrap;
-  word-break: break-word;
-}
-
-.code[data-error] {
-  color: var(--dsw-alias-state-error-primary);
-}

+ 0 - 110
packages/client/ui-chat/src/client/details/DetailsPanel.tsx

@@ -1,110 +0,0 @@
-import { Fragment } from 'react'
-import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
-import { shallowEqual } from '@deepseek-ai/dsh-client-store'
-import type { DetailsSlotProps } from '../contract/slots.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
-
-/** The snapshot-owned block reference must remain stable across unrelated frames. */
-interface CallMaterial {
-  name: string
-  argsRaw: string | null
-  block: ToolCallBlock
-}
-
-function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
-  return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
-}
-
-function runningMaterial(call: RunningToolCall): CallMaterial {
-  return { name: call.name, argsRaw: call.argsRaw, block: call }
-}
-
-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)
-}
-
-function pretty(raw: string): string {
-  try {
-    return JSON.stringify(JSON.parse(raw), null, 2)
-  } catch {
-    return raw
-  }
-}
-
-/** Flatten a settled result for the no-ui-tool fallback. */
-function rawResultText(block: ToolCallBlock): string {
-  if (!('kind' in block)) return ''
-  const parts = block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
-  if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
-  return parts.join('\n')
-}
-
-export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
-  const selection = useStore(s => s.selection)
-  // Session workspace root: a card model resolves omitted or relative
-  // tool paths against it without reading Session services.
-  const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
-  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 = useChat(
-    s => (callId === undefined ? null : materialFor(s, callId)),
-    (a, b) => shallowEqual(a, b))
-  return (
-    <div className={css.root}>
-      <div className={css.header}>
-        <div className={css.title}>
-          {selection === null ? t('details.title') : material?.name ?? selection.toolName ?? t('details.title')}
-        </div>
-        <button
-          type="button" className={css.close} aria-label={t('details.close')}
-          onClick={() => { closeDetails() }}
-        >
-          <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
-            <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
-          </svg>
-        </button>
-      </div>
-      <div className={css.body}>
-        {selection === null || callId === undefined
-          ? <div className={css.empty}>{t('details.empty')}</div>
-          : material === null
-            ? <div className={css.empty}>{t('details.notInWindow')}</div>
-            : (
-              <>
-                {material.argsRaw !== null && (
-                  <section className={css.section}>
-                    <div className={css.sectionLabel}>{t('details.input')}</div>
-                    <CodeBlock code={pretty(material.argsRaw)} lang="json" copyLabel={t('copy')} copiedLabel={t('copied')} />
-                  </section>
-                )}
-                <section className={css.section}>
-                  <div className={css.sectionLabel}>{t('details.output')}</div>
-                  {/* Keyed by the selected call: the body owns per-call view
-                      state (the terminal card's expand and copy), which React
-                      would otherwise carry into the next selection because the
-                      panel does not unmount between calls. */}
-                  <Fragment key={callId}>
-                    {renderSlot('conversation.details.tool', { block: material.block, cwd: sessionCwd }, {
-                      fallback: 'kind' in material.block
-                        ? (
-                          <pre className={css.code} data-error={material.block.isError || undefined}>
-                            {rawResultText(material.block)}
-                          </pre>
-                        )
-                        : <div className={css.empty}>{t('details.running')}</div>,
-                    })}
-                  </Fragment>
-                </section>
-              </>
-            )}
-      </div>
-    </div>
-  )
-}

+ 0 - 30
packages/client/ui-chat/src/client/details/tool-node-reader.ts

@@ -1,30 +0,0 @@
-import type { ChatNode } from '../contract/chat-nodes.ts'
-import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts'
-
-function toolNode(node: ReturnType<ChatNodeStore['get']>): ChatNode<'tool-call'> | undefined {
-  return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined
-}
-
-/**
- * Find any root or nested Tool lifecycle through the internal Node store.
- * @param snapshot - current Conversation snapshot.
- * @param callId - root or nested call identity.
- * @returns current Tool lifecycle when materialized in the loaded window.
- */
-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) {
-      const found = visit(child)
-      if (found !== undefined) return found
-    }
-    return undefined
-  }
-  for (const node of snapshot.nodes.values()) {
-    const root = toolNode(node)?.data.root
-    if (root === undefined) continue
-    const found = visit(root)
-    if (found !== undefined) return found
-  }
-  return undefined
-}

+ 2 - 3
packages/client/ui-chat/src/client/index.ts

@@ -27,14 +27,13 @@ export type {
   FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData,
   TurnProcessChatData, TurnTailChatData,
 } from './contract/chat-nodes.ts'
-export type { ChatStoreState, SelectionTarget, ToolCallId, TurnProcessViewEntry } from './contract/store.ts'
+export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts'
 export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx'
 export type { TranscriptViewMode } from '../chat-settings.ts'
 export type {
   AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected,
   ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
-  CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps,
-  DetailsToolOwnerProps, MessageImagesProps,
+  CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions,
   TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
 } from './contract/slots.ts'
 export type {

+ 0 - 18
packages/client/ui-chat/src/client/locale.ts

@@ -17,13 +17,6 @@ export const zh = {
   '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': '加载更早',
@@ -39,8 +32,6 @@ export const zh = {
   'settings.transcript.compact': 'Compact',
   'fileOpen.title': '无法打开文件',
   'fileOpen.unknown': '无法打开此文件',
-  'fileOpen.folderTitle': '无法打开文件夹',
-  'fileOpen.folderUnknown': '无法打开此文件夹',
   'message.extraBlock': '附加内容块',
   'message.systemPrompt': '系统提示词',
   'message.contextInjection': '上下文注入',
@@ -134,13 +125,6 @@ export const en = {
   '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',
@@ -156,8 +140,6 @@ export const en = {
   'settings.transcript.compact': 'Compact',
   '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.systemPrompt': 'System prompt',
   'message.contextInjection': 'Context injection',

+ 4 - 6
packages/client/ui-chat/src/client/stores.ts

@@ -1,9 +1,8 @@
-/** Per-Session Chat selection store shared by the transcript and details panel. */
+/** Per-Session Chat view store. */
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
-import type { ChatStoreState, SelectionTarget, TurnProcessViewEntry } from './contract/store.ts'
+import type { ChatStoreState, TurnProcessViewEntry } from './contract/store.ts'
 
 type ChatActions = {
-  select: (draft: ChatStoreState, target: SelectionTarget | null) => void
   setTurnProcessOpen: (
     draft: ChatStoreState,
     turn: number,
@@ -26,14 +25,13 @@ export function storedTurnProcessEntry(
 }
 
 /**
- * Create the Chat selection store handle.
+ * Create the Chat view store handle.
  * @returns a handle instantiated once per rendered Session scope.
  */
 export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
   return defineStore({
-    init: (): ChatStoreState => ({ selection: null, turnProcesses: [] }),
+    init: (): ChatStoreState => ({ turnProcesses: [] }),
     actions: {
-      select: (draft, target: SelectionTarget | null) => { draft.selection = target },
       setTurnProcessOpen: (draft, turn, answerStep, open) => {
         const index = draft.turnProcesses.findIndex(entry => entry.turn === turn)
         if (!open) {

+ 6 - 0
packages/client/ui-chat/tsconfig.json

@@ -86,6 +86,9 @@
     {
       "path": "../ui-session"
     },
+    {
+      "path": "../ui-sidebar-right"
+    },
     {
       "path": "../ui-settings"
     },
@@ -94,6 +97,9 @@
     },
     {
       "path": "../ui-workspace"
+    },
+    {
+      "path": "../ui-sidebar-textpreview"
     }
   ]
 }