opening a produced file from the web UI
从 web UI 打开产出的文件
A web session that produced a file had no way to look at it. The agent wrote `deepseek-homepage.html`, said so, and the user's only recourse was to copy an absolute path like `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` into a terminal. Two distinct defects sat behind that. The transcript never said what a turn had produced: `ToolCallView.locations` — the follow-along voca
English
Problem
A web session that produced a file had no way to look at it. The agent wrote deepseek-homepage.html, said so, and the user's only recourse was to copy an absolute path like /private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html into a terminal.
Two distinct defects sat behind that. The transcript never said what a turn had produced: ToolCallView.locations — the follow-along vocabulary the file tools already populate — had no consumer in the client, so a reader's only account of the output was whatever the closing message happened to spell. And the affordance that did exist was invisible: ToolRow already renders a mutation or read row's path as a real button wired to session/openWorkspacePath, but styled exactly like the surrounding prose and underlined only on hover, so nobody found it. The reported "I can't open what it made" was a discoverability failure sitting on top of a working capability.
Decision
A finished turn ends with the files it produced. The row is its own plugin, @deepseek-ai/dsh-client-ui-deliverables, registered into the conversation.chat.turnTail hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, openFile), the plugin owns every policy. producedForClosing reads the paths off the mutation tools' own follow-along locations — a diff card, or a generic card whose kind is edit (the shape str_replace_editor's insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. CSS container-width bands select a prefix of up to six paths and its matching + N files label, while flexbox shrinks and ellipsizes the visible basenames; the component performs no JavaScript layout observation (decision). One cordis.yml line composes the surface in or out; the unregistered hole renders nothing.
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: 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.
Alternatives considered
- Serving
/f/<sessionId>/<segments…>from the harness — built and working, including confinement by dualrealpath, the browser-trust fence, streamed reads, and a separate listener whose port gave served documents their own origin. It is the only design that shows a preview to a client on another machine, which is exactly the case ruled out of scope. Retired for that reason, not because it failed; its cost was a second socket with its own lifecycle, a port published into the page, and a URL-shape contract shared across two packages. - Same-origin HTTP serving without isolation — measurably unsafe, and recorded so nobody retries it: a document served beside
/apidrovesettings.describeto a200with full data andsession.listto 35 KB of every session's transcript, from a page that need not be agent-authored at all (a read row makes every file in a cloned repository openable). Content-Security-Policy: sandboxover that same-origin serving — closes the hole by taking the document's origin away, which measurably breaks the pages this feature exists to show: the reported artifact throwsSecurityErroron load, and because an uncaught exception aborts the rest of its<script>, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still rendered perfectly, so the breakage was invisible.- Linkifying paths in the assistant's closing message — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry
locationsas structured fact, so the produced-files row consumes that instead. - Horizontal chip scrolling — keeps every file in the DOM but makes the hidden tail undiscoverable, adds a nested horizontal gesture to the transcript, and provides no exact account of what is out of view. A non-scrolling CSS lane with a width-selected visible prefix preserves the answer's vertical rhythm and keeps the omission explicit.
- An embedded WebView in the desktop shell — the strongest isolation available, since the preview then runs in a container the product owns rather than in the user's browser. It belongs to the desktop shell's own design, not to this surface, and is recorded here as the direction a future preview capability should take.
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. 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.
中文
问题
一个产出了文件的 web 会话,没有办法看到那个文件。agent(智能体)写出了 deepseek-homepage.html 并如实告知,而用户唯一的办法是把 /private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html 这样的绝对路径复制进终端。
这背后是两个不同的缺陷。transcript(文本记录)从不说明一个轮次产出了什么:ToolCallView.locations——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:ToolRow 早已把改写行或读取行的路径渲染成一个接到 session/openWorkspacePath 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的「做完了打不开」,是一个可发现性失败叠在一项本就可用的能力之上。
决策
完成的一轮以它产出的文件收尾。 该行是独立插件 @deepseek-ai/dsh-client-ui-deliverables,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 conversation.chat.turnTail 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、openFile),插件拥有全部策略。producedForClosing 从改写工具自身的跟随文件 locations 中读出路径——diff 卡片,或 kind 为 edit 的 generic 卡片(即 str_replace_editor 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。CSS 容器宽度档位会选择前六条路径中的一个前缀及其匹配的 + N 个文件 标签,flexbox 则收缩可见的 basename 并用 ellipsis 省略;组件不执行 JavaScript 布局观察(决策)。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。
路径链接读得出是链接。 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。
对 web 客户端已被右侧 Sidebar取代: 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 机制只会回答不受支持的那个。
考虑过的替代方案
- 由 harness 提供
/f/<sessionId>/<segments…>——已经实现并可用,包含双realpath路径约束、浏览器信任 fence、流式读取,以及一个以自身端口给所服务文档独立源的监听器。它是唯一能把预览呈现给另一台机器上客户端的设计,而那恰恰是被判出范围的场景。因此退役,而不是因为它失败了;它的代价是一个带自身生命周期的第二 socket、一个注入页面的端口,以及一份跨两个包共享的 URL 形状约定。 - 同源 HTTP 提供且不加隔离——经实测不安全,记录在此以免有人重试:与
/api并排提供的文档把settings.describe打到200并拿到完整数据,把session.list打到包含所有会话 transcript 的 35 KB,而这个页面根本不必由 agent 撰写(一条 read 行就让 clone 下来的仓库里任何文件变得可打开)。 - 在那套同源提供之上加
Content-Security-Policy: sandbox——它以剥夺文档的源来堵住这个洞,而这经实测会破坏本功能存在的意义所在的那类页面:所报告的产物在加载时抛SecurityError,又因为未捕获异常会中止其<script>的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们渲染得完美无缺,所以这种破坏是看不见的。 - 把路径在助手的收尾消息里链接化——这是用户开口要的形状(「在结尾附上链接」),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把
locations作为结构化事实携带,产出文件行消费的正是它。 - 让文件 chip 横向滚动——这样会把每个文件都留在 DOM 中,却使隐藏的尾部难以发现,在 transcript 内增加一层横向手势,也无法精确说明视口外还有什么。不滚动的 CSS 单行与按宽度选择的可见前缀既保留回答的纵向节奏,也明确呈现省略量。
- 桌面端外壳中的内嵌 WebView——可得到的最强隔离,因为那时预览跑在产品自己拥有的容器里,而不是用户的浏览器里。它属于桌面端外壳自身的设计,而非本交互面,记录在此作为未来预览能力应走的方向。
后果
现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 openFile,因此链接修复与浏览器优先策略无需逐行改动。组装层 Web 测试覆盖单行 CSS 溢出和单次点击的 Host 交接,且不会启动原生应用。产出的 file:// 文档无法 fetch 同级文件(但 <script src>、<img> 和 CSS @import 可用),这是 HTTP 提供曾有、而此处没有的能力。每个客户端都保留 chip,且都不提供文件夹操作;每个 chip 的 title 仍保留完整路径。Markdown 仍由平台的 .md 应用打开;产品内渲染属于另一项工作。