mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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',
|
||||
|
||||
+40
-4
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -17579,6 +17579,7 @@
|
||||
"fbb5018602": "このファイルは空です。",
|
||||
"6112db5a1c": "この成果物は大きすぎるため共有できません。",
|
||||
"e2ed5acd8c": "Orca はこのファイルを読み取れませんでした。ワークスペースから開いて再試行してください。",
|
||||
"visualUnreadable": "共有するためにこのビジュアルを読み取れませんでした。",
|
||||
"6d475e9b25": "成果物として共有できるのはローカルの HTML ファイルと Markdown ファイルのみです。",
|
||||
"29a406be09": "成果物にはテキストが含まれている必要があります。"
|
||||
},
|
||||
|
||||
@@ -17579,6 +17579,7 @@
|
||||
"fbb5018602": "이 파일은 비어 있습니다.",
|
||||
"6112db5a1c": "이 아티팩트가 너무 커서 공유할 수 없습니다.",
|
||||
"e2ed5acd8c": "Orca가 이 파일을 읽을 수 없습니다. 워크스페이스에서 열고 다시 시도하세요.",
|
||||
"visualUnreadable": "공유하기 위해 이 시각화를 읽을 수 없습니다.",
|
||||
"6d475e9b25": "로컬 HTML 및 Markdown 파일만 아티팩트로 공유할 수 있습니다.",
|
||||
"29a406be09": "아티팩트에는 텍스트가 포함되어야 합니다."
|
||||
},
|
||||
|
||||
@@ -17579,6 +17579,7 @@
|
||||
"fbb5018602": "该文件为空。",
|
||||
"6112db5a1c": "此工件太大,无法分享。",
|
||||
"e2ed5acd8c": "Orca 无法读取该文件。请从工作区打开它并重试。",
|
||||
"visualUnreadable": "无法读取此可视化内容以进行分享。",
|
||||
"6d475e9b25": "只有本地 HTML 和 Markdown 文件可以作为工件共享。",
|
||||
"29a406be09": "工件必须包含文本。"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user