Add account-backed artifact sharing (#13012)

This commit is contained in:
Jinwoo Hong 2026-08-07 23:02:29 -07:00 committed by GitHub
parent 17b3dff3c4
commit c991bb27d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 5198 additions and 106 deletions

View File

@ -40,18 +40,18 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"releaseRevision": 35,
"packageDigest": "72f63dcaad3dea8bea54838835fe46c689c37b63cd96a9ef2f2537f0c2f6b8c8",
"gitTreeSha": "97f6596bb43a56c6e81246f077c35144c9025343",
"releaseRevision": 36,
"packageDigest": "6eaa0624f45402646d87d9a559f2b3dbbc0efd1d5e56cdfde219671ad62d320e",
"gitTreeSha": "a5a90c9731fbee834eca6d655b39eaa795dd2323",
"files": [
{
"path": "SKILL.md",
"size": 3835,
"size": 3913,
"executable": false,
"classification": "text",
"exactSha256": "90228630bc4daab4ccf67b691153976d1f28ec064d7069a4c170621ed2ea99b5",
"textNormalizedSha256": "90228630bc4daab4ccf67b691153976d1f28ec064d7069a4c170621ed2ea99b5",
"identitySha256": "90228630bc4daab4ccf67b691153976d1f28ec064d7069a4c170621ed2ea99b5"
"exactSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"textNormalizedSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
}
]
},

View File

@ -561,6 +561,22 @@
"identitySha256": "90228630bc4daab4ccf67b691153976d1f28ec064d7069a4c170621ed2ea99b5"
}
]
},
{
"releaseRevision": 36,
"packageDigest": "6eaa0624f45402646d87d9a559f2b3dbbc0efd1d5e56cdfde219671ad62d320e",
"gitTreeSha": "a5a90c9731fbee834eca6d655b39eaa795dd2323",
"files": [
{
"path": "SKILL.md",
"size": 3913,
"executable": false,
"classification": "text",
"exactSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"textNormalizedSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
}
]
}
],
"orchestration": [

View File

@ -2,12 +2,13 @@
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, worktree comments, and the browser embedded
inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
terminals, repos, automations, artifacts, worktree comments, and the browser
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
"give this to another agent", "another worktree", "Orca browser", or
"control the browser inside Orca". Prefer this over raw `git worktree`, ad hoc
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
"share HTML/Markdown", "public artifact link", or "control the browser inside
Orca". Prefer this over raw `git worktree`, ad hoc
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
embedded browser.
@ -225,6 +226,37 @@ Schedules accept `hourly`, `daily`, `weekdays`, `weekly`, 5-field cron, or RRULE
Use `--repo <selector>` for a new worktree per run, or `--workspace <selector>` / `--workspace-mode existing` for an existing Orca worktree. `--repo` and `--workspace` are mutually exclusive. Use `--reuse-session` only for existing-workspace automations; if the previous terminal is gone, Orca falls back to a fresh session. Prefer `--disabled` while testing setup.
## Artifacts
Artifacts publish HTML or Markdown files through the signed-in Orca account. The public
share URL is viewable without signing in; creating, listing, updating, and deleting
artifacts require the active Orca profile to be signed in.
```text
ORCA artifacts share <file> --json
ORCA artifacts update <file> --json
ORCA artifacts unshare <file> --json
ORCA artifacts list [--cursor <cursor>] --json
ORCA artifacts delete <id> --json
```
- `share`, `update`, and `unshare` accept `.html`, `.htm`, `.md`, and `.markdown` files.
- `share` saves the returned edit token in the active Orca profile and never includes it
in CLI output. `update` and `unshare` look up that record by the resolved local file
path, so use the same path and Orca profile that originally shared the file.
- `list` returns one page of artifacts owned by the signed-in account. If JSON output has
`nextCursor`, pass it back with `--cursor <cursor>`. `delete <id>` deletes an account-owned
artifact by the id returned from `list`; it does not need the original local file or its
edit-token record.
- Relative HTML assets are not uploaded. Share a self-contained HTML file or use absolute
asset URLs.
- If an upload exceeds the CLI transport limit, use the browser upload page as directed
by the error.
- For local or staging development, `--api-url <url>` overrides the artifact service;
`ORCA_ARTIFACTS_API_URL` provides the same override for the session.
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
## Built-In Browser
The built-in browser is Orca's embedded browser tab surface, scoped to Orca worktrees; it is not Chrome/Safari or desktop app UI.
@ -296,7 +328,7 @@ Common recoveries:
## Next Action
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, or built-in browser `snapshot`.
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, or built-in browser `snapshot`.
## Mobile Emulator (iOS Simulator via serve-sim)

View File

@ -2,12 +2,13 @@
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, worktree comments, and the browser embedded
inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
terminals, repos, automations, artifacts, worktree comments, and the browser
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
"give this to another agent", "another worktree", "Orca browser", or
"control the browser inside Orca". Prefer this over raw `git worktree`, ad hoc
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
"share HTML/Markdown", "public artifact link", or "control the browser inside
Orca". Prefer this over raw `git worktree`, ad hoc
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
embedded browser.

View File

@ -154,6 +154,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
if (
[
'account',
'artifacts',
'automations',
'project',
'repo',
@ -201,6 +202,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
(commandPath.length === 1 &&
[
'account',
'artifacts',
'automations',
'project',
'repo',

View File

@ -0,0 +1,22 @@
import type { ArtifactListItem, ArtifactListPage } from '../shared/artifacts'
export function formatArtifactList(artifacts: readonly ArtifactListItem[]): string {
if (artifacts.length === 0) {
return 'No shared artifacts.'
}
return artifacts
.map(({ artifact, shareUrl }) => {
const name = artifact.title || artifact.originalFileName || artifact.slug
return `${name}\n id: ${artifact.slug}\n updated: ${artifact.updatedAt}\n url: ${shareUrl}`
})
.join('\n\n')
}
export function formatArtifactListPage(page: ArtifactListPage): string {
const rows = formatArtifactList(page.artifacts)
return page.nextCursor ? `${rows}\nMore artifacts: --cursor ${page.nextCursor}` : rows
}
export function formatArtifactShared(item: ArtifactListItem): string {
return item.shareUrl
}

File diff suppressed because one or more lines are too long

View File

@ -22,6 +22,17 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['account add', 'account list'],
load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS
},
{
name: 'artifacts',
keys: [
'artifacts list',
'artifacts share',
'artifacts update',
'artifacts unshare',
'artifacts delete'
],
load: async () => (await import('./handlers/artifacts.js')).ARTIFACT_HANDLERS
},
{
name: 'automations',
keys: [

View File

@ -0,0 +1,134 @@
import { mkdtemp, open, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ArtifactListItem } from '../../shared/artifacts'
import { ARTIFACT_HANDLERS } from './artifacts'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
const item: ArtifactListItem = {
artifact: {
version: 1,
slug: 'artifact-1',
title: null,
originalFileName: 'report.html',
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-06T00:00:00.000Z',
updatedAt: '2026-08-06T00:00:00.000Z',
expiresAt: '2026-09-06T00:00:00.000Z',
byteSize: 12,
deletedAt: null
},
shareUrl: 'https://share.onorca.dev/a/artifact-1'
}
afterEach(() => vi.restoreAllMocks())
describe('artifact CLI handlers', () => {
it('reads a relative HTML file and sends sanitized content to the runtime', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: { status: 'ok', value: item },
_meta: { runtimeId: 'runtime-1' }
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await ARTIFACT_HANDLERS['artifacts share']!({
client: { call } as never,
cwd,
flags: new Map([['file', 'report.html']]),
json: false
})
expect(call).toHaveBeenCalledWith(
'artifacts.share',
expect.objectContaining({
sourceKey: join(cwd, 'report.html'),
content: '<h1>Hi</h1>',
contentType: 'text/html',
fileName: 'report.html'
})
)
expect(log).toHaveBeenCalledWith(item.shareUrl)
})
it('rejects unsupported file extensions before calling the runtime', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
await writeFile(join(cwd, 'report.txt'), 'hello', 'utf8')
const call = vi.fn()
await expect(
ARTIFACT_HANDLERS['artifacts share']!({
client: { call } as never,
cwd,
flags: new Map([['file', 'report.txt']]),
json: false
})
).rejects.toThrow(/HTML or Markdown/)
expect(call).not.toHaveBeenCalled()
})
it('rejects a sparse oversized file before attempting the RPC', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
const handle = await open(join(cwd, 'oversized.html'), 'w')
await handle.truncate(ARTIFACT_CLI_MAX_RPC_BYTES + 1)
await handle.close()
const call = vi.fn()
await expect(
ARTIFACT_HANDLERS['artifacts share']!({
client: { call } as never,
cwd,
flags: new Map([['file', 'oversized.html']]),
json: false
})
).rejects.toThrow(/too large/)
expect(call).not.toHaveBeenCalled()
})
it('passes an opaque list cursor through and prints the next cursor', async () => {
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: {
status: 'ok',
value: { artifacts: [item], nextCursor: 'next opaque page' }
},
_meta: { runtimeId: 'runtime-1' }
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await ARTIFACT_HANDLERS['artifacts list']!({
client: { call } as never,
cwd: '/repo',
flags: new Map([['cursor', 'current opaque page']]),
json: false
})
expect(call).toHaveBeenCalledWith('artifacts.list', { cursor: 'current opaque page' })
expect(log).toHaveBeenCalledWith(
expect.stringContaining('More artifacts: --cursor next opaque page')
)
})
it.each(['environment', 'pairing-code'])(
'rejects explicit remote selector --%s',
async (flag) => {
const call = vi.fn()
await expect(
ARTIFACT_HANDLERS['artifacts list']!({
client: { call } as never,
cwd: '/repo',
flags: new Map([[flag, 'remote-host']]),
json: false
})
).rejects.toThrow(/does not retarget artifact commands/)
expect(call).not.toHaveBeenCalled()
}
)
})

View File

@ -0,0 +1,187 @@
import { basename, extname, resolve } from 'node:path'
import type {
ArtifactCloudOperation,
ArtifactCloudOptions,
ArtifactListPage,
ArtifactListItem,
ArtifactWriteRequest
} from '../../shared/artifacts'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
import {
parseRemoteArtifactInput,
REMOTE_ARTIFACT_INPUT_ENV
} from '../../shared/artifact-cli-bridge'
import { readArtifactFileWithinLimit } from '../../shared/artifact-file-read'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { RuntimeClientError } from '../runtime-client'
import { formatArtifactListPage, formatArtifactShared } from '../artifact-format'
import { printResult } from '../format'
function stringFlag(ctx: HandlerContext, name: string): string | undefined {
const value = ctx.flags.get(name)
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function requireStringFlag(ctx: HandlerContext, name: string): string {
const value = stringFlag(ctx, name)
if (!value) {
throw new RuntimeClientError('invalid_argument', `Missing required ${name}.`)
}
return value
}
function cloudOptions(ctx: HandlerContext): ArtifactCloudOptions {
const apiUrl = stringFlag(ctx, 'api-url') ?? process.env.ORCA_ARTIFACTS_API_URL?.trim()
const authToken = process.env.ORCA_CLOUD_AUTH_TOKEN?.trim()
return {
...(apiUrl ? { apiUrl } : {}),
...(authToken ? { authToken } : {})
}
}
function rejectRemoteSelectionFlags(ctx: HandlerContext): void {
for (const flag of ['environment', 'pairing-code']) {
if (ctx.flags.has(flag)) {
throw new RuntimeClientError(
'invalid_argument',
`\`--${flag}\` does not retarget artifact commands; artifacts use the signed-in desktop account.`
)
}
}
}
function artifactContentType(path: string): ArtifactWriteRequest['contentType'] | null {
const extension = extname(path).toLowerCase()
return ['.html', '.htm'].includes(extension)
? 'text/html'
: ['.md', '.markdown'].includes(extension)
? 'text/markdown'
: null
}
async function readStdinWithinLimit(maxBytes: number): Promise<string> {
const chunks: Buffer[] = []
let bytes = 0
for await (const chunk of process.stdin) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
bytes += buffer.length
if (bytes > maxBytes) {
throw new RuntimeClientError(
'invalid_argument',
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
}
chunks.push(buffer)
}
return Buffer.concat(chunks).toString('utf8')
}
async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRequest> {
const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV])
const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file'))
const contentType = remoteInput?.contentType ?? artifactContentType(sourceKey)
if (!contentType) {
throw new RuntimeClientError('invalid_argument', 'Artifacts must be HTML or Markdown files.')
}
const localRead = remoteInput
? null
: await readArtifactFileWithinLimit(sourceKey, ARTIFACT_CLI_MAX_RPC_BYTES)
if (localRead?.status === 'not-file') {
throw new RuntimeClientError(
'invalid_argument',
'Artifact file was not found or is not a file.'
)
}
if (localRead?.status === 'too-large') {
throw new RuntimeClientError(
'invalid_argument',
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
}
const content = remoteInput
? await readStdinWithinLimit(ARTIFACT_CLI_MAX_RPC_BYTES)
: localRead?.status === 'ok'
? localRead.content
: ''
if (!content) {
throw new RuntimeClientError('invalid_argument', 'Artifact file is empty.')
}
const request: ArtifactWriteRequest = {
sourceKey,
content,
contentType,
fileName: remoteInput?.fileName ?? basename(sourceKey),
...cloudOptions(ctx)
}
if (Buffer.byteLength(JSON.stringify(request), 'utf8') > ARTIFACT_CLI_MAX_RPC_BYTES) {
throw new RuntimeClientError(
'invalid_argument',
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
}
return request
}
function requireOperation<T>(operation: ArtifactCloudOperation<T>): T {
if (operation.status === 'ok') {
return operation.value
}
if (operation.status === 'reconnect-required') {
throw new RuntimeClientError('authentication_required', 'Sign in to Orca and try again.')
}
throw new RuntimeClientError('authentication_unconfigured', operation.message)
}
export const ARTIFACT_HANDLERS: Record<string, CommandHandler> = {
'artifacts list': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const cursor = stringFlag(ctx, 'cursor')
const response = await ctx.client.call<ArtifactCloudOperation<ArtifactListPage>>(
'artifacts.list',
{
...cloudOptions(ctx),
...(cursor ? { cursor } : {})
}
)
const value = requireOperation(response.result)
printResult({ ...response, result: value }, ctx.json, formatArtifactListPage)
},
'artifacts share': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const response = await ctx.client.call<ArtifactCloudOperation<ArtifactListItem>>(
'artifacts.share',
await readArtifactRequest(ctx)
)
const value = requireOperation(response.result)
printResult({ ...response, result: value }, ctx.json, formatArtifactShared)
},
'artifacts update': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const response = await ctx.client.call<ArtifactCloudOperation<ArtifactListItem>>(
'artifacts.update',
await readArtifactRequest(ctx)
)
const value = requireOperation(response.result)
printResult({ ...response, result: value }, ctx.json, formatArtifactShared)
},
'artifacts unshare': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV])
const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file'))
const response = await ctx.client.call<ArtifactCloudOperation<void>>('artifacts.unshare', {
sourceKey,
...cloudOptions(ctx)
})
requireOperation(response.result)
printResult({ ...response, result: { deleted: true } }, ctx.json, () => 'Artifact deleted.')
},
'artifacts delete': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const response = await ctx.client.call<ArtifactCloudOperation<void>>('artifacts.delete', {
id: requireStringFlag(ctx, 'id'),
...cloudOptions(ctx)
})
requireOperation(response.result)
printResult({ ...response, result: { deleted: true } }, ctx.json, () => 'Artifact deleted.')
}
}

View File

@ -448,6 +448,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'linear list-issues' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous list-issues page'
}
if (command === 'artifacts list' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous artifacts page'
}
if (command === 'orchestration worker-read' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous worker-read page'
}

View File

@ -41,7 +41,7 @@ vi.mock('./runtime-client', async () => {
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
runtimeClientConstructorMock()
runtimeClientConstructorMock(remotePairingCode, environmentSelector)
const effectivePairingCode =
remotePairingCode === undefined
? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
@ -212,6 +212,28 @@ describe('command aliases dispatch to the canonical handler', () => {
})
})
describe('artifact runtime routing', () => {
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
process.exitCode = 0
})
it('uses the desktop runtime despite remote-selection environment fallbacks', async () => {
vi.stubEnv('ORCA_ENVIRONMENT', 'remote-environment')
vi.stubEnv('ORCA_PAIRING_CODE', 'remote-pairing-code')
vi.spyOn(console, 'log').mockImplementation(() => undefined)
callMock.mockResolvedValue(okFixture('artifact-list', { status: 'ok', value: [] }))
runtimeClientConstructorMock.mockClear()
await main(['artifacts', 'list', '--json'], '/folder-workspace')
expect(process.exitCode).not.toBe(1)
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null)
expect(callMock).toHaveBeenCalledWith('artifacts.list', {})
})
})
describe('unknown command surfaces a suggestion', () => {
let errorSpy: ReturnType<typeof vi.spyOn>

View File

@ -22,6 +22,7 @@ const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
return (
commandPath[0] === 'account' ||
commandPath[0] === 'artifacts' ||
commandPath[0] === 'environment' ||
commandPath[0] === 'serve' ||
commandPath[0] === 'agent' ||

View File

@ -0,0 +1,45 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
const CLOUD_FLAGS = ['api-url']
export const ARTIFACT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['artifacts', 'share'],
summary: 'Share an HTML or Markdown file with your Orca account',
usage: 'orca artifacts share <file> [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'file'],
positionalArgs: ['file'],
examples: ['orca artifacts share ./report.html', 'orca artifacts share ./notes.md --json']
},
{
path: ['artifacts', 'update'],
summary: 'Update a file previously shared from this Orca profile',
usage: 'orca artifacts update <file> [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'file'],
positionalArgs: ['file']
},
{
path: ['artifacts', 'unshare'],
destructive: true,
summary: 'Delete the artifact associated with a previously shared file',
usage: 'orca artifacts unshare <file> [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'file'],
positionalArgs: ['file']
},
{
path: ['artifacts', 'list'],
summary: 'List artifacts owned by the signed-in Orca account',
usage: 'orca artifacts list [--cursor <cursor>] [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'cursor']
},
{
path: ['artifacts', 'delete'],
aliases: [['artifacts', 'rm']],
destructive: true,
summary: 'Delete an artifact owned by the signed-in Orca account',
usage: 'orca artifacts delete <id> [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'id'],
positionalArgs: ['id']
}
]

View File

@ -16,9 +16,11 @@ import { INTROSPECTION_COMMAND_SPECS } from './introspection'
import { LINEAR_COMMAND_SPECS } from './linear'
import { VM_COMMAND_SPECS } from './vm'
import { SKILL_COMMAND_SPECS } from './skills'
import { ARTIFACT_COMMAND_SPECS } from './artifacts'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...ARTIFACT_COMMAND_SPECS,
...ACCOUNT_COMMAND_SPECS,
...PROJECT_COMMAND_SPECS,
...FILE_COMMAND_SPECS,

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import {
allowsArtifactCloudAuthOverride,
resolveArtifactCloudApiUrl
} from './artifact-cloud-config'
describe('resolveArtifactCloudApiUrl', () => {
it('uses the first-party production origin by default', () => {
expect(resolveArtifactCloudApiUrl(undefined, {}, true)).toBe('https://share.onorca.dev')
})
it('allows loopback HTTP only in development', () => {
expect(
resolveArtifactCloudApiUrl(
undefined,
{ ORCA_ARTIFACTS_API_URL: 'http://127.0.0.1:45961' },
false
)
).toBe('http://127.0.0.1:45961')
expect(() => resolveArtifactCloudApiUrl('http://127.0.0.1:45961', {}, true)).toThrow(/HTTPS/)
})
it('rejects origins that could receive an Orca access token', () => {
expect(() => resolveArtifactCloudApiUrl('https://example.com', {}, false)).toThrow(
/onorca\.dev/
)
expect(() => resolveArtifactCloudApiUrl('https://share.onorca.dev/path', {}, false)).toThrow(
/origin/
)
})
it('allows auth token overrides only in non-production development builds', () => {
expect(allowsArtifactCloudAuthOverride({}, false)).toBe(true)
expect(allowsArtifactCloudAuthOverride({ NODE_ENV: 'production' }, false)).toBe(false)
expect(allowsArtifactCloudAuthOverride({}, true)).toBe(false)
})
})

View File

@ -0,0 +1,39 @@
import { app } from 'electron'
const PRODUCTION_ARTIFACTS_API_URL = 'https://share.onorca.dev'
function isPackaged(): boolean {
try {
return app?.isPackaged === true
} catch {
return false
}
}
export function resolveArtifactCloudApiUrl(
override?: string,
env: NodeJS.ProcessEnv = process.env,
packaged = isPackaged()
): string {
const candidate = override?.trim() || env.ORCA_ARTIFACTS_API_URL?.trim()
const url = new URL(candidate || PRODUCTION_ARTIFACTS_API_URL)
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
const firstParty = url.hostname === 'onorca.dev' || url.hostname.endsWith('.onorca.dev')
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback && !packaged)) {
throw new Error('Artifact API URLs must use HTTPS; local development may use loopback HTTP.')
}
if (!firstParty && !loopback) {
throw new Error('Artifact API URLs must use an onorca.dev or loopback host.')
}
if (url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new Error('Artifact API URL must be an origin without credentials, paths, or parameters.')
}
return url.origin
}
export function allowsArtifactCloudAuthOverride(
env: NodeJS.ProcessEnv = process.env,
packaged = isPackaged()
): boolean {
return env.NODE_ENV !== 'production' && !packaged
}

View File

