From d40fbdba911fce7a05e40ac1a0dffcc77a304ec3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:08:31 -0700 Subject: [PATCH] feat(native-chat): Share a chat visual from the chat (#28479) * fix(files): never write through a dangling symlink A path that doesn't exist yet was authorized by walking up to the nearest existing folder. A dangling symlink fails realpath with ENOENT, so the walk stepped over it and approved the link's own path; the write then followed the link and created its target outside the allowed folders. The walk now continues from the link's target, and returns that real destination. * refactor(native-chat): rename the chat visuals folder to the chat scratch folder The per-chat folder agents write into is the chat's scratch space, not only a visuals folder. Its root becomes /chat-scratch/ (the hash is unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every agent child inherits, Pi included. The visuals skill names the new variable and says the folder is temporary. * test(native-chat): keep the delivery test to the rename The launch parity snapshot already pins that every agent drops the pre-rename variable. * fix(files): refuse any dangling symlink in the missing-path walk Following a dangling link by hand resolved relative targets against the lexical parent, not the link's real folder: through a folder link the walk could loop forever or authorize the wrong file. Refuse instead: realpath can't say where a dangling link leads, but a write would follow it. * feat(runtime-files): the host serves a chat's scratch folder as a file root File commands accept `path:/<32 hex>` and resolve it only when it names this host's own folder, at the file-commands seam so the selector never reaches chat placement. They authorize against the project roots plus the chat-scratch root, refuse files.open/openDiff for the folder, and agentSession.readVisual's ok results carry the file's absolute path so a client can build the selector. * test(runtime-files): name the readVisual helper's parameter type * test(files): drop the loop case that duplicated the dangling-link refusal * test(runtime-files): run the readVisual RPC test, which opens a real SQLite store, under Node * test(runtime-files): put the same-suffix folder inside the authorized root so only the selector refuses it * refactor(native-chat): open a chat visual like any file "Open in new tab" on an inline chat visual now opens the visual's file the way any HTML file of the chat's workspace opens: a file:// browser tab for a local chat (its overflow menu gains "Open source file"), the document preview for a paired-host chat (with its existing source action and Share). Edits save on the chat's host through the exact path: selector the host serves, and a matching editor save re-reads the inline copy freshly. The read-only chat-visual tab is removed. * feat(native-chat): share a chat visual from the chat The inline visual's toolbar gains Share beside Open. It publishes under the file's own key on the chat's host, the key the opened preview and editor use, so every surface updates one link. The published text comes from a fresh read begun after the click; a failed read publishes nothing and says the visual couldn't be read. The toolbar stays shown while Share's popover is open. * fix(native-chat): a visual read without a path is a malformed reply A visual's path is always on the current host's readVisual answer, so the inline Open needs no branch for a missing one. Also prune the deleted chat-visual tab action from the layout-writer baseline. * test(native-chat): give visual read replies their path without longer lines * fix(native-chat): address a scratch file by its folder only outside every workspace A workspace that contains the host's state folder keeps its own address, so explorer drops, folder creation and renames inside it stay on one root. Document preview Share reads a runtime grant's own selector and root directly instead of passing the selector off as a workspace id. * fix(native-chat): show a chat visual's Share whenever it shows Every read now carries the visual's path, so Share no longer waits on one; its key comes from the path the click's fresh read returns. * test(runtime-files): wait for the stores to close before removing the state folder --- .../artifacts/ArtifactPublishButton.tsx | 9 +++- .../artifacts/artifact-publish-flow.ts | 6 +++ ...tiveChatInlineVisual.open-as-file.test.tsx | 44 ++++++++++++++-- .../native-chat/NativeChatInlineVisual.tsx | 51 ++++++++++++++++++- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + 10 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx index a90c1c037a9..8bb2a49218a 100644 --- a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx +++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx @@ -21,6 +21,8 @@ export function ArtifactPublishButton({ sourceKey, createRequest, className, + variant = 'ghost', + size = 'icon-sm', disabled, anchorRef, open: controlledOpen, @@ -29,6 +31,9 @@ export function ArtifactPublishButton({ sourceKey: string createRequest: () => Promise className?: string + /** For a toolbar whose other buttons use a different style. */ + variant?: React.ComponentProps['variant'] + size?: React.ComponentProps['size'] disabled?: boolean } & ( | { anchorRef?: never; open?: never; onOpenChange?: never } @@ -134,8 +139,8 @@ export function ArtifactPublishButton({