Add account-backed artifact sharing (#13012)
This commit is contained in:
parent
17b3dff3c4
commit
c991bb27d3
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -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.')
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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`)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
]
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}`
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ describe('OrcaProfileSignOutConfirmDialog', () => {
|
|||
|
||||
expect(html).toContain('Sign out of Orca?')
|
||||
expect(html).toContain(
|
||||
'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.'
|
||||
)
|
||||
expect(html).not.toContain('Personal')
|
||||
expect(html).not.toContain('alert-triangle')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
]
|
||||
}
|
||||
])
|
||||
|
|
@ -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')
|
||||
]
|
||||
}
|
||||
])
|
||||
|
|
@ -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')
|
||||
]
|
||||
}
|
||||
])
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 can’t 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 aren’t 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": {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const RIGHT_SIDEBAR_SUPPRESSED_VIEWS = new Set<ActiveView>([
|
|||
'automations',
|
||||
'space',
|
||||
'skills',
|
||||
'artifacts',
|
||||
'mobile'
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ const SETTINGS_NAV_TARGETS = [
|
|||
'plugins',
|
||||
'agents',
|
||||
'orchestration',
|
||||
'artifacts',
|
||||
'automations',
|
||||
'orca-account',
|
||||
'linear',
|
||||
'setup-guide',
|
||||
'servers',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const TOP_LEVEL_VIEW_LOOKUP: Record<TopLevelView, true> = {
|
|||
automations: true,
|
||||
space: true,
|
||||
skills: true,
|
||||
artifacts: true,
|
||||
mobile: true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue