Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
T
Tianyi Cui 25dcd7293c docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 21:10:59 +08:00

7.3 KiB

Agent Note: opening a produced file from the web UI

Status: implemented

English | 中文

Scope: the produced-files row a finished turn ends with, the file-path link that reads as one, and the Host opener preferring the default browser for documents a browser renders. Not in scope, by decision: serving workspace files over HTTP, and previews for a client that is not on the Host machine.

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 host.openPath, 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. 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.

Opening stays the Host's job, and prefers the default browser. host.openPath 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.

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 dual realpath, 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 /api drove settings.describe to a 200 with full data and session.list to 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: sandbox over 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 throws SecurityError on 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 locations as structured fact, so the produced-files row consumes that instead.
  • 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 the browser preference apply to all of them without a per-row change. The keyless web lane (apps/web/tests/produced-files.e2e.ts) cold-seeds a recorded write turn and pins the row in the assembled application; it deliberately does not click, because the click hands a path to the Host's opener and would launch a real application on the machine running the suite. A produced file opens as a file:// document, which cannot fetch its own siblings (a multi-file artifact that loads ./data.json breaks, while <script src>, <img>, and CSS @import are unaffected) — the one capability HTTP serving had that this does not. A client reached over the network sees nothing when it clicks: host.openPath runs on the Host and is loopback-pinned by the /api trust fence. That is the scope decision showing through, not a defect, and it is why the row keeps the full path in its title for a reader who can only copy it. Markdown opens in whatever the platform hands .md, usually an editor rather than a renderer; rendering it inside the product is a separate, deferred surface.