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 <state>/chat-scratch/<hash> (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:<this host's chat-scratch root>/<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
This commit is contained in:
Brennan Benson
2026-10-14 13:08:31 -07:00
committed by GitHub
parent 8f83f6b076
commit d40fbdba91
10 changed files with 109 additions and 7 deletions
@@ -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<ArtifactWriteRequest>
className?: string
/** For a toolbar whose other buttons use a different style. */
variant?: React.ComponentProps<typeof Button>['variant']
size?: React.ComponentProps<typeof Button>['size']
disabled?: boolean
} & (
| { anchorRef?: never; open?: never; onOpenChange?: never }
@@ -134,8 +139,8 @@ export function ArtifactPublishButton({
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
variant={variant}
size={size}
className={cn('shrink-0', className)}
disabled={blocked}
aria-label={label}
@@ -20,6 +20,7 @@ export type ArtifactPublishPreparationErrorCode =
| 'empty'
| 'too-large'
| 'unreadable'
| 'visual-unreadable'
| 'unsupported'
| 'binary'
@@ -136,6 +137,11 @@ function artifactPreparationErrorDescription(code: ArtifactPublishPreparationErr
'auto.components.artifacts.artifact-publish-flow.e2ed5acd8c',
"Orca couldn't read this file. Open it from a workspace and try again."
)
case 'visual-unreadable':
return translate(
'auto.components.artifacts.artifact-publish-flow.visualUnreadable',
"Couldn't read this visual to share it."
)
case 'unsupported':
return translate(
'auto.components.artifacts.artifact-publish-flow.6d475e9b25',
@@ -31,12 +31,17 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import { openDocPreviewSource } from '@/components/browser-pane/workspace-doc/doc-preview-document-actions'
import { ORCA_EDITOR_FILE_SAVED_EVENT } from '@/components/editor/editor-autosave'
import { buildDocPreviewGrantRequest } from '@/lib/doc-preview-grants'
import { fileArtifactSourceKey } from '@/lib/file-artifact-source-key'
import { readRuntimeFileContent } from '@/runtime/runtime-file-read-client'
import { writeRuntimeFile } from '@/runtime/runtime-file-mutation-client'
import { getRemoteFileArgs } from '@/runtime/runtime-file-routing'
import { readDocPreviewArtifactRequest } from '@/components/browser-pane/workspace-doc/doc-preview-artifact-share'
import {
docPreviewGrantFileOwner,
readDocPreviewArtifactRequest
} from '@/components/browser-pane/workspace-doc/doc-preview-artifact-share'
import { useAppStore } from '@/store'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import { getDefaultSettings } from '../../../../shared/constants'
import { FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
const ROOT = '/home/host/.config/orca/chat-scratch/0123456789abcdef0123456789abcdef'
@@ -45,6 +50,12 @@ const ADDRESS = { worktree: `path:${ROOT}`, relativePath: 'usage.html' }
const target = { kind: 'environment' as const, environmentId: 'env-1' }
const visual = (revision: string, html: string) => ({ ok: true, revision, html, path: PATH })
const replies: Record<string, unknown> = {
'files.read': { content: '<p>v1</p>', truncated: false, byteLength: 9 },
'artifacts.getPublishedLink': { status: 'ok', value: null },
'artifacts.publish': { status: 'ok', value: { change: 'created', item: { shareUrl: 'u' } } }
}
afterEach(cleanup)
it('previews, edits and saves a paired chat visual on its host, then shows the save inline', async () => {
@@ -53,9 +64,7 @@ it('previews, edits and saves a paired chat visual on its host, then shows the s
method === 'agentSession.readVisual'
? new Promise((resolve) => visualReads.push(resolve))
: Promise.resolve(
method === 'files.read'
? { content: '<p>v1</p>', truncated: false, byteLength: 9 }
: { capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }
replies[method] ?? { capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }
)
)
const [createBrowserTab, openFile] = [vi.fn(), vi.fn()]
@@ -121,4 +130,31 @@ it('previews, edits and saves a paired chat visual on its host, then shows the s
await waitFor(() =>
expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain('<p>v2</p>')
)
// Share: the opened preview's key, bytes read after the click, and nothing when that read fails.
useAppStore.setState({
settings: { ...getDefaultSettings('/tmp'), artifactSharingEnabled: true },
orcaProfileAuthStatus: {
activeProfileId: 'p',
configured: true,
state: 'connected',
persistence: 'none'
}
})
fireEvent.click(screen.getByRole('button', { name: 'Share as artifact' }))
fireEvent.click(await screen.findByRole('button', { name: 'Generate link' }))
await waitFor(() => expect(visualReads).toHaveLength(4))
await act(async () => visualReads[3]({ ok: false, error: 'not_found' }))
fireEvent.click(screen.getByRole('button', { name: 'Generate link' }))
await waitFor(() => expect(visualReads).toHaveLength(5))
act(() => visualReads[4](visual('r3', '<p>v3</p>')))
const published = expect.objectContaining({
sourceKey: grant && fileArtifactSourceKey(docPreviewGrantFileOwner(grant.owner), PATH),
content: '<p>v3</p>'
})
await waitFor(() =>
expect(callRuntimeRpc.mock.calls.filter((call) => call[1] === 'artifacts.publish')).toEqual([
[{ kind: 'local' }, 'artifacts.publish', published]
])
)
})
@@ -1,13 +1,20 @@
import { useEffect, useRef, useState } from 'react'
import { SquareArrowOutUpRight } from 'lucide-react'
import { toast } from 'sonner'
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
import { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { fileArtifactSourceKey } from '@/lib/file-artifact-source-key'
import { openFileInBrowserTab } from '@/lib/file-preview'
import { basename } from '@/lib/path'
import { getRuntimeTargetHostId, runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target'
import { NATIVE_CHAT_VISUAL_RESERVED_HEIGHT, NativeChatVisualFrame } from './NativeChatVisualFrame'
import { observeTranscriptVisibility } from './NativeChatTranscriptChrome'
import type { NativeChatVisualOwner } from './native-chat-visual-owner'
import { readNativeChatVisual } from './native-chat-visual-read-client'
import { useNativeChatVisualDocument } from './use-native-chat-visual-document'
export function NativeChatVisualUnavailable(): React.JSX.Element {
@@ -26,6 +33,35 @@ function openNativeChatVisualFile(worktreeId: string, path: string): void {
}
}
/** The file's own key on the chat's host, so Share here and on the opened file update one link. */
function nativeChatVisualShareKey(owner: NativeChatVisualOwner, path: string): string {
return fileArtifactSourceKey(
{
executionHostId: getRuntimeTargetHostId(owner.target),
runtimeEnvironmentId: runtimeTargetEnvironmentId(owner.target)
},
path
)
}
/** Publishes the file as saved now, never the copy on screen; a failed read publishes nothing. */
async function readNativeChatVisualShareRequest(
owner: NativeChatVisualOwner,
file: string
): Promise<ArtifactWriteRequest> {
const { target, sessionId } = owner
const outcome = await readNativeChatVisual({ target, sessionId, file }, { fresh: true })
if (!outcome.ok) {
throw new ArtifactPublishPreparationError('visual-unreadable')
}
return {
sourceKey: nativeChatVisualShareKey(owner, outcome.document.path),
content: outcome.document.html,
contentType: 'text/html',
fileName: basename(outcome.document.path)
}
}
/**
* A visual inside an assistant reply. Its frame mounts once the reply scrolls near it and stays
* mounted, so what the reader did in it survives scrolling away and back.
@@ -42,6 +78,8 @@ export function NativeChatInlineVisual({
const boxRef = useRef<HTMLDivElement | null>(null)
const [near, setNear] = useState(false)
const [retired, setRetired] = useState(false)
// Share's popover is portalled, so hover and focus-within can't keep the toolbar shown.
const [shareOpen, setShareOpen] = useState(false)
const state = useNativeChatVisualDocument(
{ target: owner.target, sessionId: owner.sessionId, file },
near
@@ -75,7 +113,10 @@ export function NativeChatInlineVisual({
themeScope={boxRef}
onRetired={() => setRetired(true)}
/>
<div className="absolute top-2 right-2 transition-opacity can-hover:opacity-0 group-hover/visual:opacity-100 focus-within:opacity-100">
<div
data-share-open={shareOpen}
className="absolute top-2 right-2 flex items-center gap-1 transition-opacity can-hover:opacity-0 group-hover/visual:opacity-100 focus-within:opacity-100 data-[share-open=true]:opacity-100"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -91,6 +132,14 @@ export function NativeChatInlineVisual({
{openLabel}
</TooltipContent>
</Tooltip>
<ArtifactPublishButton
variant="outline"
size="icon-xs"
sourceKey={nativeChatVisualShareKey(owner, state.document.path)}
createRequest={() => readNativeChatVisualShareRequest(owner, file)}
open={shareOpen}
onOpenChange={setShareOpen}
/>
</div>
</>
) : (
+1
View File
@@ -17890,6 +17890,7 @@
"fbb5018602": "This file is empty.",
"6112db5a1c": "This artifact is too large to share.",
"e2ed5acd8c": "Orca couldn't read this file. Open it from a workspace and try again.",
"visualUnreadable": "Couldn't read this visual to share it.",
"6d475e9b25": "Only local HTML and Markdown files can be shared as artifacts.",
"29a406be09": "Artifacts must contain text."
},
+1
View File
@@ -17579,6 +17579,7 @@
"fbb5018602": "Este archivo está vacío.",
"6112db5a1c": "Este artefacto es demasiado grande para compartirlo.",
"e2ed5acd8c": "Orca no pudo leer este archivo. Ábrelo desde un espacio de trabajo e inténtalo de nuevo.",
"visualUnreadable": "No se pudo leer esta visualización para compartirla.",
"6d475e9b25": "Solo los archivos HTML y Markdown locales se pueden compartir como artefactos.",
"29a406be09": "Los artefactos deben contener texto."
},
+1
View File
@@ -17734,6 +17734,7 @@
"fbb5018602": "Ce fichier est vide.",
"6112db5a1c": "Les artifacts partagés depuis Orca doivent peser moins de 800 Ko.",
"e2ed5acd8c": "Orca n'a pas pu lire ce fichier. Ouvrez-le depuis un espace de travail et réessayez.",
"visualUnreadable": "Impossible de lire cette visualisation pour la partager.",
"6d475e9b25": "Seuls les fichiers HTML et Markdown locaux peuvent être partagés comme artifacts.",
"29a406be09": "Les artifacts doivent contenir du texte."
},
+1
View File
@@ -17579,6 +17579,7 @@
"fbb5018602": "このファイルは空です。",
"6112db5a1c": "この成果物は大きすぎるため共有できません。",
"e2ed5acd8c": "Orca はこのファイルを読み取れませんでした。ワークスペースから開いて再試行してください。",
"visualUnreadable": "共有するためにこのビジュアルを読み取れませんでした。",
"6d475e9b25": "成果物として共有できるのはローカルの HTML ファイルと Markdown ファイルのみです。",
"29a406be09": "成果物にはテキストが含まれている必要があります。"
},
+1
View File
@@ -17579,6 +17579,7 @@
"fbb5018602": "이 파일은 비어 있습니다.",
"6112db5a1c": "이 아티팩트가 너무 커서 공유할 수 없습니다.",
"e2ed5acd8c": "Orca가 이 파일을 읽을 수 없습니다. 워크스페이스에서 열고 다시 시도하세요.",
"visualUnreadable": "공유하기 위해 이 시각화를 읽을 수 없습니다.",
"6d475e9b25": "로컬 HTML 및 Markdown 파일만 아티팩트로 공유할 수 있습니다.",
"29a406be09": "아티팩트에는 텍스트가 포함되어야 합니다."
},
+1
View File
@@ -17579,6 +17579,7 @@
"fbb5018602": "该文件为空。",
"6112db5a1c": "此工件太大,无法分享。",
"e2ed5acd8c": "Orca 无法读取该文件。请从工作区打开它并重试。",
"visualUnreadable": "无法读取此可视化内容以进行分享。",
"6d475e9b25": "只有本地 HTML 和 Markdown 文件可以作为工件共享。",
"29a406be09": "工件必须包含文本。"
},