@ -0,0 +1,118 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { isPackaged: false },
safeStorage: { isEncryptionAvailable: () => false }
}))
import { ArtifactCloudService } from './artifact-cloud-service'
const createdPaths: string[] = []
const apiUrl = 'http://localhost:3000'
const writeRequest = {
sourceKey: '/repo/report.html',
content: '<h1>Hi</h1>',
contentType: 'text/html' as const,
fileName: 'report.html',
apiUrl,
authToken: 'token-a'
}
function createResponse(slug: string): Response {
return new Response(
JSON.stringify({
artifact: {
version: 1,
slug,
title: null,
originalFileName: 'report.html',
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-06T00:00:00.000Z',
updatedAt: '2026-08-06T00:00:00.000Z',
expiresAt: '2026-09-06T00:00:00.000Z',
byteSize: 12,
deletedAt: null
},
shareUrl: `https://share.onorca.dev/a/${slug}`,
editToken: `edit-${slug}`
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
async function setup(): Promise<ArtifactCloudService> {
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-races-'))
createdPaths.push(path)
return new ArtifactCloudService(path)
}
afterEach(async () => {
vi.unstubAllGlobals()
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('ArtifactCloudService same-source races', () => {
it('does not let an old update overwrite a newer share mapping', async () => {
const service = await setup()
let resolveUpdate: ((response: Response) => void) | undefined
const fetchMock = vi
.fn()
.mockResolvedValueOnce(createResponse('artifact-a'))
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveUpdate = resolve
})
)
.mockResolvedValueOnce(createResponse('artifact-b'))
.mockResolvedValueOnce(createResponse('artifact-b'))
vi.stubGlobal('fetch', fetchMock)
await service.share(writeRequest)
const oldUpdate = service.update(writeRequest)
await vi.waitFor(() => expect(resolveUpdate).toBeTypeOf('function'))
await service.share(writeRequest)
resolveUpdate?.(createResponse('artifact-a'))
await oldUpdate
await service.update(writeRequest)
expect(String(fetchMock.mock.calls[3]?.[0])).toBe(`${apiUrl}/v1/artifacts/artifact-b`)
})
it('does not let an old unshare delete a newer share mapping', async () => {
const service = await setup()
let resolveDelete: ((response: Response) => void) | undefined
const fetchMock = vi
.fn()
.mockResolvedValueOnce(createResponse('artifact-a'))
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveDelete = resolve
})
)
.mockResolvedValueOnce(createResponse('artifact-b'))
.mockResolvedValueOnce(createResponse('artifact-b'))
vi.stubGlobal('fetch', fetchMock)
await service.share(writeRequest)
const oldUnshare = service.unshare({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
await vi.waitFor(() => expect(resolveDelete).toBeTypeOf('function'))
await service.share(writeRequest)
resolveDelete?.(new Response(null, { status: 204 }))
await oldUnshare
await service.update(writeRequest)
expect(String(fetchMock.mock.calls[3]?.[0])).toBe(`${apiUrl}/v1/artifacts/artifact-b`)
})
})

View File

@ -0,0 +1,324 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { isPackaged: false },
safeStorage: { isEncryptionAvailable: () => false }
}))
import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import {
linkOrcaProfileToCloud,
unlinkOrcaProfileFromCloud
} from '../orca-profiles/profile-cloud-index'
import {
cloudSessionIdentity,
recordSuccessfulCloudSessionLogin,
tombstoneCloudSession
} from '../orca-profiles/profile-cloud-session-mutation'
import { saveOrcaCloudSession } from '../orca-profiles/profile-cloud-session-store'
import { ArtifactCloudService } from './artifact-cloud-service'
const createdPaths: string[] = []
const apiUrl = 'http://localhost:3000'
const cloudA: OrcaProfileCloudSummary = {
cloudProfileId: 'cloud-a',
userId: 'user-a',
email: 'a@example.com',
linkedAt: 1
}
const cloudB: OrcaProfileCloudSummary = {
cloudProfileId: 'cloud-b',
userId: 'user-b',
email: 'b@example.com',
linkedAt: 2
}
function createResponse(slug = 'artifact-a', expiresAt = '2026-09-06T00:00:00.000Z'): Response {
return new Response(
JSON.stringify({
artifact: {
version: 1,
slug,
title: null,
originalFileName: 'report.html',
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-06T00:00:00.000Z',
updatedAt: '2026-08-06T00:00:00.000Z',
expiresAt,
byteSize: 12,
deletedAt: null
},
shareUrl: `https://share.onorca.dev/a/${slug}`,
editToken: 'edit-secret'
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
async function setup(): Promise<{
userDataPath: string
profileId: string
service: ArtifactCloudService
}> {
const userDataPath = await mkdtemp(join(tmpdir(), 'orca-artifact-service-'))
createdPaths.push(userDataPath)
const active = ensureActiveOrcaProfile(userDataPath)
linkOrcaProfileToCloud(active.profile.id, cloudA, userDataPath)
recordSuccessfulCloudSessionLogin(cloudSessionIdentity(active.profile.id, cloudA), userDataPath)
return {
userDataPath,
profileId: active.profile.id,
service: new ArtifactCloudService(userDataPath)
}
}
const writeRequest = {
sourceKey: '/repo/report.html',
content: '<h1>Hi</h1>',
contentType: 'text/html' as const,
fileName: 'report.html',
apiUrl,
authToken: 'token-a'
}
afterEach(async () => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.restoreAllMocks()
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('ArtifactCloudService record authorization', () => {
it('passes an opaque cursor and returns the complete list page', async () => {
const { service } = await setup()
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ artifacts: [], nextCursor: 'next-page' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
vi.stubGlobal('fetch', fetchMock)
await expect(
service.list({ apiUrl, authToken: 'token-a', cursor: 'opaque/+=' })
).resolves.toEqual({
status: 'ok',
value: { artifacts: [], nextCursor: 'next-page' }
})
expect(fetchMock).toHaveBeenCalledWith(
`${apiUrl}/v1/artifacts?cursor=opaque%2F%2B%3D`,
expect.any(Object)
)
})
it('uses a distinct idempotency key for each logical share', async () => {
const { service } = await setup()
const fetchMock = vi
.fn()
.mockResolvedValueOnce(createResponse('artifact-a'))
.mockResolvedValueOnce(createResponse('artifact-b'))
vi.stubGlobal('fetch', fetchMock)
await service.share(writeRequest)
await service.share({ ...writeRequest, sourceKey: '/repo/other.html' })
const firstKey = requestHeader(fetchMock, 0, 'idempotency-key')
const secondKey = requestHeader(fetchMock, 1, 'idempotency-key')
expect(firstKey).toMatch(/^[0-9a-f-]{36}$/)
expect(secondKey).toMatch(/^[0-9a-f-]{36}$/)
expect(firstKey).not.toBe(secondKey)
})
it('keeps the idempotency key stable across an auth-refresh retry', async () => {
const { service, profileId, userDataPath } = await setup()
vi.stubEnv('ORCA_CLOUD_API_URL', 'http://localhost:4100')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
saveOrcaCloudSession(profileId, userDataPath, {
accessToken: 'access-old',
refreshToken: 'refresh-old',
expiresAt: Date.now() + 120_000,
capabilities: { flags: {}, refreshedAt: Date.now() }
})
let artifactAttempts = 0
const fetchMock = vi.fn().mockImplementation((input: string | URL) => {
const url = String(input)
if (url === `${apiUrl}/v1/artifacts`) {
artifactAttempts += 1
return Promise.resolve(
artifactAttempts === 1
? new Response(JSON.stringify({ code: 'invalid_access_token' }), { status: 401 })
: createResponse()
)
}
if (url === 'http://localhost:4100/v1/desktop/auth/refresh') {
return Promise.resolve(
new Response(
JSON.stringify({
accessToken: 'access-new',
refreshToken: 'refresh-new',
expiresAt: Date.now() + 3_600_000,
cloud: cloudA,
capabilities: { flags: {}, refreshedAt: Date.now() }
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
}
throw new Error(`Unexpected URL: ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
await expect(service.share({ ...writeRequest, authToken: undefined })).resolves.toMatchObject({
status: 'ok'
})
expect(requestHeader(fetchMock, 0, 'authorization')).toBe('Bearer access-old')
expect(requestHeader(fetchMock, 2, 'authorization')).toBe('Bearer access-new')
expect(requestHeader(fetchMock, 0, 'idempotency-key')).toBe(
requestHeader(fetchMock, 2, 'idempotency-key')
)
})
it('refuses account B update and unshare after account A signs out', async () => {
const { userDataPath, profileId, service } = await setup()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(createResponse()))
await service.share(writeRequest)
tombstoneCloudSession(cloudSessionIdentity(profileId, cloudA), userDataPath)
unlinkOrcaProfileFromCloud(profileId, userDataPath)
linkOrcaProfileToCloud(profileId, cloudB, userDataPath)
recordSuccessfulCloudSessionLogin(cloudSessionIdentity(profileId, cloudB), userDataPath)
await expect(service.update({ ...writeRequest, authToken: 'token-b' })).rejects.toThrow(
/has not been shared/
)
await expect(
service.unshare({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-b' })
).rejects.toThrow(/has not been shared/)
})
it('does not persist an edit token when a POST completes after relink', async () => {
const { userDataPath, profileId, service } = await setup()
let resolvePost: ((response: Response) => void) | undefined
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolvePost = resolve
})
)
)
const pending = service.share(writeRequest)
await vi.waitFor(() => expect(resolvePost).toBeTypeOf('function'))
tombstoneCloudSession(cloudSessionIdentity(profileId, cloudA), userDataPath)
unlinkOrcaProfileFromCloud(profileId, userDataPath)
linkOrcaProfileToCloud(profileId, cloudB, userDataPath)
recordSuccessfulCloudSessionLogin(cloudSessionIdentity(profileId, cloudB), userDataPath)
resolvePost?.(createResponse())
await expect(pending).rejects.toThrow(/account changed/)
await expect(service.update({ ...writeRequest, authToken: 'token-b' })).rejects.toThrow(
/has not been shared/
)
})
it('allows a POST to finish across a same-account metadata refresh', async () => {
const { userDataPath, profileId, service } = await setup()
let resolvePost: ((response: Response) => void) | undefined
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolvePost = resolve
})
)
)
const pending = service.share(writeRequest)
await vi.waitFor(() => expect(resolvePost).toBeTypeOf('function'))
linkOrcaProfileToCloud(
profileId,
{ ...cloudA, displayName: 'Updated name', linkedAt: 99 },
userDataPath
)
resolvePost?.(createResponse())
await expect(pending).resolves.toMatchObject({ status: 'ok' })
})
it('never scopes an explicit token to the profile linked in the UI', async () => {
const { service } = await setup()
const fetchMock = vi.fn().mockResolvedValue(createResponse())
vi.stubGlobal('fetch', fetchMock)
await service.share(writeRequest)
await expect(service.update({ ...writeRequest, authToken: 'token-b' })).rejects.toThrow(
/has not been shared/
)
expect(fetchMock).toHaveBeenCalledOnce()
})
it('cleans all matching source mappings after delete by slug', async () => {
const { service } = await setup()
const fetchMock = vi
.fn()
.mockResolvedValueOnce(createResponse())
.mockResolvedValueOnce(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
await service.share(writeRequest)
await service.delete('artifact-a', { apiUrl, authToken: 'token-a' })
await expect(service.update(writeRequest)).rejects.toThrow(/has not been shared/)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('keeps update and unshare working after an update extends expiration', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime('2026-08-07T00:00:00.000Z')
const { service } = await setup()
let resolveUpdate: ((response: Response) => void) | undefined
const fetchMock = vi
.fn()
.mockResolvedValueOnce(createResponse('artifact-a', '2026-09-06T00:00:00.000Z'))
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveUpdate = resolve
})
)
.mockResolvedValueOnce(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
await service.share(writeRequest)
vi.setSystemTime('2026-09-05T00:00:00.000Z')
const update = service.update(writeRequest)
await vi.waitFor(() => expect(resolveUpdate).toBeTypeOf('function'))
vi.setSystemTime('2026-09-07T00:00:00.000Z')
resolveUpdate?.(createResponse('artifact-a', '2026-10-06T00:00:00.000Z'))
await update
await expect(
service.unshare({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
).resolves.toEqual({ status: 'ok', value: undefined })
expect(fetchMock).toHaveBeenCalledTimes(3)
})
})
function requestHeader(
fetchMock: ReturnType<typeof vi.fn>,
index: number,
name: string
): string | null {
const init = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined
return new Headers(init?.headers).get(name)
}

View File

@ -0,0 +1,282 @@
import { createHash, randomUUID } from 'node:crypto'
import type {
ArtifactCloudOperation,
ArtifactCloudOptions,
ArtifactListOptions,
ArtifactListPage,
ArtifactListItem,
ArtifactWriteRequest
} from '../../shared/artifacts'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
import {
allowsArtifactCloudAuthOverride,
resolveArtifactCloudApiUrl
} from './artifact-cloud-config'
import {
type ArtifactShareScope,
captureArtifactShareLifecycle,
getArtifactShareRecord,
isArtifactShareLifecycleCurrent,
refreshArtifactShareRecordExpiration,
removeArtifactShareRecords,
saveArtifactShareRecord
} from './artifact-share-record-store'
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
type ArtifactAuthContext = {
profileId: string
scope: ArtifactShareScope
assertCurrent: () => void
}
function tokenFingerprint(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
function authContext(
active: ActiveOrcaProfileState,
scope: ArtifactShareScope,
userDataPath: string,
expectedCloud?: { userId: string; profileId: string; organizationId: string }
): ArtifactAuthContext {
const lifecycleGeneration = captureArtifactShareLifecycle(active.profile.id, userDataPath)
return {
profileId: active.profile.id,
scope,
assertCurrent: () => {
const current = ensureActiveOrcaProfile(userDataPath)
const cloudCurrent =
!expectedCloud ||
(current.profile.cloud?.userId === expectedCloud.userId &&
current.profile.cloud.cloudProfileId === expectedCloud.profileId &&
(current.profile.cloud.activeOrgId ?? '') === expectedCloud.organizationId)
if (
current.profile.id !== active.profile.id ||
!cloudCurrent ||
!isArtifactShareLifecycleCurrent(active.profile.id, userDataPath, lifecycleGeneration)
) {
throw new Error(
'The signed-in Orca account changed while the artifact request was running.'
)
}
}
}
}
function storedSessionAuthContext(
active: ActiveOrcaProfileState,
apiOrigin: string,
userDataPath: string
): ArtifactAuthContext {
if (!active.profile.cloud) {
throw new Error('The active Orca profile is not linked to a cloud account.')
}
return authContext(
active,
{
cloudUserId: active.profile.cloud.userId,
cloudProfileId: active.profile.cloud.cloudProfileId,
cloudOrganizationId: active.profile.cloud.activeOrgId ?? '',
apiOrigin
},
userDataPath,
{
userId: active.profile.cloud.userId,
profileId: active.profile.cloud.cloudProfileId,
organizationId: active.profile.cloud.activeOrgId ?? ''
}
)
}
function explicitTokenAuthContext(
active: ActiveOrcaProfileState,
apiOrigin: string,
token: string,
userDataPath: string
): ArtifactAuthContext {
const fingerprint = tokenFingerprint(token)
return authContext(
active,
{
cloudUserId: `token:${fingerprint}`,
cloudProfileId: `token:${fingerprint}`,
cloudOrganizationId: `token:${fingerprint}`,
apiOrigin
},
userDataPath
)
}
export class ArtifactCloudService {
constructor(private readonly userDataPath: string) {}
list(options: ArtifactListOptions): Promise<ArtifactCloudOperation<ArtifactListPage>> {
return this.withAuth(options, async (token, apiUrl) => {
const query = options.cursor ? `?cursor=${encodeURIComponent(options.cursor)}` : ''
return artifactRequest<ArtifactListPage>(apiUrl, token, query)
})
}
share(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
const idempotencyKey = randomUUID()
return this.withAuth(request, async (token, apiUrl, auth) => {
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
method: 'POST',
body: writeBody(request),
idempotencyKey
})
auth.assertCurrent()
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
slug: response.artifact.slug,
editToken: response.editToken,
shareUrl: response.shareUrl,
expiresAt: response.artifact.expiresAt,
...auth.scope
})
return { artifact: response.artifact, shareUrl: response.shareUrl }
})
}
update(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
return this.withAuth(request, async (token, apiUrl, auth) => {
const record = getArtifactShareRecord(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope
)
if (!record) {
throw new Error('This file has not been shared from the active Orca profile.')
}
const response = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
method: 'PUT',
editToken: record.editToken,
body: writeBody(request)
})
auth.assertCurrent()
refreshArtifactShareRecordExpiration(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
record,
response.artifact.expiresAt
)
return response
})
}
unshare(
request: ArtifactCloudOptions & { sourceKey: string }
): Promise<ArtifactCloudOperation<void>> {
return this.withAuth(request, async (token, apiUrl, auth) => {
const record = getArtifactShareRecord(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope
)
if (!record) {
throw new Error('This file has not been shared from the active Orca profile.')
}
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
method: 'DELETE',
editToken: record.editToken
})
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
sourceKey: request.sourceKey,
slug: record.slug
})
})
}
delete(id: string, options: ArtifactCloudOptions): Promise<ArtifactCloudOperation<void>> {
return this.withAuth(options, async (token, apiUrl, auth) => {
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, { method: 'DELETE' })
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
})
}
private async withAuth<T>(
options: ArtifactCloudOptions,
operation: (token: string, apiUrl: string, auth: ArtifactAuthContext) => Promise<T>
): Promise<ArtifactCloudOperation<T>> {
const apiUrl = resolveArtifactCloudApiUrl(options.apiUrl)
const active = ensureActiveOrcaProfile(this.userDataPath)
if (options.authToken?.trim()) {
if (!allowsArtifactCloudAuthOverride()) {
throw new Error(
'Artifact authentication overrides are available only in development builds.'
)
}
const token = options.authToken.trim()
const auth = explicitTokenAuthContext(active, apiUrl, token, this.userDataPath)
const value = await operation(token, apiUrl, auth)
auth.assertCurrent()
return {
status: 'ok',
value
}
}
const config = getOrcaCloudAuthConfig()
if (!config.configured) {
return { status: 'unconfigured', message: config.setupMessage }
}
const result = await runWithFreshOrcaCloudSession(
config.config,
active,
this.userDataPath,
async (session) => {
const auth = storedSessionAuthContext(active, apiUrl, this.userDataPath)
const value = await operation(session.accessToken, apiUrl, auth)
auth.assertCurrent()
return value
}
)
return result.status === 'ok'
? { status: 'ok', value: result.value }
: { status: 'reconnect-required' }
}
}
function writeBody(request: ArtifactWriteRequest): Record<string, string> {
return {
content: request.content,
contentType: request.contentType,
fileName: request.fileName,
...(request.title ? { title: request.title } : {})
}
}
async function artifactRequest<T>(
apiUrl: string,
token: string,
path: string,
options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
): Promise<T> {
const response = await fetch(`${apiUrl}/v1/artifacts${path}`, {
method: options.method ?? 'GET',
headers: {
authorization: `Bearer ${token}`,
...(options.editToken ? { 'x-orca-edit-token': options.editToken } : {}),
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
...(options.body ? { 'content-type': 'application/json' } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined,
redirect: 'error',
signal: AbortSignal.timeout(20_000)
})
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { code?: string } | null
throw new OrcaCloudRequestError(response.status, body?.code)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}

View File

@ -0,0 +1,257 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
captureArtifactShareLifecycle,
clearArtifactShareRecords,
getArtifactShareRecord,
isArtifactShareLifecycleCurrent,
refreshArtifactShareRecordExpiration,
removeArtifactShareRecords,
saveArtifactShareRecord,
type ArtifactShareScope
} from './artifact-share-record-store'
const createdPaths: string[] = []
const scopeA: ArtifactShareScope = {
cloudUserId: 'user-a',
cloudProfileId: 'cloud-a',
cloudOrganizationId: 'org-a',
apiOrigin: 'https://share.onorca.dev'
}
async function userDataPath(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-records-'))
createdPaths.push(path)
return path
}
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('artifact share record store', () => {
it('isolates edit tokens by cloud identity and API origin', async () => {
const path = await userDataPath()
saveArtifactShareRecord('local-profile', path, '/repo/report.html', {
...scopeA,
slug: 'artifact-a',
editToken: 'secret-a',
shareUrl: 'https://share.onorca.dev/a/artifact-a'
})
expect(
getArtifactShareRecord('local-profile', path, '/repo/report.html', scopeA)?.editToken
).toBe('secret-a')
expect(
getArtifactShareRecord('local-profile', path, '/repo/report.html', {
...scopeA,
cloudUserId: 'user-b'
})
).toBeNull()
expect(
getArtifactShareRecord('local-profile', path, '/repo/report.html', {
...scopeA,
cloudOrganizationId: 'org-b'
})
).toBeNull()
expect(
getArtifactShareRecord('local-profile', path, '/repo/report.html', {
...scopeA,
apiOrigin: 'http://localhost:3000'
})
).toBeNull()
})
it('removes every source mapping for a deleted slug in the matching scope', async () => {
const path = await userDataPath()
for (const sourceKey of ['/repo/report.html', '/repo/report-copy.html']) {
saveArtifactShareRecord('local-profile', path, sourceKey, {
...scopeA,
slug: 'artifact-a',
editToken: 'secret-a',
shareUrl: 'https://share.onorca.dev/a/artifact-a'
})
}
removeArtifactShareRecords('local-profile', path, scopeA, { slug: 'artifact-a' })
expect(getArtifactShareRecord('local-profile', path, '/repo/report.html', scopeA)).toBeNull()
expect(
getArtifactShareRecord('local-profile', path, '/repo/report-copy.html', scopeA)
).toBeNull()
})
it('discards unscoped version-one records instead of assigning them to a new login', async () => {
const path = await userDataPath()
clearArtifactShareRecords('local-profile', path)
const recordsPath = join(path, 'profiles', 'local-profile', 'artifact-shares.json')
await writeFile(
recordsPath,
JSON.stringify({
version: 1,
shares: {
'/repo/report.html': {
slug: 'artifact-a',
editToken: 'legacy-secret',
shareUrl: 'https://share.onorca.dev/a/artifact-a'
}
}
})
)
expect(getArtifactShareRecord('local-profile', path, '/repo/report.html', scopeA)).toBeNull()
expect(await readFile(recordsPath, 'utf8')).toContain('legacy-secret')
})
it('prunes expired records on read', async () => {
const path = await userDataPath()
clearArtifactShareRecords('local-profile', path)
const recordsPath = join(path, 'profiles', 'local-profile', 'artifact-shares.json')
await writeFile(
recordsPath,
JSON.stringify({
version: 2,
lifecycleGeneration: 0,
shares: {
'/repo/report.html': {
...scopeA,
slug: 'artifact-a',
editToken: 'expired-secret',
shareUrl: 'https://share.onorca.dev/a/artifact-a',
expiresAt: '2020-01-01T00:00:00.000Z',
savedAt: 1
}
}
})
)
expect(getArtifactShareRecord('local-profile', path, '/repo/report.html', scopeA)).toBeNull()
const persisted = JSON.parse(await readFile(recordsPath, 'utf8')) as { shares: object }
expect(persisted.shares).toEqual({})
})
it('caps records deterministically at ten thousand', async () => {
const path = await userDataPath()
clearArtifactShareRecords('local-profile', path)
const recordsPath = join(path, 'profiles', 'local-profile', 'artifact-shares.json')
const shares = Object.fromEntries(
Array.from({ length: 10_001 }, (_, index) => [
`/repo/report-${String(index).padStart(5, '0')}.html`,
{
...scopeA,
slug: `artifact-${index}`,
editToken: `secret-${index}`,
shareUrl: `https://share.onorca.dev/a/artifact-${index}`,
expiresAt: '2099-01-01T00:00:00.000Z',
savedAt: index
}
])
)
await writeFile(recordsPath, JSON.stringify({ version: 2, lifecycleGeneration: 0, shares }))
expect(
getArtifactShareRecord('local-profile', path, '/repo/report-10000.html', scopeA)?.editToken
).toBe('secret-10000')
expect(
getArtifactShareRecord('local-profile', path, '/repo/report-00000.html', scopeA)
).toBeNull()
const persisted = JSON.parse(await readFile(recordsPath, 'utf8')) as {
shares: Record<string, unknown>
}
expect(Object.keys(persisted.shares)).toHaveLength(10_000)
})
it('keeps usable legacy version-two records without timestamps', async () => {
const path = await userDataPath()
clearArtifactShareRecords('local-profile', path)
const recordsPath = join(path, 'profiles', 'local-profile', 'artifact-shares.json')
await writeFile(
recordsPath,
JSON.stringify({
version: 2,
lifecycleGeneration: 0,
shares: {
'/repo/legacy.html': {
cloudUserId: scopeA.cloudUserId,
cloudProfileId: scopeA.cloudProfileId,
apiOrigin: scopeA.apiOrigin,
slug: 'legacy-artifact',
editToken: 'legacy-secret',
shareUrl: 'https://share.onorca.dev/a/legacy-artifact'
}
}
})
)
expect(
getArtifactShareRecord('local-profile', path, '/repo/legacy.html', scopeA)?.editToken
).toBe('legacy-secret')
refreshArtifactShareRecordExpiration(
'local-profile',
path,
'/repo/legacy.html',
scopeA,
{ slug: 'legacy-artifact', editToken: 'legacy-secret' },
'2099-01-01T00:00:00.000Z'
)
saveArtifactShareRecord('local-profile', path, '/repo/new.html', {
...scopeA,
slug: 'new-artifact',
editToken: 'new-secret',
shareUrl: 'https://share.onorca.dev/a/new-artifact',
expiresAt: '2099-01-01T00:00:00.000Z'
})
const persisted = JSON.parse(await readFile(recordsPath, 'utf8')) as {
shares: Record<string, { cloudOrganizationId?: string; savedAt?: number }>
}
expect(persisted.shares['/repo/legacy.html']?.cloudOrganizationId).toBe('org-a')
expect(persisted.shares['/repo/legacy.html']?.savedAt).toEqual(expect.any(Number))
expect(persisted.shares['/repo/new.html']?.savedAt).toEqual(expect.any(Number))
expect(
getArtifactShareRecord('local-profile', path, '/repo/legacy.html', {
...scopeA,
cloudOrganizationId: 'org-b'
})
).toBeNull()
})
it('refuses to overwrite an unreadable existing record file', async () => {
const path = await userDataPath()
clearArtifactShareRecords('local-profile', path)
const recordsPath = join(path, 'profiles', 'local-profile', 'artifact-shares.json')
await writeFile(recordsPath, '{broken-json')
expect(() =>
saveArtifactShareRecord('local-profile', path, '/repo/new.html', {
...scopeA,
slug: 'new-artifact',
editToken: 'new-secret',
shareUrl: 'https://share.onorca.dev/a/new-artifact',
expiresAt: '2099-01-01T00:00:00.000Z'
})
).toThrow(/could not be read safely/)
expect(await readFile(recordsPath, 'utf8')).toBe('{broken-json')
})
it('clears an unreadable file and invalidates in-flight writes', async () => {
const path = await userDataPath()
clearArtifactShareRecords('local-profile', path)
const lifecycle = captureArtifactShareLifecycle('local-profile', path)
const recordsPath = join(path, 'profiles', 'local-profile', 'artifact-shares.json')
await writeFile(recordsPath, '{broken-json')
expect(() => clearArtifactShareRecords('local-profile', path)).not.toThrow()
expect(isArtifactShareLifecycleCurrent('local-profile', path, lifecycle)).toBe(false)
const persisted = JSON.parse(await readFile(recordsPath, 'utf8')) as {
lifecycleNonce?: string
shares: object
}
expect(persisted.lifecycleNonce).toMatch(/^[0-9a-f-]{36}$/)
expect(persisted.shares).toEqual({})
})
})

View File

@ -0,0 +1,260 @@
import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { writeSecureJsonFile } from '../../shared/secure-file'
import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths'
export type ArtifactShareScope = {
cloudUserId: string
cloudProfileId: string
cloudOrganizationId: string
apiOrigin: string
}
type ArtifactShareRecord = Omit<ArtifactShareScope, 'cloudOrganizationId'> & {
cloudOrganizationId?: string
slug: string
editToken: string
shareUrl: string
expiresAt?: string
savedAt?: number
}
type ArtifactShareRecordFile = {
version: 2
lifecycleGeneration: number
lifecycleNonce: string
shares: Record<string, ArtifactShareRecord>
}
type ParsedArtifactShareRecordFile = {
version?: unknown
lifecycleGeneration?: unknown
lifecycleNonce?: unknown
shares?: unknown
}
const MAX_ARTIFACT_SHARE_RECORDS = 10_000
function recordPath(profileId: string, userDataPath: string): string {
return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-shares.json')
}
function isRecord(value: unknown): value is ArtifactShareRecord {
if (!value || typeof value !== 'object') {
return false
}
const record = value as Partial<ArtifactShareRecord>
const requiredFieldsValid = [
record.slug,
record.editToken,
record.shareUrl,
record.cloudUserId,
record.cloudProfileId,
record.apiOrigin
].every((field) => typeof field === 'string' && field.length > 0)
const expiresAtValid =
record.expiresAt === undefined ||
(typeof record.expiresAt === 'string' && Number.isFinite(Date.parse(record.expiresAt)))
const savedAtValid =
record.savedAt === undefined ||
(typeof record.savedAt === 'number' &&
Number.isSafeInteger(record.savedAt) &&
record.savedAt >= 0)
return requiredFieldsValid && expiresAtValid && savedAtValid
}
function compareRecordsNewestFirst(
[sourceKeyA, recordA]: [string, ArtifactShareRecord],
[sourceKeyB, recordB]: [string, ArtifactShareRecord]
): number {
const savedAtDifference = (recordB.savedAt ?? -1) - (recordA.savedAt ?? -1)
if (savedAtDifference !== 0) {
return savedAtDifference
}
return sourceKeyA < sourceKeyB ? -1 : sourceKeyA > sourceKeyB ? 1 : 0
}
function pruneRecords(
shares: Record<string, ArtifactShareRecord>,
now: number,
preserveExpired?: { sourceKey: string; slug: string; editToken: string }
): { shares: Record<string, ArtifactShareRecord>; changed: boolean } {
const currentEntries = Object.entries(shares)
const unexpired = currentEntries.filter(
([sourceKey, record]) =>
(preserveExpired?.sourceKey === sourceKey &&
preserveExpired.slug === record.slug &&
preserveExpired.editToken === record.editToken) ||
record.expiresAt === undefined ||
Date.parse(record.expiresAt) > now
)
const retained =
unexpired.length > MAX_ARTIFACT_SHARE_RECORDS
? unexpired.sort(compareRecordsNewestFirst).slice(0, MAX_ARTIFACT_SHARE_RECORDS)
: unexpired
return {
shares: Object.fromEntries(retained),
changed: retained.length !== currentEntries.length
}
}
function readRecords(
profileId: string,
userDataPath: string,
preserveExpired?: { sourceKey: string; slug: string; editToken: string },
pruneExpired = true
): ArtifactShareRecordFile {
const path = recordPath(profileId, userDataPath)
if (!existsSync(path)) {
return { version: 2, lifecycleGeneration: 0, lifecycleNonce: '', shares: {} }
}
let parsed: ParsedArtifactShareRecordFile
try {
parsed = JSON.parse(readFileSync(path, 'utf8')) as ParsedArtifactShareRecordFile
} catch (error) {
throw new Error('Artifact share records could not be read safely.', { cause: error })
}
if (parsed.version === 1) {
return { version: 2, lifecycleGeneration: 0, lifecycleNonce: '', shares: {} }
}
if (
parsed.version !== 2 ||
!parsed.shares ||
typeof parsed.shares !== 'object' ||
Array.isArray(parsed.shares)
) {
throw new Error('Artifact share records have an unsupported format.')
}
const shareEntries = Object.entries(parsed.shares as Record<string, unknown>)
const validShares = Object.fromEntries(
shareEntries.filter((entry): entry is [string, ArtifactShareRecord] => isRecord(entry[1]))
)
const pruned = pruneExpired
? pruneRecords(validShares, Date.now(), preserveExpired)
: { shares: validShares, changed: false }
const records: ArtifactShareRecordFile = {
version: 2,
lifecycleGeneration:
Number.isSafeInteger(parsed.lifecycleGeneration) && Number(parsed.lifecycleGeneration) >= 0
? Number(parsed.lifecycleGeneration)
: 0,
lifecycleNonce: typeof parsed.lifecycleNonce === 'string' ? parsed.lifecycleNonce : '',
shares: pruned.shares
}
if (pruned.changed || shareEntries.length !== Object.keys(validShares).length) {
writeSecureJsonFile(path, records)
}
return records
}
function matchesScope(record: ArtifactShareRecord, scope: ArtifactShareScope): boolean {
return (
matchesScopeIdentity(record, scope) &&
(record.cloudOrganizationId === undefined ||
record.cloudOrganizationId === scope.cloudOrganizationId)
)
}
function matchesScopeIdentity(record: ArtifactShareRecord, scope: ArtifactShareScope): boolean {
return (
record.cloudUserId === scope.cloudUserId &&
record.cloudProfileId === scope.cloudProfileId &&
record.apiOrigin === scope.apiOrigin
)
}
export function getArtifactShareRecord(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope
): ArtifactShareRecord | null {
const record = readRecords(profileId, userDataPath).shares[sourceKey]
return record && matchesScope(record, scope) ? record : null
}
export function saveArtifactShareRecord(
profileId: string,
userDataPath: string,
sourceKey: string,
record: ArtifactShareRecord
): void {
const records = readRecords(profileId, userDataPath)
records.shares[sourceKey] = { ...record, savedAt: Date.now() }
records.shares = pruneRecords(records.shares, Date.now()).shares
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
}
export function refreshArtifactShareRecordExpiration(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope,
expected: { slug: string; editToken: string },
expiresAt: string
): void {
const records = readRecords(profileId, userDataPath, { sourceKey, ...expected })
const current = records.shares[sourceKey]
if (
!current ||
!matchesScope(current, scope) ||
current.slug !== expected.slug ||
current.editToken !== expected.editToken
) {
return
}
records.shares[sourceKey] = {
...current,
cloudOrganizationId: scope.cloudOrganizationId,
expiresAt,
savedAt: Date.now()
}
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
}
export function removeArtifactShareRecords(
profileId: string,
userDataPath: string,
scope: ArtifactShareScope,
match: { sourceKey?: string; slug?: string }
): void {
const records = readRecords(profileId, userDataPath)
for (const [sourceKey, record] of Object.entries(records.shares)) {
if (
matchesScope(record, scope) &&
(match.slug === record.slug || (match.slug === undefined && match.sourceKey === sourceKey))
) {
delete records.shares[sourceKey]
}
}
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
}
export function clearArtifactShareRecords(profileId: string, userDataPath: string): void {
let lifecycleGeneration = 0
try {
lifecycleGeneration = readRecords(profileId, userDataPath, undefined, false).lifecycleGeneration
} catch {
// Clearing must recover sign-out from an unreadable token index.
}
writeSecureJsonFile(recordPath(profileId, userDataPath), {
version: 2,
lifecycleGeneration: lifecycleGeneration + 1,
lifecycleNonce: randomUUID(),
shares: {}
})
}
export function captureArtifactShareLifecycle(profileId: string, userDataPath: string): string {
const records = readRecords(profileId, userDataPath, undefined, false)
return `${records.lifecycleGeneration}:${records.lifecycleNonce}`
}
export function isArtifactShareLifecycleCurrent(
profileId: string,
userDataPath: string,
lifecycle: string
): boolean {
return captureArtifactShareLifecycle(profileId, userDataPath) === lifecycle
}

View File

@ -15,6 +15,7 @@ import { describe, expect, it } from 'vitest'
// and update the count.
const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
// HTTP call sites — body consumed or cancelled on every path, including !ok
['main/artifacts/artifact-cloud-service.ts', 1],
['main/azure-devops/azure-devops-api-request.ts', 1],
['main/bitbucket/client.ts', 1],
['main/gitea/client.ts', 1],

View File

@ -67,6 +67,7 @@ import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-cl
import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime'
import { ArtifactCloudService } from './artifacts/artifact-cloud-service'
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
import {
fingerprintOrchestrationPeer,
@ -2557,6 +2558,7 @@ void app.whenReady().then(async () => {
: undefined
})
runtimeService.setAutomationService(automations)
runtimeService.setArtifactService(new ArtifactCloudService(app.getPath('userData')))
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtimeService.setCommitMessageAgentEnvironmentResolvers({
// Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home.

View File

@ -4222,6 +4222,24 @@ describe('registerPtyHandlers', () => {
}
})
it('prepends the bundled CLI dir to PATH for packaged macOS spawns', async () => {
const resourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath')
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: '/tmp/orca-resources'
})
try {
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' })
expect(env.PATH.split(delimiter)[0]).toBe(join('/tmp/orca-resources', 'bin'))
} finally {
if (resourcesPathDescriptor) {
Object.defineProperty(process, 'resourcesPath', resourcesPathDescriptor)
} else {
Reflect.deleteProperty(process, 'resourcesPath')
}
}
})
it('injects the agent-hook receiver env on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')

View File

@ -1100,6 +1100,7 @@ function finishPtyShutdown(
export type BuildPtyHostEnvOptions = {
isPackaged: boolean
resourcesPath?: string
userDataPath: string
selectedCodexHomePath: string | null
skipCodexHomeEnv?: boolean
@ -1811,6 +1812,16 @@ export function buildPtyHostEnv(
.filter((entry) => entry.length > 0 && entry !== shimDir)
baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter)
}
} else if (
opts.resourcesPath &&
(process.platform === 'darwin' || process.platform === 'win32')
) {
// Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI.
const bundledCliBin = join(opts.resourcesPath, 'bin')
const inheritedPath = readInheritedPath(baseEnv)
baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath
? `${bundledCliBin}${delimiter}${inheritedPath}`
: bundledCliBin
}
// Why: PATH shims keep GitHub attribution scoped to Orca's own PTYs without rewriting user git config.
@ -2365,6 +2376,7 @@ export function registerPtyHandlers(
const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath
const env = buildPtyHostEnv(id, baseEnv, {
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
userDataPath: app.getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv,
@ -4574,6 +4586,7 @@ export function registerPtyHandlers(
}
env = buildPtyHostEnv(sessionId, env ?? {}, {
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
userDataPath: app.getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv,
@ -6070,6 +6083,7 @@ export function registerPtyHandlers(
try {
buildPtyHostEnv(sessionIdForEnv, env, {
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
userDataPath: app.getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv,

View File

@ -11,6 +11,7 @@ import {
loadOrCreateProfileIndex,
writeProfileIndex
} from './profile-index-store'
import { clearArtifactShareRecords } from '../artifacts/artifact-share-record-store'
export type CreateCloudLinkedOrcaProfileRecordResult = OrcaProfileListState & {
profile: OrcaProfileSummary
@ -93,16 +94,25 @@ export function linkOrcaProfileToCloud(
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
let found = false
let cloudIdentityChanged = false
const profiles = index.profiles.map((profile) => {
if (profile.id !== profileId) {
return profile
}
found = true
cloudIdentityChanged = Boolean(
profile.cloud &&
(profile.cloud.userId !== cloud.userId ||
profile.cloud.cloudProfileId !== cloud.cloudProfileId)
)
return toCloudLinkedProfile(profile, cloud, now)
})
if (!found) {
throw new Error('unknown_orca_profile')
}
if (cloudIdentityChanged) {
clearArtifactShareRecords(profileId, userDataPath)
}
const nextIndex = {
...index,
profiles
@ -131,6 +141,7 @@ export function unlinkOrcaProfileFromCloud(
if (!found) {
throw new Error('unknown_orca_profile')
}
clearArtifactShareRecords(profileId, userDataPath)
const nextIndex = {
...index,
profiles

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy'
describe('shouldForwardHeadlessTerminalQueryReply', () => {
const xtVersion = '\x1bP>|xterm.js(6.1.0-beta.287)\x1b\\'
it('suppresses XTVERSION for a hidden Grok terminal', () => {
expect(shouldForwardHeadlessTerminalQueryReply('grok', xtVersion)).toBe(false)
})
it('keeps other Grok terminal query replies', () => {
expect(shouldForwardHeadlessTerminalQueryReply('grok', '\x1b[?1;2c')).toBe(true)
})
it('keeps XTVERSION replies for other agents', () => {
expect(shouldForwardHeadlessTerminalQueryReply('codex', xtVersion)).toBe(true)
})
})

View File

@ -0,0 +1,12 @@
import type { TuiAgent } from '../../shared/types'
/* oxlint-disable no-control-regex -- XTVERSION replies are DCS control sequences. */
const XTVERSION_REPLY = new RegExp('^\u001bP>\\|[^\u001b]*\u001b\\\\$')
/* oxlint-enable no-control-regex */
export function shouldForwardHeadlessTerminalQueryReply(
launchAgent: TuiAgent | null | undefined,
reply: string
): boolean {
return launchAgent !== 'grok' || !XTVERSION_REPLY.test(reply)
}

View File

@ -119,7 +119,17 @@ import {
createSetupCompletionScanner
} from './orchestration/setup-completion-signal'
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
import type {
ArtifactCloudOperation,
ArtifactCloudOptions,
ArtifactListOptions,
ArtifactListPage,
ArtifactListItem,
ArtifactWriteRequest
} from '../../shared/artifacts'
import type { ArtifactCloudService } from '../artifacts/artifact-cloud-service'
import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../shared/orchestration-message-wait-timeout'
import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy'
import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity'
import type {
OrchestrationCompatibilityEvidence,
@ -3346,6 +3356,7 @@ export class OrcaRuntimeService {
private accountServices: RuntimeAccountServices | null = null
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
private automationService: AutomationService | null = null
private artifactService: ArtifactCloudService | null = null
private readonly claudeAgentTeams = new ClaudeAgentTeamsService()
private mobileDictation: {
id: string
@ -4614,6 +4625,39 @@ export class OrcaRuntimeService {
this.automationService = service
}
setArtifactService(service: ArtifactCloudService): void {
this.artifactService = service
}
listArtifacts(options: ArtifactListOptions): Promise<ArtifactCloudOperation<ArtifactListPage>> {
return this.requireArtifactService().list(options)
}
shareArtifact(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
return this.requireArtifactService().share(request)
}
updateArtifact(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
return this.requireArtifactService().update(request)
}
unshareArtifact(
request: ArtifactCloudOptions & { sourceKey: string }
): Promise<ArtifactCloudOperation<void>> {
return this.requireArtifactService().unshare(request)
}
deleteArtifact(id: string, options: ArtifactCloudOptions): Promise<ArtifactCloudOperation<void>> {
return this.requireArtifactService().delete(id, options)
}
private requireArtifactService(): ArtifactCloudService {
if (!this.artifactService) {
throw new Error('Artifact service is unavailable.')
}
return this.artifactService
}
getRuntimeId(): string {
return this.runtimeId
}
@ -11451,6 +11495,11 @@ export class OrcaRuntimeService {
// disposeHeadlessTerminal, and daemon respawns reuse session ids — a
// stale link's reply must never reach a successor PTY under this id.
if (state !== null && this.headlessTerminals.get(ptyId) === state) {
if (
!shouldForwardHeadlessTerminalQueryReply(this.ptysById.get(ptyId)?.launchAgent, reply)
) {
return
}
// Why this write is safe pre-shell-ready: daemon Session.write
// QUEUES (never drops) input while the POSIX shell-ready gate is
// pending and flushes at the ready marker or the 15s

View File

@ -0,0 +1,49 @@
import { z } from 'zod'
import { defineMethod, type RpcAnyMethod } from '../core'
const CloudOptions = {
apiUrl: z.string().optional(),
authToken: z.string().optional()
}
const ListOptions = z.object({
...CloudOptions,
cursor: z.string().min(1).max(2_048).optional()
})
const WriteRequest = z.object({
sourceKey: z.string().min(1),
content: z.string().min(1),
contentType: z.enum(['text/html', 'text/markdown']),
fileName: z.string().min(1),
title: z.string().optional(),
...CloudOptions
})
export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
defineMethod({
name: 'artifacts.list',
params: ListOptions,
handler: (params, { runtime }) => runtime.listArtifacts(params)
}),
defineMethod({
name: 'artifacts.share',
params: WriteRequest,
handler: (params, { runtime }) => runtime.shareArtifact(params)
}),
defineMethod({
name: 'artifacts.update',
params: WriteRequest,
handler: (params, { runtime }) => runtime.updateArtifact(params)
}),
defineMethod({
name: 'artifacts.unshare',
params: z.object({ sourceKey: z.string().min(1), ...CloudOptions }),
handler: (params, { runtime }) => runtime.unshareArtifact(params)
}),
defineMethod({
name: 'artifacts.delete',
params: z.object({ id: z.string().min(1), ...CloudOptions }),
handler: (params, { runtime }) => runtime.deleteArtifact(params.id, params)
})
]

View File

@ -176,25 +176,23 @@ export const SettingsUpdate = z
.strict()
.default({})
const TopLevelViewSchema = z.enum([
'terminal',
'settings',
'tasks',
'activity',
'automations',
'space',
'skills',
'artifacts',
'mobile'
])
const UiUpdateFields = z
.object({
lastActiveRepoId: NullableString.optional(),
lastActiveWorktreeId: NullableString.optional(),
// Why: App.tsx persists this on every top-level view switch (#9002). Desktop
// hydration ignores it on 'sync' broadcasts, so accepting it cannot yank a
// paired window's current view — it only restores the view on next startup.
activeView: z
.enum([
'terminal',
'settings',
'tasks',
'activity',
'automations',
'space',
'skills',
'mobile'
])
.optional(),
// Why: sync hydration ignores this persisted startup view, so paired windows stay put.
activeView: TopLevelViewSchema.optional(),
sidebarWidth: z.number().finite().optional(),
rightSidebarOpen: z.boolean().optional(),
rightSidebarTab: RightSidebarTabParam.optional(),

View File

@ -39,6 +39,7 @@ import { EMULATOR_METHODS } from './emulator'
import { PAIRING_METHODS } from './pairing'
import { UPDATER_METHODS } from './updater'
import { AGENT_SESSION_METHODS } from './agent-session'
import { ARTIFACT_METHODS } from './artifacts'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@ -46,6 +47,7 @@ import { AGENT_SESSION_METHODS } from './agent-session'
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...STATUS_METHODS,
...AI_VAULT_METHODS,
...ARTIFACT_METHODS,
...AUTOMATION_METHODS,
...REPO_METHODS,
...WORKTREE_METHODS,

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import type * as NodeCrypto from 'node:crypto'
import { SshRelaySession } from './ssh-relay-session'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
type MockMuxInstance = {
@ -376,12 +377,33 @@ describe('SshRelaySession reconnect incarnation ordering', () => {
const winningCliHandler = muxInstances[2]?.requestHandlers.get('orca.cli')
expect(winningCliHandler).toBeDefined()
await winningCliHandler?.({ argv: ['status'], cwd: '/', env: {} })
await winningCliHandler?.({
argv: ['artifacts', 'share', 'report.html'],
cwd: '/srv/repo',
env: {},
stdin: '<h1>Remote</h1>',
artifactInput: {
sourceKey: '/srv/repo/report.html',
fileName: 'report.html',
contentType: 'text/html'
}
})
expect(runtime.registerOrchestrationCompatibilitySshAttachment).toHaveBeenCalledWith(
'target-1',
winningIncarnation
)
expect(vi.mocked(runRemoteOrcaCli)).toHaveBeenCalledWith(
runtime,
expect.objectContaining({
stdin: '<h1>Remote</h1>',
artifactInput: {
sourceKey: '/srv/repo/report.html',
fileName: 'report.html',
contentType: 'text/html'
}
})
)
expect(randomUUID).toHaveBeenCalledTimes(3)
})

View File

@ -76,6 +76,7 @@ import {
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS,
SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD
} from '../../shared/ssh-types'
import { normalizeRemoteArtifactInput } from '../../shared/artifact-cli-bridge'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract'
@ -1333,6 +1334,7 @@ export class SshRelaySession {
)
: {}
const stdin = typeof params.stdin === 'string' ? params.stdin : undefined
const artifactInput = normalizeRemoteArtifactInput(params.artifactInput)
const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment(
this.targetId,
connectionIncarnation
@ -1344,6 +1346,7 @@ export class SshRelaySession {
cwd,
env,
...(stdin !== undefined ? { stdin } : {}),
...(artifactInput ? { artifactInput } : {}),
runtimeAuthority
})
} finally {

View File

@ -28,6 +28,7 @@ import {
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
} from '../../shared/orchestration-compatibility-evidence'
import { REMOTE_ARTIFACT_INPUT_ENV } from '../../shared/artifact-cli-bridge'
type FakeChild = EventEmitter & {
stdout: EventEmitter
@ -109,6 +110,35 @@ describe('buildHostCliEnv', () => {
expect(env.NODE_OPTIONS).toBeUndefined()
expect(env.ORCA_NODE_OPTIONS).toBe('--inspect')
})
it('namespaces identical remote artifact paths by stable SSH target', () => {
const artifactInput = {
sourceKey: '/srv/repo/report.html',
fileName: 'report.html',
contentType: 'text/html' as const
}
const build = (targetId: string) =>
buildHostCliEnv({
hostEnv: {},
remoteEnv: {},
userDataPath: '/host/user-data',
remoteCwd: '/srv/repo',
runtimeAuthority: {
kind: 'ssh',
targetId,
connectionIncarnation: 'ephemeral-connection',
attachmentId: 'ephemeral-attachment'
},
artifactInput
})[REMOTE_ARTIFACT_INPUT_ENV]
const first = JSON.parse(String(build('host-a')))
const second = JSON.parse(String(build('host-b')))
expect(first.sourceKey).not.toBe(second.sourceKey)
expect(first.sourceKey).toContain('host-a')
expect(second.sourceKey).toContain('host-b')
expect(first.fileName).toBe('report.html')
})
})
describe('resolveHostCliKillTimeoutMs', () => {

View File

@ -23,6 +23,10 @@ import {
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
} from '../../shared/orchestration-compatibility-evidence'
import {
REMOTE_ARTIFACT_INPUT_ENV,
type RemoteArtifactInput
} from '../../shared/artifact-cli-bridge'
export type SshCliRuntimeAuthority = {
kind: 'ssh'
@ -36,6 +40,7 @@ export type RemoteOrcaCliRequest = {
cwd: string
env: Record<string, string>
stdin?: string
artifactInput?: RemoteArtifactInput
runtimeAuthority?: SshCliRuntimeAuthority
}
@ -137,6 +142,7 @@ export function buildHostCliEnv(args: {
userDataPath: string
remoteCwd: string
runtimeAuthority?: SshCliRuntimeAuthority
artifactInput?: RemoteArtifactInput
}): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...args.hostEnv }
for (const key of REMOTE_CONTEXT_ENV_VARS) {
@ -162,6 +168,7 @@ export function buildHostCliEnv(args: {
delete env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]
delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]
delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]
delete env[REMOTE_ARTIFACT_INPUT_ENV]
if (args.runtimeAuthority) {
env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh'
env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId
@ -169,6 +176,12 @@ export function buildHostCliEnv(args: {
args.runtimeAuthority.connectionIncarnation
env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] = args.runtimeAuthority.attachmentId
}
if (args.artifactInput) {
const sourceKey = args.runtimeAuthority
? JSON.stringify(['ssh', args.runtimeAuthority.targetId, args.artifactInput.sourceKey])
: args.artifactInput.sourceKey
env[REMOTE_ARTIFACT_INPUT_ENV] = JSON.stringify({ ...args.artifactInput, sourceKey })
}
env.ELECTRON_RUN_AS_NODE = '1'
return env
}
@ -220,7 +233,8 @@ export async function runHostOrcaCliPassthrough(
remoteEnv: request.env,
userDataPath,
remoteCwd: request.cwd,
runtimeAuthority: request.runtimeAuthority
runtimeAuthority: request.runtimeAuthority,
artifactInput: request.artifactInput
})
return await new Promise<RemoteOrcaCliResult>((resolve, reject) => {

View File

@ -28,4 +28,29 @@ describe('RelayDispatcher request timeout validation', () => {
expect(writes).toHaveLength(0)
}
)
it('rejects an oversized forwarded request without closing the relay client', async () => {
let closes = 0
dispatcher.dispose()
dispatcher = new RelayDispatcher(
(data) => {
writes.push(Buffer.from(data))
},
{
close: () => {
closes += 1
}
}
)
await expect(
dispatcher.requestPrimary(
'orca.cli',
{ stdin: '\\'.repeat(600 * 1024) },
{ timeoutMs: 1_000 }
)
).rejects.toThrow(/exceeded the relay control transport capacity/)
expect(writes).toHaveLength(0)
expect(closes).toBe(0)
})
})

View File

@ -801,7 +801,11 @@ export class RelayDispatcher {
reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingRelayRequests.set(id, { resolve, reject, timer })
this.enqueueFrame(client, msg, 'control')
if (!this.enqueueFrame(client, msg, 'control', () => {}, undefined, 'reject')) {
clearTimeout(timer)
this.pendingRelayRequests.delete(id)
reject(new Error(`Request "${method}" exceeded the relay control transport capacity`))
}
})
}

View File

@ -68,6 +68,11 @@ import {
import { relayLogLine } from './relay-diagnostic-log'
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input'
import {
assertRemoteArtifactCliForwardingFits,
type RemoteArtifactCliForwardingParams
} from './remote-artifact-cli-forwarding'
import { registerManagedHookInstaller } from './managed-hook-installer'
import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler'
import { DispatcherClientWriter } from './dispatcher-client-writer'
@ -302,7 +307,34 @@ async function runOrcaCliMode(
endpointCredential?: string
): Promise<void> {
const myVersion = readLaunchVersion()
const stdin = shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined
let preparedArtifact: Awaited<ReturnType<typeof prepareRemoteArtifactCliInput>>
try {
preparedArtifact = await prepareRemoteArtifactCliInput(argv, process.cwd())
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
return
}
const stdin =
preparedArtifact.stdin ??
(shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined)
const env = pickRemoteCliEnv(process.env)
const requestParams: RemoteArtifactCliForwardingParams = {
argv,
cwd: process.cwd(),
env,
...(stdin !== undefined ? { stdin } : {}),
...(preparedArtifact.artifactInput ? { artifactInput: preparedArtifact.artifactInput } : {})
}
if (preparedArtifact.artifactInput) {
try {
assertRemoteArtifactCliForwardingFits(requestParams)
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
return
}
}
const sock = createConnection({ path: sockPath })
const stdoutWriter = new DispatcherClientWriter(
(data, onSettled) =>
@ -327,18 +359,12 @@ async function runOrcaCliMode(
let initialExitCode = 0
const sendRequest = (): void => {
const env = pickRemoteCliEnv(process.env)
const frame = encodeJsonRpcFrame(
{
jsonrpc: '2.0',
id: requestId,
method: 'orca.cli',
params: {
argv,
cwd: process.cwd(),
env,
...(stdin !== undefined ? { stdin } : {})
}
params: requestParams
},
nextSeq++,
highestReceivedSeq

View File

@ -0,0 +1,39 @@
import type { RemoteArtifactInput } from '../shared/artifact-cli-bridge'
import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission'
import { encodeJsonRpcFrame } from './protocol'
export type RemoteArtifactCliForwardingParams = {
argv: string[]
cwd: string
env: Record<string, string>
stdin?: string
artifactInput?: RemoteArtifactInput
}
// Why: the daemon assigns the forwarded request id, so size against its widest safe integer.
const FORWARDED_REQUEST_SIZE_ID = Number.MAX_SAFE_INTEGER
export function remoteArtifactCliForwardingFrameBytes(
params: RemoteArtifactCliForwardingParams
): number {
return encodeJsonRpcFrame(
{
jsonrpc: '2.0',
id: FORWARDED_REQUEST_SIZE_ID,
method: 'orca.cli',
params
},
0,
0
).length
}
export function assertRemoteArtifactCliForwardingFits(
params: RemoteArtifactCliForwardingParams
): void {
if (remoteArtifactCliForwardingFrameBytes(params) > DISPATCHER_CONTROL_QUEUE_MAX_BYTES) {
throw new Error(
'Artifact is too large for the Orca SSH transport. Use the browser upload page instead.'
)
}
}

View File

@ -0,0 +1,127 @@
import { mkdtemp, open, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../shared/artifacts'
import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input'
import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission'
import {
assertRemoteArtifactCliForwardingFits,
remoteArtifactCliForwardingFrameBytes,
type RemoteArtifactCliForwardingParams
} from './remote-artifact-cli-forwarding'
const createdPaths: string[] = []
async function remoteFolder(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-remote-artifact-'))
createdPaths.push(path)
return path
}
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('prepareRemoteArtifactCliInput', () => {
it('reads a folder-workspace file on the SSH host and preserves its source path', async () => {
const cwd = await remoteFolder()
await writeFile(join(cwd, 'report.md'), '# Remote report', 'utf8')
await expect(
prepareRemoteArtifactCliInput(['artifacts', 'share', 'report.md'], cwd)
).resolves.toEqual({
stdin: '# Remote report',
artifactInput: {
sourceKey: join(cwd, 'report.md'),
fileName: 'report.md',
contentType: 'text/markdown'
}
})
})
it('rejects a sparse oversized file from stat metadata before reading its contents', async () => {
const cwd = await remoteFolder()
const path = join(cwd, 'sparse.html')
const handle = await open(path, 'w')
await handle.truncate(ARTIFACT_CLI_MAX_RPC_BYTES + 1)
await handle.close()
await expect(
prepareRemoteArtifactCliInput(['artifacts', 'share', 'sparse.html'], cwd)
).rejects.toThrow(/too large/)
})
it('transfers source identity without reading content for unshare', async () => {
const cwd = await remoteFolder()
await expect(
prepareRemoteArtifactCliInput(['artifacts', 'unshare', 'missing.html'], cwd)
).resolves.toEqual({
artifactInput: { sourceKey: join(cwd, 'missing.html'), fileName: 'missing.html' }
})
})
})
function forwardingParams(stdin: string): RemoteArtifactCliForwardingParams {
return {
argv: ['artifacts', 'share', 'report.md'],
cwd: '/workspace',
env: { ORCA_WORKSPACE_ID: 'workspace-1' },
stdin,
artifactInput: {
sourceKey: '/workspace/report.md',
fileName: 'report.md',
contentType: 'text/markdown'
}
}
}
describe('remote artifact CLI forwarding admission', () => {
it.each([
['backslash', '\\'],
['quote', '"']
])('uses the complete control frame for the %s escape boundary', (_label, character) => {
const emptyBytes = remoteArtifactCliForwardingFrameBytes(forwardingParams(''))
const escapedCharacterBytes = Buffer.byteLength(JSON.stringify(character), 'utf8') - 2
const fittingCharacters = Math.floor(
(DISPATCHER_CONTROL_QUEUE_MAX_BYTES - emptyBytes) / escapedCharacterBytes
)
const fitting = forwardingParams(character.repeat(fittingCharacters))
const oversized = forwardingParams(character.repeat(fittingCharacters + 1))
expect(remoteArtifactCliForwardingFrameBytes(fitting)).toBeLessThanOrEqual(
DISPATCHER_CONTROL_QUEUE_MAX_BYTES
)
expect(remoteArtifactCliForwardingFrameBytes(oversized)).toBeGreaterThan(
DISPATCHER_CONTROL_QUEUE_MAX_BYTES
)
expect(() => assertRemoteArtifactCliForwardingFits(fitting)).not.toThrow()
expect(() => assertRemoteArtifactCliForwardingFits(oversized)).toThrow(
/too large for the Orca SSH transport/
)
})
it.each([600 * 1024, ARTIFACT_CLI_MAX_RPC_BYTES])(
'keeps an ordinary %i-byte artifact inside the control budget',
(bytes) => {
const params = forwardingParams('a'.repeat(bytes))
expect(remoteArtifactCliForwardingFrameBytes(params)).toBeLessThanOrEqual(
DISPATCHER_CONTROL_QUEUE_MAX_BYTES
)
expect(() => assertRemoteArtifactCliForwardingFits(params)).not.toThrow()
}
)
it('rejects an escaped artifact that is below the raw file limit', () => {
const params = forwardingParams('\\'.repeat(600 * 1024))
expect(Buffer.byteLength(params.stdin ?? '', 'utf8')).toBeLessThan(ARTIFACT_CLI_MAX_RPC_BYTES)
expect(() => assertRemoteArtifactCliForwardingFits(params)).toThrow(
/too large for the Orca SSH transport/
)
})
})

View File

@ -0,0 +1,95 @@
import { basename, extname, resolve } from 'node:path'
import type { RemoteArtifactInput } from '../shared/artifact-cli-bridge'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../shared/artifacts'
import { readArtifactFileWithinLimit } from '../shared/artifact-file-read'
import { MAX_MESSAGE_SIZE } from './protocol'
export type PreparedRemoteArtifactCliInput = {
stdin?: string
artifactInput?: RemoteArtifactInput
}
const BOOLEAN_FLAGS = new Set(['help', 'json'])
const VALUE_FLAGS = new Set(['api-url', 'environment', 'file', 'pairing-code'])
function parseArtifactInvocation(argv: string[]): { command: string; file?: string } | null {
const positionals: string[] = []
let file: string | undefined
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (!token.startsWith('--')) {
positionals.push(token)
continue
}
const assignment = token.slice(2)
const equalsIndex = assignment.indexOf('=')
const flag = equalsIndex === -1 ? assignment : assignment.slice(0, equalsIndex)
if (flag === 'file' && equalsIndex !== -1) {
file = assignment.slice(equalsIndex + 1)
continue
}
if (equalsIndex !== -1 || BOOLEAN_FLAGS.has(flag)) {
continue
}
if (VALUE_FLAGS.has(flag) && argv[index + 1] && !argv[index + 1].startsWith('--')) {
if (flag === 'file') {
file = argv[index + 1]
}
index += 1
}
}
if (positionals[0] !== 'artifacts' || !['share', 'update', 'unshare'].includes(positionals[1])) {
return null
}
return { command: positionals[1], file: file ?? positionals[2] }
}
function contentTypeForPath(path: string): NonNullable<RemoteArtifactInput['contentType']> | null {
const extension = extname(path).toLowerCase()
return ['.html', '.htm'].includes(extension)
? 'text/html'
: ['.md', '.markdown'].includes(extension)
? 'text/markdown'
: null
}
export async function prepareRemoteArtifactCliInput(
argv: string[],
cwd: string
): Promise<PreparedRemoteArtifactCliInput> {
const invocation = parseArtifactInvocation(argv)
if (!invocation || argv.includes('--help')) {
return {}
}
if (!invocation.file) {
return {}
}
const sourceKey = resolve(cwd, invocation.file)
if (invocation.command === 'unshare') {
return { artifactInput: { sourceKey, fileName: basename(sourceKey) } }
}
const contentType = contentTypeForPath(sourceKey)
if (!contentType) {
throw new Error('Artifacts must be HTML or Markdown files.')
}
const result = await readArtifactFileWithinLimit(sourceKey, ARTIFACT_CLI_MAX_RPC_BYTES)
if (result.status === 'not-file') {
throw new Error('Artifact file was not found or is not a file.')
}
if (result.status === 'too-large') {
throw new Error(
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
}
if (result.status === 'empty') {
throw new Error('Artifact file is empty.')
}
const prepared = {
stdin: result.content,
artifactInput: { sourceKey, fileName: basename(sourceKey), contentType }
}
if (Buffer.byteLength(JSON.stringify(prepared), 'utf8') > MAX_MESSAGE_SIZE - 64 * 1024) {
throw new Error('Artifact content exceeds the SSH relay message limit.')
}
return prepared
}

View File

@ -74,7 +74,6 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev
import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding'
import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker'
import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton'
import { OrcaProfileSwitcher } from './components/orca-profiles/OrcaProfileSwitcher'
import {
TOGGLE_FLOATING_TERMINAL_EVENT,
requestFloatingTerminalOpenMaximized
@ -340,6 +339,7 @@ const AutomationsPage = lazy(() => import('./components/automations/AutomationsP
const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage'))
const Settings = lazy(() => import('./components/settings/Settings'))
const SkillsPage = lazy(() => import('./components/skills/SkillsPage'))
const ArtifactsPage = lazy(() => import('./components/artifacts/ArtifactsPage'))
const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/WorkspaceSpacePage'))
const MobilePage = lazy(() => import('./components/mobile/MobilePage'))
const QuickOpen = lazy(() => import('./components/QuickOpen'))
@ -1530,9 +1530,6 @@ function App(): React.JSX.Element {
})
// Full-page navigation surfaces own the whole content area, so suppress right-sidebar controls.
const showRightSidebarControls = !creationLayoutActive && canShowRightSidebarForView(activeView)
const showProfileSwitcherInSidebarFooter = showSidebar && sidebarOpen
const showProfileSwitcherInTopRight = !showProfileSwitcherInSidebarFooter
const handleToggleExpand = (): void => {
if (!effectiveActiveTabId) {
return
@ -2235,33 +2232,12 @@ function App(): React.JSX.Element {
</TooltipContent>
</Tooltip>
)}
{showProfileSwitcherInTopRight ? <OrcaProfileSwitcher /> : null}
{/* Why: the open right sidebar's header renders its own close button, so hide this duplicate. */}
{!rightSidebarOpen && rightSidebarToggle}
{/* Why: reserve space so the Windows/Linux window-controls overlay doesn't obscure content. */}
{hasCustomTitleBar && <div className="window-controls-titlebar-spacer" />}
</>
)
const workspaceProfileSwitcher =
showProfileSwitcherInTopRight &&
workspaceChromeActive &&
leftTitlebarChromeLayout.shouldMount &&
!stackedSidebarOpen ? (
<div
className="absolute top-0 z-10 flex h-[36px] items-center"
style={
{
right: showRightSidebarControls
? 'calc(var(--window-controls-width) + 42px)'
: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
<OrcaProfileSwitcher />
</div>
) : null
return (
<div
ref={setAppRootNode}
@ -2398,7 +2374,6 @@ function App(): React.JSX.Element {
{rightSidebarToggle}
</div>
)}
{workspaceProfileSwitcher}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
{shouldMountTerminalWorkbench ? (
<div
@ -2440,6 +2415,7 @@ function App(): React.JSX.Element {
>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'artifacts' ? <ArtifactsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}

View File

@ -0,0 +1,75 @@
import { Copy, ExternalLink, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
type ArtifactActionsProps = {
deleting: boolean
item: ArtifactListItem
onDelete: (item: ArtifactListItem) => void
}
export function ArtifactActions({
deleting,
item,
onDelete
}: ArtifactActionsProps): React.JSX.Element {
const copyLink = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(item.shareUrl)
toast.success(translate('auto.components.artifacts.copySuccess', 'Artifact link copied'))
} catch {
toast.error(translate('auto.components.artifacts.copyFailed', 'Could not copy artifact link'))
}
}
return (
<div
className="flex shrink-0 items-center gap-1"
aria-label={translate('auto.components.artifacts.actions', 'Artifact actions')}
>
<Button size="sm" className="mr-1" onClick={() => void copyLink()}>
<Copy />
{translate('auto.components.artifacts.copyLink', 'Copy link')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => void window.api.shell.openUrl(item.shareUrl)}
aria-label={translate('auto.components.artifacts.openInBrowser', 'Open in browser')}
>
<ExternalLink />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.artifacts.openInBrowser', 'Open in browser')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
disabled={deleting}
onClick={() => onDelete(item)}
aria-label={translate(
'auto.components.artifacts.ArtifactsPage.deleteArtifact',
'Delete artifact'
)}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.artifacts.ArtifactsPage.deleteArtifact', 'Delete artifact')}
</TooltipContent>
</Tooltip>
</div>
)
}

View File

@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ArtifactListItem } from '../../../../shared/artifacts'
vi.mock('./ArtifactPreview', () => ({
ArtifactPreview: ({ shareUrl }: { shareUrl: string }) => <div>{`Preview ${shareUrl}`}</div>
}))
vi.mock('./ArtifactActions', () => ({
ArtifactActions: () => <div>Artifact actions</div>
}))
import { ArtifactCollection } from './ArtifactCollection'
function artifact(slug: string, title: string): ArtifactListItem {
return {
artifact: {
version: 1,
slug,
title,
originalFileName: `${slug}.html`,
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-07T12:00:00.000Z',
updatedAt: '2026-08-07T12:00:00.000Z',
expiresAt: '2026-09-07T12:00:00.000Z',
byteSize: 1200,
deletedAt: null
},
shareUrl: `https://share.onorca.dev/a/${slug}`
}
}
describe('ArtifactCollection', () => {
afterEach(cleanup)
it('keeps the artifact list beside a contained preview', async () => {
const items = [artifact('first', 'First artifact'), artifact('second', 'Second artifact')]
const selectArtifact = vi.fn()
const { container } = render(
<ArtifactCollection
artifacts={items}
deletingId={null}
selectedArtifact={items[0]}
selectArtifact={selectArtifact}
deleteArtifact={vi.fn()}
hasMore={false}
loadingMore={false}
loadMore={vi.fn()}
/>
)
const collection = container.firstElementChild
expect(collection).toHaveClass('grid-cols-[16rem_minmax(0,1fr)]')
expect(collection?.children[0]?.tagName).toBe('ASIDE')
expect(collection?.children[1]?.tagName).toBe('SECTION')
expect(screen.getByText('Preview https://share.onorca.dev/a/first')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /Second artifact/ }))
expect(selectArtifact).toHaveBeenCalledWith('second')
})
})

View File

@ -0,0 +1,110 @@
import { Files, Loader2 } from 'lucide-react'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { ArtifactActions } from './ArtifactActions'
import { ArtifactPreview } from './ArtifactPreview'
function formatArtifactDate(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(value)
)
}
function formatByteSize(value: number): string {
if (value < 1024) {
return `${value} B`
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${(value / (1024 * 1024)).toFixed(1)} MB`
}
function artifactName(item: ArtifactListItem): string {
return item.artifact.title || item.artifact.originalFileName || item.artifact.slug
}
export function ArtifactCollection({
artifacts,
deletingId,
selectedArtifact,
selectArtifact,
deleteArtifact,
hasMore,
loadingMore,
loadMore
}: {
artifacts: readonly ArtifactListItem[]
deletingId: string | null
selectedArtifact: ArtifactListItem
selectArtifact: (slug: string) => void
deleteArtifact: (item: ArtifactListItem) => void
hasMore: boolean
loadingMore: boolean
loadMore: () => void
}): React.JSX.Element {
return (
<div className="grid min-h-0 flex-1 grid-cols-[16rem_minmax(0,1fr)] overflow-hidden rounded-md border border-border/50 bg-muted/20">
<aside className="min-h-0 overflow-y-auto border-r border-border/50 scrollbar-sleek">
{artifacts.map((item) => {
const selected = item.artifact.slug === selectedArtifact.artifact.slug
return (
<button
type="button"
key={item.artifact.slug}
data-current={selected ? 'true' : undefined}
onClick={() => selectArtifact(item.artifact.slug)}
className={cn(
'flex w-full items-center gap-3 border-b border-border/50 px-3 py-3 text-left transition-colors last:border-b-0 hover:bg-accent/50',
selected && 'bg-accent'
)}
>
<Files className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{artifactName(item)}</span>
<span className="block truncate text-xs text-muted-foreground">
{formatArtifactDate(item.artifact.updatedAt)} ·{' '}
{formatByteSize(item.artifact.byteSize)}
</span>
</span>
</button>
)
})}
{hasMore ? (
<div className="border-t border-border/50 p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
disabled={loadingMore}
onClick={loadMore}
>
{loadingMore ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
</Button>
</div>
) : null}
</aside>
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 px-4 py-3">
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold">{artifactName(selectedArtifact)}</h2>
<p className="truncate text-xs text-muted-foreground">
{formatArtifactDate(selectedArtifact.artifact.updatedAt)} ·{' '}
{formatByteSize(selectedArtifact.artifact.byteSize)}
</p>
</div>
<ArtifactActions
deleting={deletingId === selectedArtifact.artifact.slug}
item={selectedArtifact}
onDelete={deleteArtifact}
/>
</div>
<ArtifactPreview shareUrl={selectedArtifact.shareUrl} />
</section>
</div>
)
}

View File

@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ArtifactPreview } from './ArtifactPreview'
function dispatchLoadFailure(
webview: Element,
failure: { errorCode: number; isMainFrame: boolean }
): void {
const event = new Event('did-fail-load')
Object.assign(event, {
errorCode: failure.errorCode,
errorDescription: 'failed',
validatedURL: 'https://share.onorca.dev/a/report',
isMainFrame: failure.isMainFrame
})
webview.dispatchEvent(event)
}
describe('ArtifactPreview', () => {
beforeEach(() => {
Object.assign(window, {
api: {
browser: { sessionResolvePartition: vi.fn().mockResolvedValue('persist:orca-default') }
}
})
})
afterEach(() => {
vi.useRealTimers()
cleanup()
})
it('ignores child-frame failures and aborted navigations', async () => {
render(<ArtifactPreview shareUrl="https://share.onorca.dev/a/report" />)
const webview = await waitFor(() => {
const element = document.querySelector('webview')
expect(element).not.toBeNull()
return element as Element
})
dispatchLoadFailure(webview, { errorCode: -105, isMainFrame: false })
dispatchLoadFailure(webview, { errorCode: -3, isMainFrame: true })
webview.dispatchEvent(new Event('did-stop-loading'))
expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument()
})
it('stops waiting when navigation stalls', async () => {
vi.useFakeTimers()
render(<ArtifactPreview shareUrl="https://share.onorca.dev/a/report" />)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(document.querySelector('webview')).not.toBeNull()
act(() => vi.advanceTimersByTime(20_000))
expect(screen.getByText('Preview unavailable')).toBeInTheDocument()
})
it('stops waiting when preview-session resolution stalls', () => {
vi.useFakeTimers()
vi.mocked(window.api.browser.sessionResolvePartition).mockReturnValue(new Promise(() => {}))
render(<ArtifactPreview shareUrl="https://share.onorca.dev/a/report" />)
act(() => vi.advanceTimersByTime(20_000))
expect(screen.getByText('Preview unavailable')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,163 @@
import { useEffect, useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/webview-registry'
import { translate } from '@/i18n/i18n'
type PreviewState = 'loading' | 'ready' | 'unavailable'
const ARTIFACT_PREVIEW_LOAD_TIMEOUT_MS = 20_000
function scheduleArtifactPreviewTimeout(onTimeout: () => void): () => void {
const timeout = setTimeout(onTimeout, ARTIFACT_PREVIEW_LOAD_TIMEOUT_MS)
return () => clearTimeout(timeout)
}
function artifactPreviewUrl(shareUrl: string): string {
const url = new URL(shareUrl)
url.searchParams.set('embed', '1')
return url.toString()
}
function attachArtifactWebview({
container,
partition,
shareUrl,
onLoadStarted,
onLoadStopped,
onLoadFailed
}: {
container: HTMLDivElement
partition: string
shareUrl: string
onLoadStarted: () => void
onLoadStopped: () => void
onLoadFailed: (event: Electron.DidFailLoadEvent) => void
}): () => void {
const webview = document.createElement('webview') as Electron.WebviewTag
webview.setAttribute('partition', partition)
webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)
webview.setAttribute(
'aria-label',
translate('auto.components.artifacts.preview', 'Artifact preview')
)
webview.style.display = 'flex'
webview.style.width = '100%'
webview.style.height = '100%'
webview.style.border = 'none'
webview.style.background = '#ffffff'
webview.addEventListener('did-start-loading', onLoadStarted)
webview.addEventListener('did-stop-loading', onLoadStopped)
webview.addEventListener('did-fail-load', onLoadFailed)
container.appendChild(webview)
webview.setAttribute('src', artifactPreviewUrl(shareUrl))
return () => {
webview.removeEventListener('did-start-loading', onLoadStarted)
webview.removeEventListener('did-stop-loading', onLoadStopped)
webview.removeEventListener('did-fail-load', onLoadFailed)
moveFocusToRendererBeforeWebviewDetach(webview)
webview.remove()
}
}
export function ArtifactPreview({ shareUrl }: { shareUrl: string }): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const [state, setState] = useState<PreviewState>('loading')
useEffect(() => {
let disposed = false
let detachPreview: (() => void) | undefined
let cancelLoadTimeout: (() => void) | undefined
let loadFailed = false
const clearLoadTimeout = (): void => {
cancelLoadTimeout?.()
cancelLoadTimeout = undefined
}
const startLoadTimeout = (): void => {
clearLoadTimeout()
cancelLoadTimeout = scheduleArtifactPreviewTimeout(() => {
loadFailed = true
setState('unavailable')
})
}
const onLoadStarted = (): void => {
loadFailed = false
setState('loading')
startLoadTimeout()
}
const onLoadStopped = (): void => {
clearLoadTimeout()
if (!loadFailed) {
setState('ready')
}
}
const onLoadFailed = (event: Electron.DidFailLoadEvent): void => {
if (!event.isMainFrame || event.errorCode === -3) {
return
}
clearLoadTimeout()
loadFailed = true
setState('unavailable')
}
setState('loading')
startLoadTimeout()
void window.api.browser
.sessionResolvePartition({ profileId: null })
.then((partition) => {
if (disposed || !partition || !containerRef.current) {
if (!disposed) {
clearLoadTimeout()
setState('unavailable')
}
return
}
detachPreview = attachArtifactWebview({
container: containerRef.current,
partition,
shareUrl,
onLoadStarted,
onLoadStopped,
onLoadFailed
})
startLoadTimeout()
})
.catch(() => {
if (!disposed) {
clearLoadTimeout()
setState('unavailable')
}
})
return () => {
disposed = true
clearLoadTimeout()
detachPreview?.()
}
}, [shareUrl])
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden bg-white" ref={containerRef}>
{state === 'loading' ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : null}
{state === 'unavailable' ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background px-6 text-center">
<AlertCircle className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">
{translate('auto.components.artifacts.previewUnavailable', 'Preview unavailable')}
</p>
<p className="max-w-sm text-xs text-muted-foreground">
{translate(
'auto.components.artifacts.previewUnavailableDescription',
'Open this artifact in your browser to view it.'
)}
</p>
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,479 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import type { ReactNode } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
const mocks = vi.hoisted(() => ({
authStatus: {
activeProfileId: 'profile-a',
cloud: { cloudProfileId: 'cloud-a', userId: 'user-a' },
configured: true,
state: 'connected'
} as Record<string, unknown>,
closePage: vi.fn(),
connect: vi.fn(),
confirm: vi.fn(),
refreshAuth: vi.fn(),
rpc: vi.fn(),
resolvePartition: vi.fn(),
writeClipboardText: vi.fn(),
openUrl: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { success: mocks.toastSuccess, error: mocks.toastError }
}))
vi.mock('@/components/confirmation-dialog-context', () => ({
useConfirmationDialog: () => mocks.confirm
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: mocks.rpc
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: Record<string, unknown>) => unknown) => selector(storeState()),
{ getState: storeState }
)
}))
function storeState(): Record<string, unknown> {
return {
closeArtifactsPage: mocks.closePage,
connectCurrentOrcaProfile: mocks.connect,
orcaProfileAuthStatus: mocks.authStatus,
orcaProfileConnecting: false,
refreshCurrentOrcaProfileAuth: mocks.refreshAuth
}
}
import ArtifactsPage from './ArtifactsPage'
import { artifactAccountIdentity } from './useArtifactPagination'
describe('ArtifactsPage', () => {
beforeEach(() => {
mocks.authStatus = {
activeProfileId: 'profile-a',
cloud: { cloudProfileId: 'cloud-a', userId: 'user-a' },
configured: true,
state: 'connected'
}
mocks.closePage.mockReset()
mocks.connect.mockReset()
mocks.confirm.mockReset()
mocks.refreshAuth.mockReset()
mocks.rpc.mockReset()
mocks.resolvePartition.mockReset().mockResolvedValue('persist:orca-default')
mocks.writeClipboardText.mockReset().mockResolvedValue(undefined)
mocks.openUrl.mockReset().mockResolvedValue(undefined)
mocks.toastSuccess.mockReset()
mocks.toastError.mockReset()
Object.assign(window, {
api: {
browser: { sessionResolvePartition: mocks.resolvePartition },
ui: { writeClipboardText: mocks.writeClipboardText },
shell: { openUrl: mocks.openUrl }
}
})
mocks.rpc.mockResolvedValue({
status: 'ok',
value: {
artifacts: [
{
artifact: {
byteSize: 1024,
createdAt: '2026-08-01T12:00:00.000Z',
deletedAt: null,
expiresAt: '2026-09-01T12:00:00.000Z',
originalFileName: 'report.html',
renderedContentType: 'text/html',
slug: 'report-123',
sourceContentType: 'text/html',
title: 'Quarterly report',
updatedAt: '2026-08-02T12:00:00.000Z',
version: 1
},
shareUrl: 'https://share.onorca.dev/a/report-123'
}
]
}
})
})
afterEach(cleanup)
it('renders the selected artifact in-app with copy link as the primary action', async () => {
render(<ArtifactsPage />)
expect(await screen.findAllByText('Quarterly report')).toHaveLength(2)
const closeButton = screen.getByRole('button', { name: 'Close artifacts' })
expect(closeButton).toHaveClass('size-7', 'rounded-full')
expect(closeButton.closest('header')).toHaveClass('px-5', 'pb-3', 'pt-1.5', 'md:px-8')
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Refresh' })).toHaveClass(
'border',
'border-border/50'
)
const copyButton = screen.getByRole('button', { name: 'Copy link' })
expect(copyButton).toHaveAttribute('data-variant', 'default')
expect(copyButton.parentElement).toHaveAttribute('aria-label', 'Artifact actions')
expect(screen.getByRole('button', { name: 'Open in browser' })).toHaveAttribute(
'data-variant',
'ghost'
)
expect(screen.getByRole('button', { name: 'Delete artifact' })).toHaveClass(
'text-muted-foreground',
'hover:text-destructive'
)
await waitFor(() => {
const preview = document.querySelector('webview[aria-label="Artifact preview"]')
expect(preview).toHaveAttribute('partition', 'persist:orca-default')
expect(preview).toHaveAttribute('src', 'https://share.onorca.dev/a/report-123?embed=1')
})
fireEvent.click(copyButton)
await waitFor(() =>
expect(mocks.writeClipboardText).toHaveBeenCalledWith('https://share.onorca.dev/a/report-123')
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('Artifact link copied')
fireEvent.click(screen.getByRole('button', { name: 'Open in browser' }))
expect(mocks.openUrl).toHaveBeenCalledWith('https://share.onorca.dev/a/report-123')
})
it('shows a fallback when the desktop preview session is unavailable', async () => {
mocks.resolvePartition.mockResolvedValue(null)
render(<ArtifactsPage />)
expect(await screen.findByText('Preview unavailable')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Copy link' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Open in browser' })).toBeEnabled()
})
it('closes from the header button and Escape', async () => {
render(<ArtifactsPage />)
await waitFor(() => expect(mocks.rpc).toHaveBeenCalledOnce())
fireEvent.click(screen.getByRole('button', { name: 'Close artifacts' }))
expect(mocks.closePage).toHaveBeenCalledOnce()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(mocks.closePage).toHaveBeenCalledTimes(2)
})
it('explains the agent-first sharing workflow', async () => {
mocks.rpc.mockResolvedValue({ status: 'ok', value: { artifacts: [] } })
render(<ArtifactsPage />)
const heading = await screen.findByText('No shared artifacts')
expect(heading.parentElement).toHaveClass('flex-1', 'justify-center')
expect(
screen.getByText('Ask your agent to share an HTML or Markdown file, and it will appear here.')
).toBeInTheDocument()
expect(screen.queryByText(/orca artifacts share/)).not.toBeInTheDocument()
})
it('loads each cursor once and appends the next artifact page', async () => {
let resolveNextPage!: (value: unknown) => void
mocks.rpc
.mockResolvedValueOnce({
status: 'ok',
value: {
artifacts: [artifactListItem('First page', 'first-page')],
nextCursor: 'opaque cursor'
}
})
.mockReturnValueOnce(
new Promise((resolve) => {
resolveNextPage = resolve
})
)
render(<ArtifactsPage />)
await screen.findAllByText('First page')
const loadMore = screen.getByRole('button', { name: 'Load more' })
fireEvent.click(loadMore)
fireEvent.click(loadMore)
expect(mocks.rpc).toHaveBeenCalledTimes(2)
expect(mocks.rpc).toHaveBeenLastCalledWith({ kind: 'local' }, 'artifacts.list', {
cursor: 'opaque cursor'
})
resolveNextPage({
status: 'ok',
value: { artifacts: [artifactListItem('Second page', 'second-page')] }
})
expect(await screen.findByText('Second page')).toBeInTheDocument()
expect(screen.getAllByText('First page')).toHaveLength(2)
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
})
it('can continue from an empty page that has a next cursor', async () => {
mocks.rpc
.mockResolvedValueOnce({
status: 'ok',
value: { artifacts: [], nextCursor: 'after-empty-page' }
})
.mockResolvedValueOnce({
status: 'ok',
value: { artifacts: [artifactListItem('Older artifact', 'older-artifact')] }
})
render(<ArtifactsPage />)
expect(await screen.findByText('More artifacts are available')).toBeInTheDocument()
expect(screen.queryByText('No shared artifacts')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findAllByText('Older artifact')).toHaveLength(2)
})
it('keeps loaded artifacts when loading another page fails', async () => {
mocks.rpc
.mockResolvedValueOnce({
status: 'ok',
value: {
artifacts: [artifactListItem('Still visible', 'still-visible')],
nextCursor: 'next-page'
}
})
.mockRejectedValueOnce(new Error('network down'))
render(<ArtifactsPage />)
await screen.findAllByText('Still visible')
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findByText('Could not load more artifacts.')).toBeInTheDocument()
expect(screen.getAllByText('Still visible')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Load more' })).toBeEnabled()
})
it('does not surface an initial auth error after the account changes during refresh', async () => {
let resolveRefresh!: () => void
mocks.refreshAuth.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveRefresh = resolve
})
)
mocks.rpc.mockResolvedValueOnce({ status: 'reconnect-required' }).mockResolvedValueOnce({
status: 'ok',
value: { artifacts: [artifactListItem('Account B', 'account-b')] }
})
const view = render(<ArtifactsPage />)
await waitFor(() => expect(mocks.refreshAuth).toHaveBeenCalledOnce())
mocks.authStatus = {
activeProfileId: 'profile-b',
cloud: { cloudProfileId: 'cloud-b', userId: 'user-b' },
configured: true,
state: 'connected'
}
view.rerender(<ArtifactsPage />)
expect(await screen.findAllByText('Account B')).toHaveLength(2)
resolveRefresh()
await waitFor(() =>
expect(screen.queryByText('Sign in to Orca again to load artifacts.')).not.toBeInTheDocument()
)
})
it('does not surface a load-more auth error after switching accounts during refresh', async () => {
let resolveRefresh!: () => void
mocks.refreshAuth.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveRefresh = resolve
})
)
mocks.rpc
.mockResolvedValueOnce({
status: 'ok',
value: {
artifacts: [artifactListItem('Account A', 'account-a')],
nextCursor: 'account-a-next'
}
})
.mockResolvedValueOnce({ status: 'reconnect-required' })
.mockResolvedValueOnce({
status: 'ok',
value: { artifacts: [artifactListItem('Account B', 'account-b')] }
})
const view = render(<ArtifactsPage />)
await screen.findAllByText('Account A')
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
await waitFor(() => expect(mocks.refreshAuth).toHaveBeenCalledOnce())
mocks.authStatus = {
activeProfileId: 'profile-b',
cloud: { cloudProfileId: 'cloud-b', userId: 'user-b' },
configured: true,
state: 'connected'
}
view.rerender(<ArtifactsPage />)
expect(await screen.findAllByText('Account B')).toHaveLength(2)
resolveRefresh()
await waitFor(() =>
expect(screen.queryByText('Sign in to Orca again to load artifacts.')).not.toBeInTheDocument()
)
})
it('never renders artifacts loaded for a previous account', async () => {
let resolveFirst!: (value: unknown) => void
mocks.rpc.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve
})
)
const view = render(<ArtifactsPage />)
mocks.authStatus = {
activeProfileId: 'profile-b',
cloud: { cloudProfileId: 'cloud-b', userId: 'user-b' },
configured: true,
state: 'connected'
}
mocks.rpc.mockResolvedValueOnce({ status: 'ok', value: { artifacts: [] } })
view.rerender(<ArtifactsPage />)
resolveFirst({
status: 'ok',
value: {
artifacts: [
{
artifact: {
byteSize: 1,
createdAt: '2026-08-01T12:00:00.000Z',
deletedAt: null,
expiresAt: '2026-09-01T12:00:00.000Z',
originalFileName: 'account-a-secret.html',
renderedContentType: 'text/html',
slug: 'account-a-secret',
sourceContentType: 'text/html',
title: 'Account A secret',
updatedAt: '2026-08-02T12:00:00.000Z',
version: 1
},
shareUrl: 'https://share.onorca.dev/a/account-a-secret'
}
]
}
})
await screen.findByText('No shared artifacts')
expect(screen.queryByText('Account A secret')).not.toBeInTheDocument()
})
it('does not apply a completed deletion to a new account', async () => {
let resolveDelete!: (value: unknown) => void
mocks.confirm.mockResolvedValue(true)
mocks.rpc.mockResolvedValueOnce({
status: 'ok',
value: { artifacts: [artifactListItem('Shared slug A', 'shared-slug')] }
})
mocks.rpc.mockReturnValueOnce(
new Promise((resolve) => {
resolveDelete = resolve
})
)
const view = render(<ArtifactsPage />)
await screen.findAllByText('Shared slug A')
fireEvent.click(screen.getByRole('button', { name: 'Delete artifact' }))
await waitFor(() => expect(mocks.rpc).toHaveBeenCalledTimes(2))
mocks.authStatus = {
activeProfileId: 'profile-b',
cloud: { cloudProfileId: 'cloud-b', userId: 'user-b' },
configured: true,
state: 'connected'
}
mocks.rpc.mockResolvedValueOnce({
status: 'ok',
value: { artifacts: [artifactListItem('Shared slug B', 'shared-slug')] }
})
view.rerender(<ArtifactsPage />)
resolveDelete({ status: 'ok', value: undefined })
expect(await screen.findAllByText('Shared slug B')).toHaveLength(2)
})
it('does not resurrect a deletion from an older refresh', async () => {
let resolveRefresh!: (value: unknown) => void
mocks.confirm.mockResolvedValue(true)
mocks.rpc
.mockResolvedValueOnce({
status: 'ok',
value: { artifacts: [artifactListItem('Delete me', 'delete-me')] }
})
.mockReturnValueOnce(
new Promise((resolve) => {
resolveRefresh = resolve
})
)
.mockResolvedValueOnce({ status: 'ok', value: undefined })
render(<ArtifactsPage />)
await screen.findAllByText('Delete me')
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete artifact' }))
await waitFor(() => expect(screen.queryByText('Delete me')).not.toBeInTheDocument())
resolveRefresh({
status: 'ok',
value: { artifacts: [artifactListItem('Delete me', 'delete-me')] }
})
await waitFor(() => expect(screen.queryByText('Delete me')).not.toBeInTheDocument())
})
it('treats an organization switch as an account identity change', () => {
const status = {
activeProfileId: 'profile-a',
cloud: {
activeOrgId: 'org-a',
cloudProfileId: 'cloud-a',
email: 'a@example.com',
linkedAt: 1,
userId: 'user-a'
},
configured: true,
persistence: 'encrypted',
state: 'connected'
} satisfies OrcaProfileAuthStatus
expect(artifactAccountIdentity(status)).not.toBe(
artifactAccountIdentity({ ...status, cloud: { ...status.cloud, activeOrgId: 'org-b' } })
)
})
})
function artifactListItem(title: string, slug: string): Record<string, unknown> {
return {
artifact: {
byteSize: 1,
createdAt: '2026-08-01T12:00:00.000Z',
deletedAt: null,
expiresAt: '2026-09-01T12:00:00.000Z',
originalFileName: `${slug}.html`,
renderedContentType: 'text/html',
slug,
sourceContentType: 'text/html',
title,
updatedAt: '2026-08-02T12:00:00.000Z',
version: 1
},
shareUrl: `https://share.onorca.dev/a/${slug}`
}
}

View File

@ -0,0 +1,258 @@
import { useEffect, useState } from 'react'
import { Files, Loader2, RefreshCw, X } from 'lucide-react'
import type { ArtifactCloudOperation, ArtifactListItem } from '../../../../shared/artifacts'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { ArtifactCollection } from './ArtifactCollection'
import { artifactAccountIdentity, useArtifactPagination } from './useArtifactPagination'
const LOCAL_RUNTIME = { kind: 'local' } as const
export default function ArtifactsPage(): React.JSX.Element {
const closePage = useAppStore((state) => state.closeArtifactsPage)
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const refreshAuth = useAppStore((state) => state.refreshCurrentOrcaProfileAuth)
const confirm = useConfirmationDialog()
const [deleting, setDeleting] = useState<{ identity: string; slug: string } | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const signedIn = authStatus?.state === 'connected'
const {
accountIdentity,
artifacts,
error,
loading,
loadingMore,
nextCursor,
loadArtifacts,
loadMoreArtifacts,
removeArtifact,
setError
} = useArtifactPagination(authStatus, refreshAuth)
const deletingId = deleting?.identity === accountIdentity ? deleting.slug : null
const selectedArtifact =
artifacts.find(({ artifact }) => artifact.slug === selectedSlug) ?? artifacts[0] ?? null
useEffect(() => {
setSelectedSlug((current) => {
if (current && artifacts.some(({ artifact }) => artifact.slug === current)) {
return current
}
return artifacts[0]?.artifact.slug ?? null
})
}, [artifacts])
useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || event.defaultPrevented) {
return
}
event.preventDefault()
closePage()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [closePage])
const deleteArtifact = async (item: ArtifactListItem): Promise<void> => {
const requestedIdentity = accountIdentity
if (!requestedIdentity) {
return
}
const requestedAccountIsCurrent = (): boolean =>
artifactAccountIdentity(useAppStore.getState().orcaProfileAuthStatus) === requestedIdentity
const name = item.artifact.title || item.artifact.originalFileName || item.artifact.slug
const accepted = await confirm({
title: translate('auto.components.artifacts.ArtifactsPage.deleteTitle', 'Delete artifact?'),
description: translate(
'auto.components.artifacts.ArtifactsPage.deleteDescription',
'“{{name}}” will no longer be available at its public link.',
{ name }
),
confirmLabel: translate('auto.components.artifacts.ArtifactsPage.delete', 'Delete'),
confirmVariant: 'destructive'
})
if (!accepted || !requestedAccountIsCurrent()) {
return
}
setDeleting({ identity: requestedIdentity, slug: item.artifact.slug })
try {
const result = await callRuntimeRpc<ArtifactCloudOperation<void>>(
LOCAL_RUNTIME,
'artifacts.delete',
{ id: item.artifact.slug }
)
if (!requestedAccountIsCurrent()) {
return
}
if (result.status !== 'ok') {
await refreshAuth()
throw new Error(result.status)
}
removeArtifact(requestedIdentity, item.artifact.slug)
} catch (deleteError) {
console.error('Failed to delete artifact:', deleteError)
if (requestedAccountIsCurrent()) {
setError(
translate(
'auto.components.artifacts.ArtifactsPage.deleteFailed',
'Could not delete the artifact.'
)
)
}
} finally {
setDeleting((current) =>
current?.identity === requestedIdentity && current.slug === item.artifact.slug
? null
: current
)
}
}
return (
<main className="relative flex h-full min-h-0 flex-1 flex-col bg-background text-foreground">
<header className="flex shrink-0 items-center justify-between px-5 pb-3 pt-1.5 md:px-8">
<div className="flex min-w-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-full"
onClick={closePage}
aria-label={translate(
'auto.components.artifacts.ArtifactsPage.closeArtifacts',
'Close artifacts'
)}
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.artifacts.ArtifactsPage.closeTooltip', 'Close · Esc')}
</TooltipContent>
</Tooltip>
<div className="mx-1 h-5 w-px bg-border/50" aria-hidden />
<Files className="size-4 shrink-0 text-muted-foreground" />
<h1 className="truncate text-sm font-semibold">
{translate('auto.components.artifacts.ArtifactsPage.title', 'Artifacts')}
</h1>
</div>
{signedIn ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="border border-border/50 bg-transparent hover:bg-muted/50"
onClick={() => void loadArtifacts()}
disabled={loading}
aria-label={translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
>
<RefreshCw className={loading ? 'animate-spin' : undefined} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
</TooltipContent>
</Tooltip>
) : null}
</header>
<div className="flex min-h-0 flex-1 border-t border-border/50 px-5 py-5 md:px-8">
<div className="mx-auto flex min-h-0 w-full flex-1 flex-col">
{!signedIn ? (
<div className="flex min-h-72 flex-col items-center justify-center gap-3 text-center">
<Files className="size-8 text-muted-foreground" />
<div className="space-y-1">
<h2 className="text-sm font-semibold">
{translate(
'auto.components.artifacts.ArtifactsPage.signInHeading',
'Sign in to Orca'
)}
</h2>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.artifacts.ArtifactsPage.signInCopy',
'Sign in to view and manage artifacts shared through your account.'
)}
</p>
</div>
<Button
size="sm"
disabled={connecting || authStatus?.configured !== true}
onClick={() => void connect()}
>
{connecting
? translate('auto.components.artifacts.ArtifactsPage.signingIn', 'Signing in…')
: translate('auto.components.artifacts.ArtifactsPage.signIn', 'Sign in to Orca')}
</Button>
</div>
) : loading && artifacts.length === 0 ? (
<div className="flex min-h-72 items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : artifacts.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<Files className="size-8 text-muted-foreground" />
<h2 className="text-sm font-semibold">
{nextCursor
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailable',
'More artifacts are available'
)
: translate(
'auto.components.artifacts.ArtifactsPage.empty',
'No shared artifacts'
)}
</h2>
<p className="text-xs text-muted-foreground">
{nextCursor
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailableCopy',
'Load the next page to continue.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.emptyCopy',
'Ask your agent to share an HTML or Markdown file, and it will appear here.'
)}
</p>
{nextCursor ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
disabled={loadingMore}
onClick={() => void loadMoreArtifacts()}
>
{loadingMore ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
</Button>
) : null}
</div>
) : (
selectedArtifact && (
<ArtifactCollection
artifacts={artifacts}
deletingId={deletingId}
selectedArtifact={selectedArtifact}
selectArtifact={setSelectedSlug}
deleteArtifact={(target) => void deleteArtifact(target)}
hasMore={Boolean(nextCursor)}
loadingMore={loadingMore}
loadMore={() => void loadMoreArtifacts()}
/>
)
)}
{error ? <p className="mt-3 text-xs text-destructive">{error}</p> : null}
</div>
</div>
</main>
)
}

View File

@ -0,0 +1,218 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
ArtifactCloudOperation,
ArtifactListItem,
ArtifactListPage
} from '../../../../shared/artifacts'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
import { translate } from '@/i18n/i18n'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
const LOCAL_RUNTIME = { kind: 'local' } as const
const EMPTY_ARTIFACTS: readonly ArtifactListItem[] = []
export function artifactAccountIdentity(authStatus: OrcaProfileAuthStatus | null): string | null {
return authStatus?.state === 'connected'
? `${authStatus.activeProfileId}:${authStatus.cloud?.userId ?? ''}:${authStatus.cloud?.cloudProfileId ?? ''}:${authStatus.cloud?.activeOrgId ?? ''}`
: null
}
function appendArtifactPage(
current: readonly ArtifactListItem[],
incoming: readonly ArtifactListItem[]
): readonly ArtifactListItem[] {
const knownSlugs = new Set(current.map(({ artifact }) => artifact.slug))
return [...current, ...incoming.filter(({ artifact }) => !knownSlugs.has(artifact.slug))]
}
function artifactRequestIsCurrent(
sequence: number,
currentSequence: number,
identity: string
): boolean {
return (
sequence === currentSequence &&
artifactAccountIdentity(useAppStore.getState().orcaProfileAuthStatus) === identity
)
}
export function useArtifactPagination(
authStatus: OrcaProfileAuthStatus | null,
refreshAuth: () => Promise<unknown>
): {
accountIdentity: string | null
artifacts: readonly ArtifactListItem[]
error: string | null
loading: boolean
loadingMore: boolean
nextCursor?: string
loadArtifacts: () => Promise<void>
loadMoreArtifacts: () => Promise<void>
removeArtifact: (identity: string, slug: string) => void
setError: (error: string | null) => void
} {
const accountIdentity = artifactAccountIdentity(authStatus)
const [artifactState, setArtifactState] = useState<{
identity: string | null
page: ArtifactListPage
}>({ identity: null, page: { artifacts: [] } })
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadSequence = useRef(0)
const loadingCursor = useRef<string | null>(null)
const currentPage = artifactState.identity === accountIdentity ? artifactState.page : null
const artifacts = currentPage?.artifacts ?? EMPTY_ARTIFACTS
const loadArtifacts = useCallback(async (): Promise<void> => {
const sequence = ++loadSequence.current
loadingCursor.current = null
setLoadingMore(false)
if (!accountIdentity) {
setArtifactState({ identity: null, page: { artifacts: [] } })
setError(null)
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const result = await callRuntimeRpc<ArtifactCloudOperation<ArtifactListPage>>(
LOCAL_RUNTIME,
'artifacts.list',
{}
)
if (!artifactRequestIsCurrent(sequence, loadSequence.current, accountIdentity)) {
return
}
if (result.status === 'ok') {
setArtifactState({ identity: accountIdentity, page: result.value })
} else {
await refreshAuth()
if (!artifactRequestIsCurrent(sequence, loadSequence.current, accountIdentity)) {
return
}
setError(
translate(
'auto.components.artifacts.ArtifactsPage.signInAgain',
'Sign in to Orca again to load artifacts.'
)
)
}
} catch (loadError) {
if (!artifactRequestIsCurrent(sequence, loadSequence.current, accountIdentity)) {
return
}
console.error('Failed to load artifacts:', loadError)
setError(
translate('auto.components.artifacts.ArtifactsPage.loadFailed', 'Could not load artifacts.')
)
} finally {
if (artifactRequestIsCurrent(sequence, loadSequence.current, accountIdentity)) {
setLoading(false)
}
}
}, [accountIdentity, refreshAuth])
useEffect(() => {
void loadArtifacts()
return () => {
loadSequence.current += 1
}
}, [loadArtifacts])
const loadMoreArtifacts = useCallback(async (): Promise<void> => {
const cursor = currentPage?.nextCursor
if (!accountIdentity || !cursor || loadingCursor.current) {
return
}
const sequence = loadSequence.current
loadingCursor.current = cursor
setLoadingMore(true)
setError(null)
try {
const result = await callRuntimeRpc<ArtifactCloudOperation<ArtifactListPage>>(
LOCAL_RUNTIME,
'artifacts.list',
{ cursor }
)
if (!artifactRequestIsCurrent(sequence, loadSequence.current, accountIdentity)) {
return
}
if (result.status !== 'ok') {
await refreshAuth()
if (!artifactRequestIsCurrent(sequence, loadSequence.current, accountIdentity)) {
return
}
setError(
translate(
'auto.components.artifacts.ArtifactsPage.signInAgain',
'Sign in to Orca again to load artifacts.'
)
)
return
}
setArtifactState((current) =>
current.identity === accountIdentity
? {
identity: current.identity,
page: {
artifacts: appendArtifactPage(current.page.artifacts, result.value.artifacts),
...(result.value.nextCursor && result.value.nextCursor !== cursor
? { nextCursor: result.value.nextCursor }
: {})
}
}
: current
)
} catch (loadError) {
if (!artifactRequestIsCurrent(sequence, loadSequence.current, accountIdentity)) {
return
}
console.error('Failed to load more artifacts:', loadError)
setError(
translate(
'auto.components.artifacts.ArtifactsPage.loadMoreFailed',
'Could not load more artifacts.'
)
)
} finally {
if (loadingCursor.current === cursor) {
loadingCursor.current = null
setLoadingMore(false)
}
}
}, [accountIdentity, currentPage?.nextCursor, refreshAuth])
const removeArtifact = useCallback((identity: string, slug: string): void => {
loadSequence.current += 1
loadingCursor.current = null
setLoading(false)
setLoadingMore(false)
setArtifactState((current) =>
current.identity === identity
? {
...current,
page: {
...current.page,
artifacts: current.page.artifacts.filter((item) => item.artifact.slug !== slug)
}
}
: current
)
}, [])
return {
accountIdentity,
artifacts,
error,
loading,
loadingMore,
nextCursor: currentPage?.nextCursor,
loadArtifacts,
loadMoreArtifacts,
removeArtifact,
setError
}
}

View File

@ -31,7 +31,7 @@ describe('OrcaProfileSignOutConfirmDialog', () => {
expect(html).toContain('Sign out of Orca?')
expect(html).toContain(
'You&#x27;ll be signed out of Orca on this device. Your local projects and worktrees won&#x27;t be affected.'
'Artifacts and Orca Relay will be unavailable until you sign in again. Your local projects and worktrees won&#x27;t be affected.'
)
expect(html).not.toContain('Personal')
expect(html).not.toContain('alert-triangle')

View File

@ -31,7 +31,7 @@ export function OrcaProfileSignOutConfirmDialog({
<DialogDescription>
{translate(
'auto.components.orca.profiles.signout.confirm.description',
"You'll be signed out of Orca on this device. Your local projects and worktrees won't be affected."
"Artifacts and Orca Relay will be unavailable until you sign in again. Your local projects and worktrees won't be affected."
)}
</DialogDescription>
</DialogHeader>

View File

@ -0,0 +1,133 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
const mocks = vi.hoisted(() => ({
connect: vi.fn(),
fetchAuthStatus: vi.fn(),
openArtifactsPage: vi.fn(),
state: {
orcaProfileAuthStatus: {
configured: true,
state: 'connected'
} as Record<string, unknown> | null,
orcaProfileConnecting: false
}
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
...mocks.state,
connectCurrentOrcaProfile: mocks.connect,
fetchOrcaProfileAuthStatus: mocks.fetchAuthStatus,
openArtifactsPage: mocks.openArtifactsPage
})
}))
import { ArtifactsSettingsPane } from './ArtifactsSettingsPane'
describe('ArtifactsSettingsPane', () => {
beforeEach(() => {
mocks.connect.mockReset()
mocks.fetchAuthStatus.mockReset()
mocks.openArtifactsPage.mockReset()
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
mocks.state.orcaProfileConnecting = false
})
afterEach(cleanup)
it('explains the complete sharing workflow', () => {
render(
<ArtifactsSettingsPane
settings={{ ...getDefaultSettings('/tmp'), showArtifactsButton: true }}
updateSettings={vi.fn()}
/>
)
expect(screen.getByText('How to use Artifacts')).toBeInTheDocument()
expect(screen.getByText('Ask your agent to share it')).toBeInTheDocument()
expect(
screen.getByText('For example: “Share this HTML mock as an artifact.”')
).toBeInTheDocument()
expect(screen.getByText('Share the public link')).toBeInTheDocument()
expect(
screen.getByText('Your agent returns a link that anyone with the URL can view.')
).toBeInTheDocument()
expect(screen.getByText('Manage it in Orca')).toBeInTheDocument()
expect(
screen.getByText('Preview, copy, and manage links shared through your account.')
).toBeInTheDocument()
expect(
screen.queryByText('Uploads require sign-in; public links do not.')
).not.toBeInTheDocument()
expect(screen.queryByText('Orca account')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sign in to Orca' })).not.toBeInTheDocument()
})
it('offers sign in for a local profile', async () => {
const user = userEvent.setup()
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
render(<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />)
expect(screen.getByText('Sign in to share artifacts')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
expect(mocks.connect).toHaveBeenCalledOnce()
})
it('shows reconnect and connecting states', () => {
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'reconnect-required' }
const { rerender } = render(
<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
)
expect(screen.getByRole('button', { name: 'Sign in again' })).toBeEnabled()
mocks.state.orcaProfileConnecting = true
rerender(
<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
)
expect(screen.getByRole('button', { name: 'Signing in…' })).toBeDisabled()
})
it('loads missing account status and disables sign in until configured', () => {
mocks.state.orcaProfileAuthStatus = null
render(<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />)
expect(mocks.fetchAuthStatus).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'Sign in to Orca' })).toBeDisabled()
})
it('controls only sidebar visibility and always allows opening Artifacts', async () => {
const user = userEvent.setup()
const updateSettings = vi.fn()
render(
<ArtifactsSettingsPane
settings={{ ...getDefaultSettings('/tmp'), showArtifactsButton: false }}
updateSettings={updateSettings}
/>
)
const toggle = screen.getByRole('switch', { name: 'Show Artifacts Button' })
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(updateSettings).toHaveBeenCalledWith({ showArtifactsButton: true })
expect(screen.queryByText(/orca artifacts share/)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy command' })).not.toBeInTheDocument()
const openButton = screen.getByRole('button', { name: /Open Artifacts/ })
expect(openButton).toBeEnabled()
await user.click(openButton)
expect(mocks.openArtifactsPage).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,168 @@
import { useEffect } from 'react'
import { ArrowRight, Files } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '@/components/ui/button'
import { SettingsSwitchRow } from './SettingsFormControls'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
export function ArtifactsSettingsPane({
settings,
updateSettings
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
}): React.JSX.Element {
const openArtifactsPage = useAppStore((state) => state.openArtifactsPage)
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
const signedIn = authStatus?.state === 'connected'
useEffect(() => {
if (!authStatus) {
void fetchAuthStatus()
}
}, [authStatus, fetchAuthStatus])
return (
<div className="divide-y divide-border">
<SettingsSwitchRow
label={translate('auto.components.settings.artifacts.showButton', 'Show Artifacts Button')}
description={translate(
'auto.components.settings.artifacts.showButtonDescription',
'Show the Artifacts shortcut in the sidebar.'
)}
checked={settings.showArtifactsButton === true}
onChange={() => void updateSettings({ showArtifactsButton: !settings.showArtifactsButton })}
/>
{!signedIn ? (
<section className="flex flex-wrap items-center gap-4 py-5">
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-sm font-medium">
{translate(
'auto.components.settings.artifacts.signInTitle',
'Sign in to share artifacts'
)}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.signInDescription',
'Use your Orca account to upload artifacts and manage their public links.'
)}
</p>
</div>
<Button
type="button"
size="sm"
disabled={connecting || authStatus?.configured !== true}
onClick={() => void connect()}
>
{connecting
? translate('auto.components.settings.artifacts.signingIn', 'Signing in…')
: authStatus?.state === 'reconnect-required'
? translate('auto.components.settings.artifacts.signInAgain', 'Sign in again')
: translate('auto.components.settings.artifacts.signIn', 'Sign in to Orca')}
</Button>
</section>
) : null}
<section className="space-y-4 py-5">
<div className="space-y-1">
<h3 className="text-sm font-medium">
{translate('auto.components.settings.artifacts.howToTitle', 'How to use Artifacts')}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.howToDescription',
'Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.'
)}
</p>
</div>
<ol className="space-y-3">
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
1
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.artifacts.shareStepTitle',
'Ask your agent to share it'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.shareStepDescription',
'For example: “Share this HTML mock as an artifact.”'
)}
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
2
</span>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.artifacts.linkStepTitle',
'Share the public link'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.linkStepDescription',
'Your agent returns a link that anyone with the URL can view.'
)}
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
3
</span>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.artifacts.manageStepTitle',
'Manage it in Orca'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.manageStepDescription',
'Open Artifacts from the sidebar to revisit or delete links owned by your account.'
)}
</p>
</div>
</li>
</ol>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start whitespace-normal rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-left hover:bg-muted/35 hover:text-foreground"
onClick={openArtifactsPage}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
<Files className="size-4" />
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block text-sm font-medium text-foreground">
{translate('auto.components.settings.artifacts.openArtifacts', 'Open Artifacts')}
</span>
<span className="block text-xs font-normal text-muted-foreground">
{translate(
'auto.components.settings.artifacts.openArtifactsDescriptionV2',
'Preview, copy, and manage links shared through your account.'
)}
</span>
</span>
<ArrowRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
</Button>
</section>
</div>
)
}

View File

@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
const mocks = vi.hoisted(() => ({
openAutomationsPage: vi.fn()
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ openAutomationsPage: mocks.openAutomationsPage })
}))
import { AutomationsSettingsPane } from './AutomationsSettingsPane'
describe('AutomationsSettingsPane', () => {
beforeEach(() => {
mocks.openAutomationsPage.mockReset()
})
afterEach(cleanup)
it('explains the scheduled agent workflow', () => {
render(
<AutomationsSettingsPane
settings={{ ...getDefaultSettings('/tmp'), showAutomationsButton: true }}
updateSettings={vi.fn()}
/>
)
expect(screen.getByText('How Automations work')).toBeInTheDocument()
expect(screen.getByText('Describe the work')).toBeInTheDocument()
expect(screen.getByText('Orca starts each run')).toBeInTheDocument()
expect(screen.getByText('Review the results')).toBeInTheDocument()
expect(screen.getByText('Create schedules and inspect recent runs.')).toBeInTheDocument()
})
it('controls sidebar visibility and opens Automations', async () => {
const user = userEvent.setup()
const updateSettings = vi.fn()
render(
<AutomationsSettingsPane
settings={{ ...getDefaultSettings('/tmp'), showAutomationsButton: false }}
updateSettings={updateSettings}
/>
)
const toggle = screen.getByRole('switch', { name: 'Show Automations Button' })
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(updateSettings).toHaveBeenCalledWith({ showAutomationsButton: true })
const openButton = screen.getByRole('button', { name: /Open Automations/ })
expect(openButton).toBeEnabled()
await user.click(openButton)
expect(mocks.openAutomationsPage).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,139 @@
import { ArrowRight, CalendarClock } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '@/components/ui/button'
import { SettingsSwitchRow } from './SettingsFormControls'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
export function AutomationsSettingsPane({
settings,
updateSettings
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
}): React.JSX.Element {
const openAutomationsPage = useAppStore((state) => state.openAutomationsPage)
return (
<div className="divide-y divide-border">
<SettingsSwitchRow
label={translate(
'auto.components.settings.automations.showButton',
'Show Automations Button'
)}
description={translate(
'auto.components.settings.automations.showButtonDescription',
'Show the Automations shortcut in the sidebar.'
)}
checked={settings.showAutomationsButton !== false}
onChange={() =>
void updateSettings({
showAutomationsButton: settings.showAutomationsButton === false
})
}
/>
<section className="space-y-4 py-5">
<div className="space-y-1">
<h3 className="text-sm font-medium">
{translate(
'auto.components.settings.automations.howItWorksTitle',
'How Automations work'
)}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.automations.howItWorksDescription',
'Schedule agent work once, then let Orca create each run and keep its results together.'
)}
</p>
</div>
<ol className="space-y-3">
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
1
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.automations.defineStepTitle',
'Describe the work'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.automations.defineStepDescription',
'Choose a project, agent, prompt, and schedule.'
)}
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
2
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.automations.runStepTitle',
'Orca starts each run'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.automations.runStepDescription',
'The selected agent gets a fresh workspace when the schedule is due.'
)}
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
3
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.automations.reviewStepTitle',
'Review the results'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.automations.reviewStepDescription',
'Inspect recent runs and continue the work whenever you need to.'
)}
</p>
</div>
</li>
</ol>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start whitespace-normal rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-left hover:bg-muted/35 hover:text-foreground"
onClick={openAutomationsPage}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
<CalendarClock className="size-4" />
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block text-sm font-medium text-foreground">
{translate(
'auto.components.settings.automations.openAutomations',
'Open Automations'
)}
</span>
<span className="block text-xs font-normal text-muted-foreground">
{translate(
'auto.components.settings.automations.openAutomationsDescription',
'Create schedules and inspect recent runs.'
)}
</span>
</span>
<ArrowRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
</Button>
</section>
</div>
)
}

View File

@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import type { ReactNode } from 'react'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
connect: vi.fn(),
fetchAuthStatus: vi.fn(),
signOut: vi.fn(),
state: {
orcaProfileAuthStatus: {
configured: true,
state: 'connected',
cloud: { displayName: 'Ada Lovelace', email: 'ada@example.com' }
} as Record<string, unknown> | null,
orcaProfileConnecting: false
}
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
...mocks.state,
connectCurrentOrcaProfile: mocks.connect,
fetchOrcaProfileAuthStatus: mocks.fetchAuthStatus,
signOutCurrentOrcaProfile: mocks.signOut
})
}))
vi.mock('../orca-profiles/OrcaProfileSignOutConfirmDialog', () => ({
OrcaProfileSignOutConfirmDialog: ({
open,
onConfirm
}: {
open: boolean
onConfirm: () => void
children?: ReactNode
}) => (open ? <button onClick={onConfirm}>Confirm sign out</button> : null)
}))
import { OrcaAccountSettingsPane } from './OrcaAccountSettingsPane'
describe('OrcaAccountSettingsPane', () => {
beforeEach(() => {
mocks.connect.mockReset()
mocks.fetchAuthStatus.mockReset()
mocks.signOut.mockReset()
mocks.signOut.mockResolvedValue({ status: 'signed-out' })
mocks.state.orcaProfileAuthStatus = {
configured: true,
state: 'connected',
cloud: { displayName: 'Ada Lovelace', email: 'ada@example.com' }
}
mocks.state.orcaProfileConnecting = false
})
afterEach(cleanup)
it('shows the connected identity and confirms sign out', async () => {
const user = userEvent.setup()
render(<OrcaAccountSettingsPane />)
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
expect(screen.getByText('ada@example.com')).toBeInTheDocument()
expect(screen.getByText('Artifact sharing')).toBeInTheDocument()
expect(screen.getByText('Orca Relay')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Sign out' }))
await user.click(screen.getByRole('button', { name: 'Confirm sign out' }))
expect(mocks.signOut).toHaveBeenCalledOnce()
})
it('offers sign in for a local profile', async () => {
const user = userEvent.setup()
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
render(<OrcaAccountSettingsPane />)
expect(screen.getByText('Sign in to use Artifacts and Orca Relay.')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
expect(mocks.connect).toHaveBeenCalledOnce()
})
it('loads account status when it is not hydrated yet', () => {
mocks.state.orcaProfileAuthStatus = null
render(<OrcaAccountSettingsPane />)
expect(mocks.fetchAuthStatus).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'Sign in to Orca' })).toBeDisabled()
})
})

View File

@ -0,0 +1,167 @@
import { useEffect, useState } from 'react'
import { Check, CircleUserRound, Files, Smartphone } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { OrcaProfileSignOutConfirmDialog } from '../orca-profiles/OrcaProfileSignOutConfirmDialog'
function accountStatusCopy(
state: 'local' | 'unconfigured' | 'connected' | 'reconnect-required' | undefined,
email: string | undefined
): string {
if (state === 'connected') {
return email ?? translate('auto.components.settings.orcaAccount.connected', 'Connected')
}
if (state === 'reconnect-required') {
return translate(
'auto.components.settings.orcaAccount.reconnectRequired',
'Your session expired. Sign in again to use cloud features.'
)
}
if (state === 'unconfigured') {
return translate(
'auto.components.settings.orcaAccount.unavailable',
'Orca sign-in is unavailable in this build.'
)
}
if (state === 'local') {
return translate(
'auto.components.settings.orcaAccount.signedOut',
'Sign in to use Artifacts and Orca Relay.'
)
}
return translate('auto.components.settings.orcaAccount.checking', 'Checking account status…')
}
export function OrcaAccountSettingsPane(): React.JSX.Element {
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
const signOut = useAppStore((state) => state.signOutCurrentOrcaProfile)
const [signOutOpen, setSignOutOpen] = useState(false)
const [signingOut, setSigningOut] = useState(false)
const connected = authStatus?.state === 'connected'
const canConnect = authStatus?.configured === true
useEffect(() => {
if (!authStatus) {
void fetchAuthStatus()
}
}, [authStatus, fetchAuthStatus])
const confirmSignOut = async (): Promise<void> => {
if (signingOut) {
return
}
setSigningOut(true)
const result = await signOut()
setSigningOut(false)
if (result) {
setSignOutOpen(false)
}
}
return (
<>
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<CircleUserRound className="size-5" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">
{authStatus?.cloud?.displayName?.trim() ||
translate('auto.components.settings.orcaAccount.account', 'Orca account')}
</p>
{connected ? (
<Badge variant="outline" className="text-[11px] text-muted-foreground">
<Check />
{translate('auto.components.settings.orcaAccount.connected', 'Connected')}
</Badge>
) : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{accountStatusCopy(authStatus?.state, authStatus?.cloud?.email)}
</p>
</div>
{connected ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={signingOut}
onClick={() => setSignOutOpen(true)}
>
{translate('auto.components.settings.orcaAccount.signOut', 'Sign out')}
</Button>
) : (
<Button
type="button"
size="sm"
disabled={!canConnect || connecting}
onClick={() => void connect()}
>
{connecting
? translate('auto.components.settings.orcaAccount.signingIn', 'Signing in…')
: authStatus?.state === 'reconnect-required'
? translate('auto.components.settings.orcaAccount.signInAgain', 'Sign in again')
: translate('auto.components.settings.orcaAccount.signIn', 'Sign in to Orca')}
</Button>
)}
</div>
<div className="space-y-4 border-t border-border/60 pt-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate(
'auto.components.settings.orcaAccount.benefitsTitle',
'Included with your account'
)}
</p>
<div className="grid gap-5 md:grid-cols-2 md:gap-0 md:divide-x md:divide-border/60">
<div className="flex items-start gap-3 md:pr-6">
<Files className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.orcaAccount.artifactsTitle',
'Artifact sharing'
)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.settings.orcaAccount.artifactsDescription',
'Publish HTML and Markdown files, then manage every shared link from Orca.'
)}
</p>
</div>
</div>
<div className="flex items-start gap-3 md:pl-6">
<Smartphone className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium">
{translate('auto.components.settings.orcaAccount.relayTitle', 'Orca Relay')}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.settings.orcaAccount.relayDescription',
'Connect Orca Mobile to this desktop across cellular or any Wi-Fi.'
)}
</p>
</div>
</div>
</div>
</div>
</div>
<OrcaProfileSignOutConfirmDialog
open={signOutOpen}
onOpenChange={setSignOutOpen}
onConfirm={() => void confirmSignOut()}
signingOut={signingOut}
/>
</>
)
}

View File

@ -54,6 +54,9 @@ import { ExperimentalPane } from './ExperimentalPane'
import { PluginsSettingsSection } from './PluginsSettingsSection'
import { AgentsPane } from './AgentsPane'
import { OrchestrationPane } from './OrchestrationPane'
import { ArtifactsSettingsPane } from './ArtifactsSettingsPane'
import { AutomationsSettingsPane } from './AutomationsSettingsPane'
import { OrcaAccountSettingsPane } from './OrcaAccountSettingsPane'
import { LinearAgentSkillPane } from './LinearAgentSkillPane'
import { AccountsPane } from './AccountsPane'
import { StatsPane } from '../stats/StatsPane'
@ -1341,6 +1344,20 @@ function Settings(): React.JSX.Element {
</>
) : null}
{showDesktopOnlySettings ? (
<SettingsSection
id="orca-account"
title={translate('auto.components.settings.orcaAccount.title', 'Orca Account')}
description={translate(
'auto.components.settings.orcaAccount.description',
'Share work instantly and reach your desktop from Orca Mobile wherever you are.'
)}
searchEntries={getSectionSearchEntries('orca-account')}
>
{isSectionMounted('orca-account') ? <OrcaAccountSettingsPane /> : null}
</SettingsSection>
) : null}
<SettingsSection
id="setup-guide"
title={translate(
@ -1408,6 +1425,35 @@ function Settings(): React.JSX.Element {
</SettingsSection>
) : null}
<SettingsSection
id="automations"
title={translate('auto.components.settings.automations.title', 'Automations')}
description={translate(
'auto.components.settings.automations.description',
'Schedule agent work and choose whether Automations appears in the sidebar.'
)}
searchEntries={getSectionSearchEntries('automations')}
>
{isSectionMounted('automations') ? (
<AutomationsSettingsPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
<SettingsSection
id="artifacts"
title={translate('auto.components.settings.artifacts.title', 'Artifacts')}
badge="Beta"
description={translate(
'auto.components.settings.artifacts.description',
'Share HTML and Markdown files with your team and manage their public links.'
)}
searchEntries={getSectionSearchEntries('artifacts')}
>
{isSectionMounted('artifacts') ? (
<ArtifactsSettingsPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
<SettingsSection
id="git"
title={translate(

View File

@ -0,0 +1,20 @@
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
export const getArtifactsSettingsSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.artifacts.showButton', 'Show Artifacts Button'),
description: translate(
'auto.components.settings.artifacts.showButtonDescription',
'Show the Artifacts shortcut in the sidebar.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.artifacts.keywordArtifacts', 'artifacts'),
...translateSearchKeyword('auto.components.settings.artifacts.keywordShare', 'share'),
...translateSearchKeyword('auto.components.settings.artifacts.keywordHtml', 'HTML'),
...translateSearchKeyword('auto.components.settings.artifacts.keywordMarkdown', 'Markdown'),
...translateSearchKeyword('auto.components.settings.artifacts.keywordUpload', 'upload')
]
}
])

View File

@ -0,0 +1,22 @@
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
export const getAutomationsSettingsSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.automations.showButton', 'Show Automations Button'),
description: translate(
'auto.components.settings.automations.showButtonDescription',
'Show the Automations shortcut in the sidebar.'
),
keywords: [
...translateSearchKeyword(
'auto.components.settings.automations.keywordAutomations',
'automations'
),
...translateSearchKeyword('auto.components.settings.automations.keywordSchedule', 'schedule'),
...translateSearchKeyword('auto.components.settings.automations.keywordAgent', 'agent'),
...translateSearchKeyword('auto.components.settings.automations.keywordRuns', 'runs')
]
}
])

View File

@ -0,0 +1,22 @@
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
export const getOrcaAccountSettingsSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.orcaAccount.account', 'Orca account'),
description: translate(
'auto.components.settings.orcaAccount.searchDescription',
'Sign in or out of the account used by Artifacts and Orca Relay.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.orcaAccount.keywordAccount', 'account'),
...translateSearchKeyword('auto.components.settings.orcaAccount.keywordLogin', 'login'),
...translateSearchKeyword('auto.components.settings.orcaAccount.keywordLogout', 'logout'),
...translateSearchKeyword('auto.components.settings.orcaAccount.keywordSignIn', 'sign in'),
...translateSearchKeyword('auto.components.settings.orcaAccount.keywordSignOut', 'sign out'),
...translateSearchKeyword('auto.components.settings.orcaAccount.keywordRelay', 'relay'),
...translateSearchKeyword('auto.components.settings.orcaAccount.keywordCloud', 'cloud')
]
}
])

View File

@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
openAutomationsPage: vi.fn(),
openActivityPage: vi.fn(),
openMobilePage: vi.fn(),
openArtifactsPage: vi.fn(),
openModal: vi.fn(),
updateSettings: vi.fn(),
refreshPreflightStatus: vi.fn(),
@ -90,6 +91,7 @@ import SidebarNav, {
shouldShowAgentDashboardButton,
shouldShowAgentsButton,
shouldShowAutomationsButton,
shouldShowArtifactsButton,
shouldShowMobileButton,
shouldShowSetupGuideEntry
} from './SidebarNav'
@ -131,6 +133,7 @@ function setSidebarState({
openAutomationsPage: mocks.openAutomationsPage,
openActivityPage: mocks.openActivityPage,
openMobilePage: mocks.openMobilePage,
openArtifactsPage: mocks.openArtifactsPage,
openModal: mocks.openModal,
updateSettings: mocks.updateSettings,
preflightStatus: { glab: { installed: false } },
@ -296,6 +299,37 @@ describe('SidebarNav', () => {
expect(shouldShowMobileButton({})).toBe(true)
})
it('hides the Artifacts entry by default for older settings', () => {
expect(shouldShowArtifactsButton(null)).toBe(false)
expect(shouldShowArtifactsButton({})).toBe(false)
expect(shouldShowArtifactsButton({ showArtifactsButton: true })).toBe(true)
expect(shouldShowArtifactsButton({ showArtifactsButton: false })).toBe(false)
})
it('opens Artifacts from the sidebar', async () => {
setSidebarState({
settings: { ...getDefaultSettings('/tmp'), showArtifactsButton: true }
})
const container = await renderSidebarNav()
await clickButton(getButtonByText(container, 'Artifacts'))
expect(mocks.openArtifactsPage).toHaveBeenCalledOnce()
})
it('hides Artifacts from its context menu', async () => {
setSidebarState({
settings: { ...getDefaultSettings('/tmp'), showArtifactsButton: true }
})
const container = await renderSidebarNav()
const row = getButtonByText(container, 'Artifacts')
const menu = row.closest('[data-testid="context-menu"]')
await clickButton(getHideButton(menu as Element))
expect(mocks.updateSettings).toHaveBeenCalledWith({ showArtifactsButton: false })
})
it('hides the Mobile entry when the sidebar setting is off', () => {
expect(shouldShowMobileButton({ showMobileButton: false })).toBe(false)
})

View File

@ -1,5 +1,5 @@
import React from 'react'
import { Bell, CalendarClock, EyeOff, Search, Smartphone } from 'lucide-react'
import { Bell, CalendarClock, EyeOff, Files, Search, Smartphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
@ -43,6 +43,12 @@ export function shouldShowAutomationsButton(
return settings?.showAutomationsButton !== false
}
export function shouldShowArtifactsButton(
settings: Pick<GlobalSettings, 'showArtifactsButton'> | null | undefined
): boolean {
return settings?.showArtifactsButton === true
}
const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry'))
const SidebarNav = React.memo(function SidebarNav() {
@ -53,6 +59,7 @@ const SidebarNav = React.memo(function SidebarNav() {
const openAutomationsPage = useAppStore((s) => s.openAutomationsPage)
const openActivityPage = useAppStore((s) => s.openActivityPage)
const openMobilePage = useAppStore((s) => s.openMobilePage)
const openArtifactsPage = useAppStore((s) => s.openArtifactsPage)
const openModal = useAppStore((s) => s.openModal)
const updateSettings = useAppStore((s) => s.updateSettings)
const activeView = useAppStore((s) => s.activeView)
@ -65,9 +72,11 @@ const SidebarNav = React.memo(function SidebarNav() {
const showAgentDashboardButton = (experimentalSidebarButtons & 2) !== 0
const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings))
const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings))
const showArtifactsButton = useAppStore((s) => shouldShowArtifactsButton(s.settings))
const automationsActive = activeView === 'automations'
const activityActive = activeView === 'activity'
const mobileActive = activeView === 'mobile'
const artifactsActive = activeView === 'artifacts'
const activityUnreadCount = useActivityUnreadCount(showAgentsButton, 'sidebar-badge')
const mobileOnboardingBadge = useMobileSidebarOnboardingBadge(showMobileButton)
const hideAutomationsButton = React.useCallback(() => {
@ -76,6 +85,9 @@ const SidebarNav = React.memo(function SidebarNav() {
const hideMobileButton = React.useCallback(() => {
void updateSettings({ showMobileButton: false })
}, [updateSettings])
const hideArtifactsButton = React.useCallback(() => {
void updateSettings({ showArtifactsButton: false })
}, [updateSettings])
return (
<div
@ -84,6 +96,35 @@ const SidebarNav = React.memo(function SidebarNav() {
>
<SetupGuideSidebarEntry />
<SidebarTaskNavButton />
{showArtifactsButton ? (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={openArtifactsPage}
aria-current={artifactsActive ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
artifactsActive
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
: 'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8'
)}
>
<Files
className={cn(
'size-4 shrink-0',
!artifactsActive && 'text-worktree-sidebar-foreground/30'
)}
strokeWidth={artifactsActive ? 2.25 : 1.75}
/>
<span className="flex-1">
{translate('auto.components.sidebar.SidebarNav.artifacts', 'Artifacts')}
</span>
</button>
</ContextMenuTrigger>
<HideSidebarMenu onHide={hideArtifactsButton} />
</ContextMenu>
) : null}
{showAutomationsButton ? (
<ContextMenu>
<ContextMenuTrigger asChild>

View File

@ -38,14 +38,6 @@ vi.mock('./SidebarSettingsHelpMenu', () => ({
SidebarSettingsHelpMenu: () => <button type="button">Settings</button>
}))
vi.mock('../orca-profiles/OrcaProfileSwitcher', () => ({
OrcaProfileSwitcher: ({ placement }: { placement?: string }) => (
<button type="button" data-placement={placement}>
Profile
</button>
)
}))
const roots: Root[] = []
async function renderToolbar(onWorkspaceBoardToggle = vi.fn()): Promise<{
@ -156,11 +148,10 @@ describe('SidebarToolbar moved workspace board hint', () => {
expect(container.querySelector(`button[aria-label="${localized}"]`)).not.toBeNull()
})
it('renders the profile switcher before settings in the footer controls', async () => {
it('keeps account controls out of the sidebar footer', async () => {
const { container } = await renderToolbar()
const html = container.innerHTML
expect(html).toContain('data-placement="sidebar"')
expect(html.indexOf('Profile')).toBeLessThan(html.indexOf('Settings'))
expect(container.textContent).not.toContain('Profile')
expect(container.textContent).toContain('Settings')
})
})

View File

@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton'
import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu'
import { OrcaProfileSwitcher } from '../orca-profiles/OrcaProfileSwitcher'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { hasFeatureInteraction } from '../../../../shared/feature-interactions'
@ -74,7 +73,6 @@ const SidebarToolbar = React.memo(function SidebarToolbar({
<div className="mt-auto shrink-0">
<div className="flex items-center justify-between border-t border-worktree-sidebar-border px-2 py-1.5">
<div className="flex min-w-0 items-center gap-1">
<OrcaProfileSwitcher placement="sidebar" />
<SidebarSettingsHelpMenu />
</div>
<div className="flex items-center gap-1">

View File

@ -3,15 +3,7 @@
* based on current view, tab type, and focused element.
*/
export function resolveZoomTarget(args: {
activeView:
| 'terminal'
| 'settings'
| 'tasks'
| 'activity'
| 'automations'
| 'space'
| 'skills'
| 'mobile'
activeView: TopLevelView
activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator'
activeElement: unknown
}): 'terminal' | 'editor' | 'simulator' | 'ui' {
@ -62,3 +54,4 @@ export function resolveZoomTarget(args: {
}
return 'ui'
}
import type { TopLevelView } from '../../../shared/types'

View File

@ -39,11 +39,11 @@ describe('settings navigation metadata', () => {
'orchestration',
'computer-use',
'voice',
'orca-account',
'setup-guide',
'general',
'integrations',
'mobile',
'git'
'mobile'
])
})
@ -79,15 +79,51 @@ describe('settings navigation metadata', () => {
expect(sections.find((section) => section.id === 'mobile')?.group).toBe('setup')
})
it('places Automations and Artifacts first under Workflows', () => {
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
isWebClient: false,
repos: [repo]
})
const automations = sections.find((section) => section.id === 'automations')
const artifacts = sections.find((section) => section.id === 'artifacts')
const workflowIds = sections
.filter((section) => section.group === 'workflows')
.map((section) => section.id)
expect(automations?.group).toBe('workflows')
expect(automations?.searchEntries[0]?.title).toBe('Show Automations Button')
expect(artifacts?.group).toBe('workflows')
expect(artifacts?.badge).toBe('Beta')
expect(artifacts?.description).toBe(
'Share HTML and Markdown files with your team and manage their public links.'
)
expect(workflowIds.slice(0, 2)).toEqual(['automations', 'artifacts'])
})
it('places the Orca account in Set Up on desktop only', () => {
const desktopSections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
isWebClient: false,
repos: [repo]
})
const account = desktopSections.find((section) => section.id === 'orca-account')
expect(account?.group).toBe('setup')
expect(account?.searchEntries[0]?.title).toBe('Orca account')
expect(ids({ isWebClient: true })).not.toContain('orca-account')
})
it('puts web-safe AI capability panes at the top while hiding desktop-only panes', () => {
expect(ids({ isWebClient: true }).slice(0, 7)).toEqual([
expect(ids({ isWebClient: true }).slice(0, 6)).toEqual([
'agents',
'accounts',
'orchestration',
'setup-guide',
'general',
'integrations',
'git'
'integrations'
])
})

View File

@ -11,6 +11,8 @@ import {
Bot,
Bug,
Cable,
CalendarClock,
CircleUserRound,
FlaskConical,
GitBranch,
Globe,
@ -30,6 +32,7 @@ import {
TabletSmartphone,
SquareTerminal,
TextCursorInput,
Files,
UserCog,
Wrench
} from 'lucide-react'
@ -56,6 +59,9 @@ import { getQuickCommandsPaneSearchEntries } from '@/components/settings/quick-c
import { getBrowserPaneCombinedSearchEntries } from '@/components/settings/browser-pane-search'
import { getNotificationsPaneSearchEntries } from '@/components/settings/notifications-search'
import { getOrchestrationPaneSearchEntries } from '@/components/settings/orchestration-search'
import { getArtifactsSettingsSearchEntries } from '@/components/settings/artifacts-settings-search'
import { getAutomationsSettingsSearchEntries } from '@/components/settings/automations-settings-search'
import { getOrcaAccountSettingsSearchEntries } from '@/components/settings/orca-account-settings-search'
import { getLinearAgentSkillPaneSearchEntries } from '@/components/settings/linear-agent-skill-search'
import {
getRuntimeEnvironmentsSearchEntry,
@ -231,6 +237,21 @@ export function buildSettingsNavigationMetadata({
}
]
: []),
...(showDesktopOnlySettings
? [
{
id: 'orca-account',
title: translate('auto.components.settings.orcaAccount.title', 'Orca Account'),
description: translate(
'auto.components.settings.orcaAccount.description',
'Share work instantly and reach your desktop from Orca Mobile wherever you are.'
),
icon: CircleUserRound,
searchEntries: getOrcaAccountSettingsSearchEntries(),
group: 'setup'
}
]
: []),
{
id: 'setup-guide',
title: translate(
@ -301,6 +322,29 @@ export function buildSettingsNavigationMetadata({
}
]
: []),
{
id: 'automations',
title: translate('auto.hooks.useSettingsNavigationMetadata.automationsTitle', 'Automations'),
description: translate(
'auto.hooks.useSettingsNavigationMetadata.automationsDescription',
'Schedule agent work and choose whether Automations appears in the sidebar.'
),
icon: CalendarClock,
searchEntries: getAutomationsSettingsSearchEntries(),
group: 'workflows'
},
{
id: 'artifacts',
title: translate('auto.hooks.useSettingsNavigationMetadata.artifactsTitle', 'Artifacts'),
description: translate(
'auto.hooks.useSettingsNavigationMetadata.artifactsDescription',
'Share HTML and Markdown files with your team and manage their public links.'
),
icon: Files,
searchEntries: getArtifactsSettingsSearchEntries(),
group: 'workflows',
badge: translate('auto.hooks.useSettingsNavigationMetadata.40d80bad8a', 'Beta')
},
{
id: 'git',
title: translate(

View File

@ -881,7 +881,11 @@
"linearDescription": "How Linear works in Orca, setup checklist, agent skill, and example prompts.",
"pluginsTitle": "Plugins",
"pluginsDescription": "Install and manage experimental Orca plugins.",
"tasksDescription": "Connect providers, install the Linear skill, and choose what appears in Tasks."
"tasksDescription": "Connect providers, install the Linear skill, and choose what appears in Tasks.",
"artifactsTitle": "Artifacts",
"artifactsDescription": "Share HTML and Markdown files with your team and manage their public links.",
"automationsTitle": "Automations",
"automationsDescription": "Schedule agent work and choose whether Automations appears in the sidebar."
},
"useAppMenuPaste": {
"pasteTooLarge": "Paste is too large."
@ -4427,7 +4431,8 @@
"196c1b5362": "Open GitLab tasks",
"0ccba862b8": "Open GitHub tasks",
"fee535205b": "Tasks",
"d599269755": "Hide from sidebar"
"d599269755": "Hide from sidebar",
"artifacts": "Artifacts"
},
"SidebarRepositoryFilterSection": {
"d3a9c4cea1": "Clear",
@ -10383,6 +10388,69 @@
"body": "The terminal daemon was started by an Orca install that no longer exists, so macOS cant attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.",
"openManageSessions": "Open Manage Sessions",
"title": "macOS permission grants arent reaching terminals"
},
"artifacts": {
"enable": "Enable Artifacts",
"enableDescription": "Add Artifacts to the sidebar so you can open and delete shared files.",
"account": "Orca account",
"connected": "Connected",
"signInRequired": "Sign in is required to upload and manage artifacts.",
"signingIn": "Signing in…",
"signIn": "Sign in to Orca",
"title": "Artifacts",
"description": "Share HTML and Markdown files with your team and manage their public links.",
"howToTitle": "How to use Artifacts",
"howToDescription": "Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.",
"shareStepTitle": "Ask your agent to share it",
"shareStepDescription": "For example: “Share this HTML mock as an artifact.”",
"linkStepTitle": "Share the public link",
"linkStepDescription": "Your agent returns a link that anyone with the URL can view.",
"manageStepTitle": "Manage it in Orca",
"manageStepDescription": "Open Artifacts from the sidebar to revisit or delete links owned by your account.",
"openArtifacts": "Open Artifacts",
"openArtifactsDescription": "View and delete links shared through your account.",
"showButton": "Show Artifacts Button",
"showButtonDescription": "Show the Artifacts shortcut in the sidebar.",
"openArtifactsDescriptionV2": "Preview, copy, and manage links shared through your account.",
"signInTitle": "Sign in to share artifacts",
"signInDescription": "Use your Orca account to upload artifacts and manage their public links.",
"signInAgain": "Sign in again"
},
"orcaAccount": {
"connected": "Connected",
"reconnectRequired": "Your session expired. Sign in again to use cloud features.",
"unavailable": "Orca sign-in is unavailable in this build.",
"signedOut": "Sign in to use Artifacts and Orca Relay.",
"checking": "Checking account status…",
"account": "Orca account",
"signOut": "Sign out",
"signingIn": "Signing in…",
"signInAgain": "Sign in again",
"signIn": "Sign in to Orca",
"title": "Orca Account",
"description": "Share work instantly and reach your desktop from Orca Mobile wherever you are.",
"searchDescription": "Sign in or out of the account used by Artifacts and Orca Relay.",
"benefitsTitle": "Included with your account",
"artifactsTitle": "Artifact sharing",
"artifactsDescription": "Publish HTML and Markdown files, then manage every shared link from Orca.",
"relayTitle": "Orca Relay",
"relayDescription": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi."
},
"automations": {
"showButton": "Show Automations Button",
"showButtonDescription": "Show the Automations shortcut in the sidebar.",
"howItWorksTitle": "How Automations work",
"howItWorksDescription": "Schedule agent work once, then let Orca create each run and keep its results together.",
"defineStepTitle": "Describe the work",
"defineStepDescription": "Choose a project, agent, prompt, and schedule.",
"runStepTitle": "Orca starts each run",
"runStepDescription": "The selected agent gets a fresh workspace when the schedule is due.",
"reviewStepTitle": "Review the results",
"reviewStepDescription": "Inspect recent runs and continue the work whenever you need to.",
"openAutomations": "Open Automations",
"openAutomationsDescription": "Create schedules and inspect recent runs.",
"title": "Automations",
"description": "Schedule agent work and choose whether Automations appears in the sidebar."
}
},
"right": {
@ -14649,7 +14717,7 @@
"signout": {
"confirm": {
"title": "Sign out of Orca?",
"description": "You'll be signed out of Orca on this device. Your local projects and worktrees won't be affected.",
"description": "Artifacts and Orca Relay will be unavailable until you sign in again. Your local projects and worktrees won't be affected.",
"cancel": "Cancel",
"action": "Sign out"
}
@ -14833,6 +14901,42 @@
"streamConnectionUnreachable": "Cannot reach the remote server.",
"streamRestartFailed": "Failed to restart remote browser stream.",
"streamCapabilityUnsupported": "The selected runtime does not support remote browser streaming."
},
"artifacts": {
"ArtifactsPage": {
"signInAgain": "Sign in to Orca again to load artifacts.",
"loadFailed": "Could not load artifacts.",
"deleteTitle": "Delete artifact?",
"deleteDescription": "“{{name}}” will no longer be available at its public link.",
"delete": "Delete",
"deleteFailed": "Could not delete the artifact.",
"closeArtifacts": "Close artifacts",
"closeTooltip": "Close · Esc",
"title": "Artifacts",
"refresh": "Refresh",
"signInHeading": "Sign in to Orca",
"signInCopy": "Sign in to view and manage artifacts shared through your account.",
"signingIn": "Signing in…",
"signIn": "Sign in to Orca",
"empty": "No shared artifacts",
"emptyCopy": "Ask your agent to share an HTML or Markdown file, and it will appear here.",
"openArtifact": "Open artifact",
"deleteArtifact": "Delete artifact",
"moreAvailable": "More artifacts are available",
"moreAvailableCopy": "Load the next page to continue.",
"loadMoreFailed": "Could not load more artifacts."
},
"copySuccess": "Artifact link copied",
"copyFailed": "Could not copy artifact link",
"copyLink": "Copy link",
"openInBrowser": "Open in browser",
"preview": "Artifact preview",
"previewUnavailable": "Preview unavailable",
"previewUnavailableDescription": "Open this artifact in your browser to view it.",
"actions": "Artifact actions",
"ArtifactCollection": {
"loadMore": "Load more"
}
}
},
"i18n": {

View File

@ -10,6 +10,7 @@ const RIGHT_SIDEBAR_SUPPRESSED_VIEWS = new Set<ActiveView>([
'automations',
'space',
'skills',
'artifacts',
'mobile'
])

View File

@ -38,6 +38,9 @@ const SETTINGS_NAV_TARGETS = [
'plugins',
'agents',
'orchestration',
'artifacts',
'automations',
'orca-account',
'linear',
'setup-guide',
'servers',

View File

@ -1,7 +1,11 @@
/* eslint-disable max-lines */
import { createStore, type StoreApi } from 'zustand/vanilla'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultUIState, getWorktreeCardModeProperties } from '../../../../shared/constants'
import {
getDefaultSettings,
getDefaultUIState,
getWorktreeCardModeProperties
} from '../../../../shared/constants'
import type {
GitHubWorkItem,
JiraIssue,
@ -3517,6 +3521,31 @@ describe('createUISlice space navigation', () => {
expect(store.getState().activeView).toBe('tasks')
})
it('returns to the originating view after closing Artifacts', () => {
const store = createUIStore()
store.getState().openTaskPage()
store.getState().openArtifactsPage()
expect(store.getState().activeView).toBe('artifacts')
expect(store.getState().previousViewBeforeArtifacts).toBe('tasks')
store.getState().closeArtifactsPage()
expect(store.getState().activeView).toBe('tasks')
})
it('opens and restores Artifacts when its sidebar shortcut is hidden', () => {
const store = createUIStore()
store.setState({ settings: { ...getDefaultSettings('/tmp'), showArtifactsButton: false } })
store.getState().openArtifactsPage()
expect(store.getState().activeView).toBe('artifacts')
store.getState().hydratePersistedUI(makePersistedUI({ activeView: 'artifacts' }), 'startup')
expect(store.getState().activeView).toBe('artifacts')
})
})
describe('openDiffNotesSendMenuForActiveWorktree', () => {

View File

@ -624,6 +624,7 @@ export type UISlice = {
| 'automations'
| 'space'
| 'skills'
| 'artifacts'
| 'mobile'
previousViewBeforeSettings:
| 'terminal'
@ -632,6 +633,7 @@ export type UISlice = {
| 'automations'
| 'space'
| 'skills'
| 'artifacts'
| 'mobile'
previousViewBeforeActivity:
| 'terminal'
@ -640,6 +642,7 @@ export type UISlice = {
| 'automations'
| 'space'
| 'skills'
| 'artifacts'
| 'mobile'
previousViewBeforeAutomations:
| 'terminal'
@ -648,6 +651,7 @@ export type UISlice = {
| 'activity'
| 'space'
| 'skills'
| 'artifacts'
| 'mobile'
previousViewBeforeSpace:
| 'terminal'
@ -656,6 +660,7 @@ export type UISlice = {
| 'activity'
| 'automations'
| 'skills'
| 'artifacts'
| 'mobile'
previousViewBeforeSkills:
| 'terminal'
@ -664,6 +669,7 @@ export type UISlice = {
| 'activity'
| 'automations'
| 'space'
| 'artifacts'
| 'mobile'
previousViewBeforeMobile:
| 'terminal'
@ -673,6 +679,16 @@ export type UISlice = {
| 'automations'
| 'space'
| 'skills'
| 'artifacts'
previousViewBeforeArtifacts:
| 'terminal'
| 'settings'
| 'tasks'
| 'activity'
| 'automations'
| 'space'
| 'skills'
| 'mobile'
setActiveView: (view: UISlice['activeView']) => void
taskPageData: {
preselectedRepoId?: string
@ -751,6 +767,8 @@ export type UISlice = {
closeSpacePage: () => void
openSkillsPage: () => void
closeSkillsPage: () => void
openArtifactsPage: () => void
closeArtifactsPage: () => void
openMobilePage: () => void
closeMobilePage: () => void
setNewWorkspaceDraft: (draft: NonNullable<UISlice['newWorkspaceDraft']>) => void
@ -1243,6 +1261,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
previousViewBeforeSpace: 'terminal',
previousViewBeforeSkills: 'terminal',
previousViewBeforeMobile: 'terminal',
previousViewBeforeArtifacts: 'terminal',
setActiveView: (view) => set({ activeView: view }),
taskPageData: {},
taskResumeState: undefined,
@ -1488,6 +1507,16 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set((state) => ({
activeView: state.previousViewBeforeSkills
})),
openArtifactsPage: () =>
set((state) => ({
activeView: 'artifacts',
previousViewBeforeArtifacts:
state.activeView === 'artifacts' ? state.previousViewBeforeArtifacts : state.activeView
})),
closeArtifactsPage: () =>
set((state) => ({
activeView: state.previousViewBeforeArtifacts
})),
openMobilePage: () =>
set((state) => ({
activeView: 'mobile',

View File

@ -0,0 +1,35 @@
export const REMOTE_ARTIFACT_INPUT_ENV = 'ORCA_REMOTE_ARTIFACT_INPUT'
export type RemoteArtifactInput = {
sourceKey: string
fileName: string
contentType?: 'text/html' | 'text/markdown'
}
export function normalizeRemoteArtifactInput(value: unknown): RemoteArtifactInput | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const input = value as Partial<RemoteArtifactInput>
if (
typeof input.sourceKey !== 'string' ||
!input.sourceKey ||
typeof input.fileName !== 'string' ||
!input.fileName ||
(input.contentType !== undefined && !['text/html', 'text/markdown'].includes(input.contentType))
) {
return null
}
return input as RemoteArtifactInput
}
export function parseRemoteArtifactInput(value: string | undefined): RemoteArtifactInput | null {
if (!value) {
return null
}
try {
return normalizeRemoteArtifactInput(JSON.parse(value))
} catch {
return null
}
}

View File

@ -0,0 +1,44 @@
import { open } from 'node:fs/promises'
export type ArtifactFileReadResult =
| { status: 'ok'; content: string }
| { status: 'not-file' }
| { status: 'empty' }
| { status: 'too-large' }
export async function readArtifactFileWithinLimit(
path: string,
maxBytes: number
): Promise<ArtifactFileReadResult> {
const handle = await open(path, 'r').catch(() => null)
if (!handle) {
return { status: 'not-file' }
}
try {
const fileStats = await handle.stat().catch(() => null)
if (!fileStats?.isFile()) {
return { status: 'not-file' }
}
if (fileStats.size > maxBytes) {
return { status: 'too-large' }
}
const buffer = Buffer.allocUnsafe(maxBytes + 1)
let bytesRead = 0
while (bytesRead < buffer.length) {
const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, null)
if (result.bytesRead === 0) {
break
}
bytesRead += result.bytesRead
}
if (bytesRead > maxBytes) {
return { status: 'too-large' }
}
if (bytesRead === 0) {
return { status: 'empty' }
}
return { status: 'ok', content: buffer.subarray(0, bytesRead).toString('utf8') }
} finally {
await handle.close()
}
}

49
src/shared/artifacts.ts Normal file
View File

@ -0,0 +1,49 @@
export const ARTIFACT_CLI_MAX_RPC_BYTES = 800 * 1024
export type ArtifactMetadata = {
version: 1
slug: string
title: string | null
originalFileName: string | null
sourceContentType: string
renderedContentType: 'text/html'
createdAt: string
updatedAt: string
expiresAt: string
byteSize: number
deletedAt: string | null
}
export type ArtifactListItem = {
artifact: ArtifactMetadata
shareUrl: string
}
export type ArtifactListPage = {
artifacts: readonly ArtifactListItem[]
nextCursor?: string
}
export type ArtifactWriteRequest = {
sourceKey: string
content: string
contentType: 'text/html' | 'text/markdown'
fileName: string
title?: string
apiUrl?: string
authToken?: string
}
export type ArtifactCloudOptions = {
apiUrl?: string
authToken?: string
}
export type ArtifactListOptions = ArtifactCloudOptions & {
cursor?: string
}
export type ArtifactCloudOperation<T> =
| { status: 'ok'; value: T }
| { status: 'reconnect-required' }
| { status: 'unconfigured'; message: string }

View File

@ -277,6 +277,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
showTitlebarAppName: true,
showTasksButton: true,
showAutomationsButton: true,
artifactsEnabled: true,
showArtifactsButton: false,
showMobileButton: true,
showPinnedWorktreesInGroups: false,
ctrlTabOrderMode: 'mru',

View File

@ -10,6 +10,7 @@ const TOP_LEVEL_VIEW_LOOKUP: Record<TopLevelView, true> = {
automations: true,
space: true,
skills: true,
artifacts: true,
mobile: true
}

View File

@ -2901,6 +2901,10 @@ export type GlobalSettings = {
showTasksButton: boolean
/** Only toggles the sidebar shortcut; Automations stay reachable from Settings/View menu. */
showAutomationsButton?: boolean
/** Deprecated: Artifacts are always available. Use showArtifactsButton for sidebar visibility. */
artifactsEnabled?: boolean
/** Only toggles the sidebar shortcut; Artifacts stay reachable from Settings. */
showArtifactsButton?: boolean
/** Only toggles the sidebar shortcut; Orca Mobile stays reachable from Settings. */
showMobileButton?: boolean
/** Pinned workspaces show in one sidebar location by default; opt in to also show them in their natural groups. */
@ -3371,6 +3375,7 @@ export type TopLevelView =
| 'automations'
| 'space'
| 'skills'
| 'artifacts'
| 'mobile'
export type PersistedUIState = {