From ded700760fd7700647eec197e6742c6768a5b700 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sun, 9 Aug 2026 16:11:16 -0700
Subject: [PATCH] Add manual artifact sharing from HTML and Markdown views
(#13369)
(cherry picked from commit 639766827179ffd69f30bd03950fd3f33ce74b54)
---
src/main/artifacts/artifact-cloud-request.ts | 39 +++
.../artifact-cloud-service-races.test.ts | 32 +-
.../artifacts/artifact-cloud-service.test.ts | 166 ++++++++++
src/main/artifacts/artifact-cloud-service.ts | 212 ++++++-------
src/main/artifacts/artifact-publisher.ts | 169 ++++++++++
src/main/global-fetch-call-site-audit.test.ts | 2 +-
src/main/runtime/orca-runtime.ts | 14 +
.../runtime/rpc/methods/artifacts.test.ts | 45 +++
src/main/runtime/rpc/methods/artifacts.ts | 41 ++-
.../ssh-remote-cli-host-passthrough.test.ts | 4 +-
.../ssh/ssh-remote-cli-host-passthrough.ts | 3 +-
.../artifacts/ArtifactPublishButton.test.tsx | 206 ++++++++++++
.../artifacts/ArtifactPublishButton.tsx | 295 ++++++++++++++++++
.../artifacts/ArtifactPublishedLinkPanel.tsx | 132 ++++++++
.../artifacts/ArtifactsPage.test.tsx | 4 +-
.../components/artifacts/ArtifactsPage.tsx | 4 +-
.../artifacts/artifact-link-actions.ts | 11 +-
.../artifacts/artifact-publish-flow.test.ts | 110 +++++++
.../artifacts/artifact-publish-flow.ts | 144 +++++++++
.../artifact-published-link-client.test.ts | 38 +++
.../artifact-published-link-client.ts | 16 +
.../components/browser-pane/BrowserPane.tsx | 39 ++-
.../browser-artifact-upload.test.ts | 66 ++++
.../browser-pane/browser-artifact-upload.ts | 70 +++++
.../AgentKanbanBoard.test.tsx | 2 +-
.../src/components/editor/EditorPanel.tsx | 12 +
.../editor/EditorPanelHeader.test.tsx | 105 ++++---
.../components/editor/EditorPanelHeader.tsx | 14 +-
.../components/editor/EditorPanelShell.tsx | 4 +
.../editor/markdown-artifact-upload.test.ts | 91 ++++++
.../editor/markdown-artifact-upload.ts | 52 +++
.../settings/ArtifactsSettingsPane.test.tsx | 30 +-
.../settings/ArtifactsSettingsPane.tsx | 24 +-
.../settings/OrcaAccountSettingsPane.test.tsx | 6 +-
.../settings/OrcaAccountSettingsPane.tsx | 2 +-
.../settings/artifacts-settings-search.ts | 2 +-
src/renderer/src/i18n/locales/en.json | 72 ++++-
src/shared/artifact-cli-bridge.ts | 4 +
src/shared/artifacts.ts | 13 +
39 files changed, 2067 insertions(+), 228 deletions(-)
create mode 100644 src/main/artifacts/artifact-cloud-request.ts
create mode 100644 src/main/artifacts/artifact-publisher.ts
create mode 100644 src/main/runtime/rpc/methods/artifacts.test.ts
create mode 100644 src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx
create mode 100644 src/renderer/src/components/artifacts/ArtifactPublishButton.tsx
create mode 100644 src/renderer/src/components/artifacts/ArtifactPublishedLinkPanel.tsx
create mode 100644 src/renderer/src/components/artifacts/artifact-publish-flow.test.ts
create mode 100644 src/renderer/src/components/artifacts/artifact-publish-flow.ts
create mode 100644 src/renderer/src/components/artifacts/artifact-published-link-client.test.ts
create mode 100644 src/renderer/src/components/artifacts/artifact-published-link-client.ts
create mode 100644 src/renderer/src/components/browser-pane/browser-artifact-upload.test.ts
create mode 100644 src/renderer/src/components/browser-pane/browser-artifact-upload.ts
create mode 100644 src/renderer/src/components/editor/markdown-artifact-upload.test.ts
create mode 100644 src/renderer/src/components/editor/markdown-artifact-upload.ts
diff --git a/src/main/artifacts/artifact-cloud-request.ts b/src/main/artifacts/artifact-cloud-request.ts
new file mode 100644
index 000000000..6f1e0bb0f
--- /dev/null
+++ b/src/main/artifacts/artifact-cloud-request.ts
@@ -0,0 +1,39 @@
+import type { ArtifactWriteRequest } from '../../shared/artifacts'
+import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
+
+export function artifactWriteBody(request: ArtifactWriteRequest): Record {
+ return {
+ content: request.content,
+ contentType: request.contentType,
+ fileName: request.fileName,
+ ...(request.title ? { title: request.title } : {})
+ }
+}
+
+export async function artifactRequest(
+ apiUrl: string,
+ token: string,
+ path: string,
+ options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
+): Promise {
+ 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
+}
diff --git a/src/main/artifacts/artifact-cloud-service-races.test.ts b/src/main/artifacts/artifact-cloud-service-races.test.ts
index 2e3f34d21..c31c2a23e 100644
--- a/src/main/artifacts/artifact-cloud-service-races.test.ts
+++ b/src/main/artifacts/artifact-cloud-service-races.test.ts
@@ -58,6 +58,30 @@ afterEach(async () => {
})
describe('ArtifactCloudService same-source races', () => {
+ it('runs the next same-source operation after an earlier failure', async () => {
+ const service = await setup()
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ code: 'temporary_failure' }), {
+ status: 500,
+ headers: { 'content-type': 'application/json' }
+ })
+ )
+ .mockResolvedValueOnce(createResponse('artifact-b'))
+ vi.stubGlobal('fetch', fetchMock)
+
+ const failedShare = service.share(writeRequest)
+ const nextShare = service.share(writeRequest)
+
+ await expect(failedShare).rejects.toMatchObject({ statusCode: 500 })
+ await expect(nextShare).resolves.toMatchObject({
+ status: 'ok',
+ value: { artifact: { slug: 'artifact-b' } }
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+
it('does not let an old update overwrite a newer share mapping', async () => {
const service = await setup()
let resolveUpdate: ((response: Response) => void) | undefined
@@ -77,9 +101,11 @@ describe('ArtifactCloudService same-source races', () => {
await service.share(writeRequest)
const oldUpdate = service.update(writeRequest)
await vi.waitFor(() => expect(resolveUpdate).toBeTypeOf('function'))
- await service.share(writeRequest)
+ const newerShare = service.share(writeRequest)
+ expect(fetchMock).toHaveBeenCalledTimes(2)
resolveUpdate?.(createResponse('artifact-a'))
await oldUpdate
+ await newerShare
await service.update(writeRequest)
expect(String(fetchMock.mock.calls[3]?.[0])).toBe(`${apiUrl}/v1/artifacts/artifact-b`)
@@ -108,9 +134,11 @@ describe('ArtifactCloudService same-source races', () => {
authToken: 'token-a'
})
await vi.waitFor(() => expect(resolveDelete).toBeTypeOf('function'))
- await service.share(writeRequest)
+ const newerShare = service.share(writeRequest)
+ expect(fetchMock).toHaveBeenCalledTimes(2)
resolveDelete?.(new Response(null, { status: 204 }))
await oldUnshare
+ await newerShare
await service.update(writeRequest)
expect(String(fetchMock.mock.calls[3]?.[0])).toBe(`${apiUrl}/v1/artifacts/artifact-b`)
diff --git a/src/main/artifacts/artifact-cloud-service.test.ts b/src/main/artifacts/artifact-cloud-service.test.ts
index 6cc8f2388..75da3922f 100644
--- a/src/main/artifacts/artifact-cloud-service.test.ts
+++ b/src/main/artifacts/artifact-cloud-service.test.ts
@@ -143,6 +143,165 @@ describe('ArtifactCloudService record authorization', () => {
expect(firstKey).not.toBe(secondKey)
})
+ it('creates once and updates on repeated publish', async () => {
+ const { service } = await setup()
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(createResponse())
+ .mockResolvedValueOnce(createResponse())
+ vi.stubGlobal('fetch', fetchMock)
+
+ await expect(service.publish(writeRequest)).resolves.toMatchObject({
+ status: 'ok',
+ value: { change: 'created', item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' } }
+ })
+ await expect(service.publish(writeRequest)).resolves.toMatchObject({
+ status: 'ok',
+ value: { change: 'updated', item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' } }
+ })
+
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'POST' })
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(`${apiUrl}/v1/artifacts/artifact-a`)
+ expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' })
+ })
+
+ it('resolves a persisted public link only for its source and cloud scope', async () => {
+ const { service } = await setup()
+ const fetchMock = vi.fn().mockResolvedValueOnce(createResponse())
+ vi.stubGlobal('fetch', fetchMock)
+
+ await service.publish(writeRequest)
+
+ await expect(
+ service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
+ ).resolves.toEqual({
+ status: 'ok',
+ value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
+ })
+ await expect(
+ service.getPublishedLink({ sourceKey: '/repo/other.html', apiUrl, authToken: 'token-a' })
+ ).resolves.toEqual({ status: 'ok', value: null })
+ await expect(
+ service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-b' })
+ ).resolves.toEqual({ status: 'ok', value: null })
+ expect(fetchMock).toHaveBeenCalledOnce()
+ })
+
+ it('serializes concurrent publishes for the same source', async () => {
+ const { service } = await setup()
+ let resolveCreate: ((response: Response) => void) | undefined
+ const fetchMock = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveCreate = resolve
+ })
+ )
+ .mockResolvedValueOnce(createResponse())
+ vi.stubGlobal('fetch', fetchMock)
+
+ const first = service.publish(writeRequest)
+ const second = service.publish(writeRequest)
+ await vi.waitFor(() => expect(resolveCreate).toBeTypeOf('function'))
+ expect(fetchMock).toHaveBeenCalledOnce()
+ resolveCreate?.(createResponse())
+
+ await expect(Promise.all([first, second])).resolves.toMatchObject([
+ { status: 'ok', value: { change: 'created' } },
+ { status: 'ok', value: { change: 'updated' } }
+ ])
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' })
+ })
+
+ it('serializes manual publish with CLI share for the same source', async () => {
+ const { service } = await setup()
+ let resolvePublish: ((response: Response) => void) | undefined
+ const fetchMock = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolvePublish = resolve
+ })
+ )
+ .mockResolvedValueOnce(createResponse('artifact-b'))
+ vi.stubGlobal('fetch', fetchMock)
+
+ const publish = service.publish(writeRequest)
+ const share = service.share(writeRequest)
+ await vi.waitFor(() => expect(resolvePublish).toBeTypeOf('function'))
+ expect(fetchMock).toHaveBeenCalledOnce()
+ resolvePublish?.(createResponse('artifact-a'))
+
+ await expect(Promise.all([publish, share])).resolves.toMatchObject([
+ { status: 'ok', value: { change: 'created' } },
+ { status: 'ok', value: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
+ ])
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('serializes account deletion with a mapped source update', async () => {
+ const { service } = await setup()
+ let resolveUpdate: ((response: Response) => void) | undefined
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(createResponse())
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveUpdate = resolve
+ })
+ )
+ .mockResolvedValueOnce(new Response(null, { status: 204 }))
+ vi.stubGlobal('fetch', fetchMock)
+ await service.share(writeRequest)
+
+ const update = service.update(writeRequest)
+ await vi.waitFor(() => expect(resolveUpdate).toBeTypeOf('function'))
+ const deletion = service.delete('artifact-a', { apiUrl, authToken: 'token-a' })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ resolveUpdate?.(createResponse())
+
+ await expect(Promise.all([update, deletion])).resolves.toMatchObject([
+ { status: 'ok' },
+ { status: 'ok' }
+ ])
+ await expect(
+ service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
+ ).resolves.toEqual({ status: 'ok', value: null })
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ })
+
+ it('recreates an artifact when its stored public link was deleted elsewhere', async () => {
+ const { service } = await setup()
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(createResponse('artifact-a'))
+ .mockResolvedValueOnce(new Response(JSON.stringify({ code: 'not_found' }), { status: 404 }))
+ .mockResolvedValueOnce(createResponse('artifact-b'))
+ .mockResolvedValueOnce(createResponse('artifact-b'))
+ vi.stubGlobal('fetch', fetchMock)
+
+ await service.publish(writeRequest)
+ await expect(service.publish(writeRequest)).resolves.toMatchObject({
+ status: 'ok',
+ value: { change: 'created', item: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
+ })
+ await expect(service.publish(writeRequest)).resolves.toMatchObject({
+ status: 'ok',
+ value: { change: 'updated', item: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
+ })
+
+ expect(fetchMock.mock.calls.map(([, options]) => options?.method)).toEqual([
+ 'POST',
+ 'PUT',
+ 'POST',
+ 'PUT'
+ ])
+ })
+
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')
@@ -327,6 +486,7 @@ describe('ArtifactCloudService publish capability gate', () => {
it.each([
['share', (service: ArtifactCloudService) => service.share(writeRequest)],
+ ['publish', (service: ArtifactCloudService) => service.publish(writeRequest)],
['update', (service: ArtifactCloudService) => service.update(writeRequest)]
])('rejects %s without reaching the network when the capability is off', async (_name, call) => {
const { service } = await setup({ value: false })
@@ -381,6 +541,12 @@ describe('ArtifactCloudService publish capability gate', () => {
await expect(service.list({ apiUrl, authToken: 'token-a' })).resolves.toMatchObject({
status: 'ok'
})
+ await expect(
+ service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
+ ).resolves.toEqual({
+ status: 'ok',
+ value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
+ })
await expect(
service.unshare({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
).resolves.toEqual({ status: 'ok', value: undefined })
diff --git a/src/main/artifacts/artifact-cloud-service.ts b/src/main/artifacts/artifact-cloud-service.ts
index 78ec82d30..6abb0525e 100644
--- a/src/main/artifacts/artifact-cloud-service.ts
+++ b/src/main/artifacts/artifact-cloud-service.ts
@@ -5,12 +5,13 @@ import type {
ArtifactListOptions,
ArtifactListPage,
ArtifactListItem,
+ ArtifactPublishedLink,
+ ArtifactPublishResult,
ArtifactWriteRequest
} from '../../shared/artifacts'
import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate'
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,
@@ -22,12 +23,11 @@ import {
getArtifactShareRecord,
isArtifactShareLifecycleCurrent,
refreshArtifactShareRecordExpiration,
- removeArtifactShareRecords,
- saveArtifactShareRecord
+ removeArtifactShareRecords
} from './artifact-share-record-store'
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
-
-type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
+import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
+import { ArtifactPublisher } from './artifact-publisher'
type ArtifactAuthContext = {
profileId: string
@@ -114,6 +114,8 @@ function explicitTokenAuthContext(
}
export class ArtifactCloudService {
+ private readonly publisher: ArtifactPublisher
+
/**
* `isSharingEnabled` is the publish capability gate. It is read per call, never cached, so
* revoking it in Settings takes effect on the next request. List, unshare, and delete stay
@@ -122,7 +124,9 @@ export class ArtifactCloudService {
constructor(
private readonly userDataPath: string,
private readonly isSharingEnabled: () => boolean
- ) {}
+ ) {
+ this.publisher = new ArtifactPublisher(userDataPath)
+ }
list(options: ArtifactListOptions): Promise> {
return this.withAuth(options, async (token, apiUrl) => {
@@ -131,88 +135,121 @@ export class ArtifactCloudService {
})
}
+ getPublishedLink(
+ request: ArtifactCloudOptions & { sourceKey: string }
+ ): Promise> {
+ return this.withAuth(request, async (_token, _apiUrl, auth) => {
+ const record = getArtifactShareRecord(
+ auth.profileId,
+ this.userDataPath,
+ request.sourceKey,
+ auth.scope
+ )
+ return record ? { shareUrl: record.shareUrl } : null
+ })
+ }
+
// Why async: the gate must surface as a rejection, not a synchronous throw, so every caller's
// promise chain handles it the same way.
async share(request: ArtifactWriteRequest): Promise> {
assertArtifactSharingAllowed(this.isSharingEnabled)
const idempotencyKey = randomUUID()
- return this.withAuth(request, async (token, apiUrl, auth) => {
- const response = await artifactRequest(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 }
- })
+ return this.withAuth(request, (token, apiUrl, auth) =>
+ this.publisher.share(request, token, apiUrl, auth, idempotencyKey)
+ )
+ }
+
+ async publish(
+ request: ArtifactWriteRequest
+ ): Promise> {
+ assertArtifactSharingAllowed(this.isSharingEnabled)
+ const idempotencyKey = randomUUID()
+ return this.withAuth(request, (token, apiUrl, auth) =>
+ this.publisher.publish(request, token, apiUrl, auth, idempotencyKey)
+ )
}
async update(request: ArtifactWriteRequest): Promise> {
assertArtifactSharingAllowed(this.isSharingEnabled)
- 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(apiUrl, token, `/${record.slug}`, {
- method: 'PUT',
- editToken: record.editToken,
- body: writeBody(request)
+ return this.withAuth(request, (token, apiUrl, auth) =>
+ this.publisher.runForSource(request.sourceKey, auth, async () => {
+ auth.assertCurrent()
+ 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.')
+ }
+ return this.publisher.runForSlug(record.slug, auth, async () => {
+ auth.assertCurrent()
+ const response = await artifactRequest(
+ apiUrl,
+ token,
+ `/${record.slug}`,
+ {
+ method: 'PUT',
+ editToken: record.editToken,
+ body: artifactWriteBody(request)
+ }
+ )
+ auth.assertCurrent()
+ refreshArtifactShareRecordExpiration(
+ auth.profileId,
+ this.userDataPath,
+ request.sourceKey,
+ auth.scope,
+ record,
+ response.artifact.expiresAt
+ )
+ return response
+ })
})
- auth.assertCurrent()
- refreshArtifactShareRecordExpiration(
- auth.profileId,
- this.userDataPath,
- request.sourceKey,
- auth.scope,
- record,
- response.artifact.expiresAt
- )
- return response
- })
+ )
}
unshare(
request: ArtifactCloudOptions & { sourceKey: string }
): Promise> {
- 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(apiUrl, token, `/${record.slug}`, {
- method: 'DELETE',
- editToken: record.editToken
+ return this.withAuth(request, (token, apiUrl, auth) =>
+ this.publisher.runForSource(request.sourceKey, auth, async () => {
+ auth.assertCurrent()
+ 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.')
+ }
+ return this.publisher.runForSlug(record.slug, auth, async () => {
+ auth.assertCurrent()
+ await artifactRequest(apiUrl, token, `/${record.slug}`, {
+ method: 'DELETE',
+ editToken: record.editToken
+ })
+ removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
+ sourceKey: request.sourceKey,
+ slug: record.slug
+ })
+ })
})
- removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
- sourceKey: request.sourceKey,
- slug: record.slug
- })
- })
+ )
}
delete(id: string, options: ArtifactCloudOptions): Promise> {
- return this.withAuth(options, async (token, apiUrl, auth) => {
- await artifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`, { method: 'DELETE' })
- removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
- })
+ return this.withAuth(options, (token, apiUrl, auth) =>
+ this.publisher.runForSlug(id, auth, async () => {
+ auth.assertCurrent()
+ await artifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`, {
+ method: 'DELETE'
+ })
+ removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
+ })
+ )
}
private async withAuth(
@@ -256,40 +293,3 @@ export class ArtifactCloudService {
: { status: 'reconnect-required' }
}
}
-
-function writeBody(request: ArtifactWriteRequest): Record {
- return {
- content: request.content,
- contentType: request.contentType,
- fileName: request.fileName,
- ...(request.title ? { title: request.title } : {})
- }
-}
-
-async function artifactRequest(
- apiUrl: string,
- token: string,
- path: string,
- options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
-): Promise {
- 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
-}
diff --git a/src/main/artifacts/artifact-publisher.ts b/src/main/artifacts/artifact-publisher.ts
new file mode 100644
index 000000000..8f03c4e5b
--- /dev/null
+++ b/src/main/artifacts/artifact-publisher.ts
@@ -0,0 +1,169 @@
+import type {
+ ArtifactListItem,
+ ArtifactPublishResult,
+ ArtifactWriteRequest
+} from '../../shared/artifacts'
+import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
+import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
+import {
+ type ArtifactShareScope,
+ getArtifactShareRecord,
+ refreshArtifactShareRecordExpiration,
+ removeArtifactShareRecords,
+ saveArtifactShareRecord
+} from './artifact-share-record-store'
+
+type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
+
+type ArtifactPublishAuthContext = {
+ profileId: string
+ scope: ArtifactShareScope
+ assertCurrent: () => void
+}
+
+function artifactOperationQueueKey(
+ kind: 'source' | 'slug',
+ auth: Pick,
+ identity: string
+): string {
+ return JSON.stringify([
+ kind,
+ auth.profileId,
+ auth.scope.cloudUserId,
+ auth.scope.cloudProfileId,
+ auth.scope.cloudOrganizationId,
+ auth.scope.apiOrigin,
+ identity
+ ])
+}
+
+export class ArtifactPublisher {
+ private readonly queues = new Map>()
+
+ constructor(private readonly userDataPath: string) {}
+
+ async share(
+ request: ArtifactWriteRequest,
+ token: string,
+ apiUrl: string,
+ auth: ArtifactPublishAuthContext,
+ idempotencyKey: string
+ ): Promise {
+ return this.runForSource(request.sourceKey, auth, async () => {
+ auth.assertCurrent()
+ return (await this.create(request, token, apiUrl, auth, idempotencyKey)).item
+ })
+ }
+
+ publish(
+ request: ArtifactWriteRequest,
+ token: string,
+ apiUrl: string,
+ auth: ArtifactPublishAuthContext,
+ idempotencyKey: string
+ ): Promise {
+ return this.runForSource(request.sourceKey, auth, async () => {
+ auth.assertCurrent()
+ const record = getArtifactShareRecord(
+ auth.profileId,
+ this.userDataPath,
+ request.sourceKey,
+ auth.scope
+ )
+ if (record) {
+ try {
+ return await this.runForSlug(record.slug, auth, async () => {
+ auth.assertCurrent()
+ const item = await artifactRequest(apiUrl, token, `/${record.slug}`, {
+ method: 'PUT',
+ editToken: record.editToken,
+ body: artifactWriteBody(request)
+ })
+ auth.assertCurrent()
+ refreshArtifactShareRecordExpiration(
+ auth.profileId,
+ this.userDataPath,
+ request.sourceKey,
+ auth.scope,
+ record,
+ item.artifact.expiresAt
+ )
+ return { change: 'updated', item }
+ })
+ } catch (error) {
+ if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 404) {
+ throw error
+ }
+ auth.assertCurrent()
+ removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
+ sourceKey: request.sourceKey,
+ slug: record.slug
+ })
+ }
+ }
+ return this.create(request, token, apiUrl, auth, idempotencyKey)
+ })
+ }
+
+ runForSource(
+ sourceKey: string,
+ auth: Pick,
+ operation: () => Promise
+ ): Promise {
+ return this.runSerialized(artifactOperationQueueKey('source', auth, sourceKey), operation)
+ }
+
+ runForSlug(
+ slug: string,
+ auth: Pick,
+ operation: () => Promise
+ ): Promise {
+ return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation)
+ }
+
+ private async create(
+ request: ArtifactWriteRequest,
+ token: string,
+ apiUrl: string,
+ auth: ArtifactPublishAuthContext,
+ idempotencyKey: string
+ ): Promise {
+ const response = await artifactRequest(apiUrl, token, '', {
+ method: 'POST',
+ body: artifactWriteBody(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 {
+ change: 'created',
+ item: { artifact: response.artifact, shareUrl: response.shareUrl }
+ }
+ }
+
+ private async runSerialized(key: string, operation: () => Promise): Promise {
+ const previous = this.queues.get(key) ?? Promise.resolve()
+ let release = (): void => {}
+ const released = new Promise((resolve) => {
+ release = resolve
+ })
+ const ready = previous.catch(() => {})
+ const current = ready.then(() => released)
+ this.queues.set(key, current)
+ await ready
+ try {
+ return await operation()
+ } finally {
+ release()
+ if (this.queues.get(key) === current) {
+ this.queues.delete(key)
+ }
+ }
+ }
+}
diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts
index a2c8c688a..625d9fa2b 100644
--- a/src/main/global-fetch-call-site-audit.test.ts
+++ b/src/main/global-fetch-call-site-audit.test.ts
@@ -15,7 +15,7 @@ import { describe, expect, it } from 'vitest'
// and update the count.
const AUDITED_GLOBAL_FETCH_LINES = new Map([
// HTTP call sites — body consumed or cancelled on every path, including !ok
- ['main/artifacts/artifact-cloud-service.ts', 1],
+ ['main/artifacts/artifact-cloud-request.ts', 1],
['main/azure-devops/azure-devops-api-request.ts', 1],
['main/bitbucket/client.ts', 1],
['main/gitea/client.ts', 1],
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 0ba68b82d..7356b583b 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -126,6 +126,8 @@ import type {
ArtifactListOptions,
ArtifactListPage,
ArtifactListItem,
+ ArtifactPublishedLink,
+ ArtifactPublishResult,
ArtifactWriteRequest
} from '../../shared/artifacts'
import type { ArtifactCloudService } from '../artifacts/artifact-cloud-service'
@@ -4638,10 +4640,22 @@ export class OrcaRuntimeService {
return this.requireArtifactService().list(options)
}
+ getPublishedArtifactLink(
+ request: ArtifactCloudOptions & { sourceKey: string }
+ ): Promise> {
+ return this.requireArtifactService().getPublishedLink(request)
+ }
+
shareArtifact(request: ArtifactWriteRequest): Promise> {
return this.requireArtifactService().share(request)
}
+ publishArtifact(
+ request: ArtifactWriteRequest
+ ): Promise> {
+ return this.requireArtifactService().publish(request)
+ }
+
updateArtifact(request: ArtifactWriteRequest): Promise> {
return this.requireArtifactService().update(request)
}
diff --git a/src/main/runtime/rpc/methods/artifacts.test.ts b/src/main/runtime/rpc/methods/artifacts.test.ts
new file mode 100644
index 000000000..2a84cf94d
--- /dev/null
+++ b/src/main/runtime/rpc/methods/artifacts.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
+import { ARTIFACT_METHODS } from './artifacts'
+
+const validRequest = {
+ sourceKey: '/repo/report.html',
+ content: 'Report ',
+ contentType: 'text/html',
+ fileName: 'report.html'
+}
+
+function writeSchema(name: string) {
+ const method = ARTIFACT_METHODS.find((candidate) => candidate.name === name)
+ if (!method?.params) {
+ throw new Error(`Missing ${name} schema`)
+ }
+ return method.params
+}
+
+describe('artifact RPC schemas', () => {
+ it('registers the local publish upsert', () => {
+ expect(writeSchema('artifacts.publish').safeParse(validRequest).success).toBe(true)
+ })
+
+ it('registers the persisted-link lookup', () => {
+ const schema = writeSchema('artifacts.getPublishedLink')
+ expect(schema.safeParse({ sourceKey: validRequest.sourceKey }).success).toBe(true)
+ expect(schema.safeParse({ sourceKey: '' }).success).toBe(false)
+ })
+
+ it('rejects empty and oversized artifact requests', () => {
+ const schema = writeSchema('artifacts.publish')
+ expect(schema.safeParse({ ...validRequest, content: '' }).success).toBe(false)
+ expect(
+ schema.safeParse({ ...validRequest, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES + 1) })
+ .success
+ ).toBe(false)
+ expect(
+ schema.safeParse({
+ ...validRequest,
+ content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2))
+ }).success
+ ).toBe(false)
+ })
+})
diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts
index 4712b273b..79d99ba7d 100644
--- a/src/main/runtime/rpc/methods/artifacts.ts
+++ b/src/main/runtime/rpc/methods/artifacts.ts
@@ -1,9 +1,13 @@
import { z } from 'zod'
+import {
+ ARTIFACT_CLI_MAX_RPC_BYTES,
+ artifactWriteRequestByteLength
+} from '../../../../shared/artifacts'
import { defineMethod, type RpcAnyMethod } from '../core'
const CloudOptions = {
- apiUrl: z.string().optional(),
- authToken: z.string().optional()
+ apiUrl: z.string().max(2_048).optional(),
+ authToken: z.string().max(16_384).optional()
}
const ListOptions = z.object({
@@ -11,26 +15,45 @@ const ListOptions = z.object({
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(),
+const SourceRequest = z.object({
+ sourceKey: z.string().min(1).max(32_768),
...CloudOptions
})
+const WriteRequest = z
+ .object({
+ sourceKey: z.string().min(1).max(32_768),
+ content: z.string().min(1).max(ARTIFACT_CLI_MAX_RPC_BYTES),
+ contentType: z.enum(['text/html', 'text/markdown']),
+ fileName: z.string().min(1).max(512),
+ title: z.string().max(512).optional(),
+ ...CloudOptions
+ })
+ .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_CLI_MAX_RPC_BYTES, {
+ message: 'Artifact request exceeds the local RPC size limit.'
+ })
+
export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
defineMethod({
name: 'artifacts.list',
params: ListOptions,
handler: (params, { runtime }) => runtime.listArtifacts(params)
}),
+ defineMethod({
+ name: 'artifacts.getPublishedLink',
+ params: SourceRequest,
+ handler: (params, { runtime }) => runtime.getPublishedArtifactLink(params)
+ }),
defineMethod({
name: 'artifacts.share',
params: WriteRequest,
handler: (params, { runtime }) => runtime.shareArtifact(params)
}),
+ defineMethod({
+ name: 'artifacts.publish',
+ params: WriteRequest,
+ handler: (params, { runtime }) => runtime.publishArtifact(params)
+ }),
defineMethod({
name: 'artifacts.update',
params: WriteRequest,
@@ -38,7 +61,7 @@ export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
}),
defineMethod({
name: 'artifacts.unshare',
- params: z.object({ sourceKey: z.string().min(1), ...CloudOptions }),
+ params: SourceRequest,
handler: (params, { runtime }) => runtime.unshareArtifact(params)
}),
defineMethod({
diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts
index 67084cc6b..b4d7ca07f 100644
--- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts
+++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts
@@ -135,8 +135,8 @@ describe('buildHostCliEnv', () => {
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(JSON.parse(first.sourceKey)).toEqual(['ssh', 'host-a', '/srv/repo/report.html'])
+ expect(JSON.parse(second.sourceKey)).toEqual(['ssh', 'host-b', '/srv/repo/report.html'])
expect(first.fileName).toBe('report.html')
})
})
diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts
index bf480c13e..7749584a6 100644
--- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts
+++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts
@@ -25,6 +25,7 @@ import {
} from '../../shared/orchestration-compatibility-evidence'
import {
REMOTE_ARTIFACT_INPUT_ENV,
+ sshArtifactSourceKey,
type RemoteArtifactInput
} from '../../shared/artifact-cli-bridge'
@@ -178,7 +179,7 @@ export function buildHostCliEnv(args: {
}
if (args.artifactInput) {
const sourceKey = args.runtimeAuthority
- ? JSON.stringify(['ssh', args.runtimeAuthority.targetId, args.artifactInput.sourceKey])
+ ? sshArtifactSourceKey(args.runtimeAuthority.targetId, args.artifactInput.sourceKey)
: args.artifactInput.sourceKey
env[REMOTE_ARTIFACT_INPUT_ENV] = JSON.stringify({ ...args.artifactInput, sourceKey })
}
diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx
new file mode 100644
index 000000000..630c05b98
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx
@@ -0,0 +1,206 @@
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+import type { ReactNode } from 'react'
+import { cleanup, render, screen, waitFor } 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(),
+ openSettingsPage: vi.fn(),
+ openSettingsTarget: vi.fn(),
+ getPublishedLink: vi.fn(),
+ copyLink: vi.fn(),
+ openLink: vi.fn(),
+ publish: vi.fn(),
+ openPopover: null as ((open: boolean) => void) | null,
+ state: {
+ orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record,
+ orcaProfileConnecting: false,
+ settings: { artifactSharingEnabled: true }
+ }
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: Record) => unknown) =>
+ selector({
+ ...mocks.state,
+ connectCurrentOrcaProfile: mocks.connect,
+ openSettingsPage: mocks.openSettingsPage,
+ openSettingsTarget: mocks.openSettingsTarget
+ })
+}))
+
+vi.mock('@/components/ui/popover', () => ({
+ Popover: ({
+ children,
+ onOpenChange
+ }: {
+ children: ReactNode
+ onOpenChange?: (open: boolean) => void
+ }) => {
+ mocks.openPopover = onOpenChange ?? null
+ return <>{children}>
+ },
+ PopoverContent: ({ children }: { children: ReactNode }) => {children}
,
+ PopoverTrigger: ({ children }: { children: ReactNode }) => (
+ mocks.openPopover?.(true)}>{children}
+ )
+}))
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: ReactNode }) => {children}
,
+ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}>
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+vi.mock('./artifact-publish-flow', () => ({
+ publishArtifactFromSurface: mocks.publish
+}))
+vi.mock('./artifact-published-link-client', () => ({
+ getPublishedArtifactLink: mocks.getPublishedLink
+}))
+vi.mock('./artifact-link-actions', () => ({
+ copyArtifactLink: mocks.copyLink,
+ openArtifactInBrowser: mocks.openLink
+}))
+
+import { ArtifactPublishButton } from './ArtifactPublishButton'
+
+describe('ArtifactPublishButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.publish.mockResolvedValue({
+ change: 'created',
+ item: { shareUrl: 'https://example.com' }
+ })
+ mocks.getPublishedLink.mockResolvedValue(null)
+ mocks.copyLink.mockResolvedValue(true)
+ mocks.openPopover = null
+ mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
+ mocks.state.orcaProfileConnecting = false
+ mocks.state.settings = { artifactSharingEnabled: true }
+ })
+
+ afterEach(cleanup)
+
+ it('requires explicit confirmation before publishing', async () => {
+ const user = userEvent.setup()
+ const createRequest = vi.fn()
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
+ expect(mocks.publish).not.toHaveBeenCalled()
+
+ await user.click(await screen.findByRole('button', { name: 'Share public link' }))
+ await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(createRequest))
+ expect(screen.getByText('https://example.com')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Update shared content' })).toBeInTheDocument()
+ })
+
+ it('offers sign-in and blocks confirmation while signed out', async () => {
+ const user = userEvent.setup()
+ mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
+ render( )
+
+ expect(await screen.findByRole('button', { name: 'Share public link' })).toBeDisabled()
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+
+ expect(mocks.connect).toHaveBeenCalledOnce()
+ expect(mocks.publish).not.toHaveBeenCalled()
+ })
+
+ it('routes disabled publishing to Artifacts settings', async () => {
+ const user = userEvent.setup()
+ mocks.state.settings = { artifactSharingEnabled: false }
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
+ expect(await screen.findByRole('button', { name: 'Share public link' })).toBeDisabled()
+ await user.click(screen.getByRole('button', { name: 'Open Artifacts settings' }))
+
+ expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ pane: 'artifacts', repoId: null })
+ expect(mocks.openSettingsPage).toHaveBeenCalledOnce()
+ expect(mocks.publish).not.toHaveBeenCalled()
+ })
+
+ it('hides the settings prompt once publishing is enabled', () => {
+ render( )
+
+ expect(screen.queryByText('Artifact sharing is off')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Open Artifacts settings' })).toBeNull()
+ })
+
+ it('shows and manages an existing public link', async () => {
+ const user = userEvent.setup()
+ const createRequest = vi.fn()
+ mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
+ mocks.publish.mockResolvedValue({
+ change: 'updated',
+ item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
+ })
+
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
+ expect(await screen.findByText('https://share.onorca.dev/a/artifact-a')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Copy link' }))
+ expect(mocks.copyLink).toHaveBeenCalledWith('https://share.onorca.dev/a/artifact-a', {
+ showSuccessToast: false
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Update shared content' }))
+ await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(createRequest))
+ })
+
+ it('keeps existing links available when publishing is disabled', async () => {
+ const user = userEvent.setup()
+ mocks.state.settings = { artifactSharingEnabled: false }
+ mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
+
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
+ expect(await screen.findByRole('button', { name: 'Copy link' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Update shared content' })).toBeNull()
+ expect(screen.getByRole('button', { name: 'Open Artifacts settings' })).toBeInTheDocument()
+ })
+
+ it('looks up a persisted link only after the popover opens', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ expect(mocks.getPublishedLink).not.toHaveBeenCalled()
+ await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
+
+ await waitFor(() => expect(mocks.getPublishedLink).toHaveBeenCalledOnce())
+ })
+
+ it('does not start copy feedback after the panel unmounts', async () => {
+ const user = userEvent.setup()
+ let finishCopy: ((copied: boolean) => void) | undefined
+ mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
+ mocks.copyLink.mockReturnValue(
+ new Promise((resolve) => {
+ finishCopy = resolve
+ })
+ )
+ const timeoutSpy = vi.spyOn(window, 'setTimeout')
+ const view = render(
+
+ )
+ await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
+ await user.click(await screen.findByRole('button', { name: 'Copy link' }))
+
+ view.unmount()
+ finishCopy?.(true)
+ await Promise.resolve()
+
+ expect(timeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 1_500)
+ })
+})
diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx
new file mode 100644
index 000000000..052a2e8e9
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx
@@ -0,0 +1,295 @@
+import { useEffect, useRef, useState } from 'react'
+import { ArrowRight, Loader2, Share2 } from 'lucide-react'
+import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { translate } from '@/i18n/i18n'
+import { cn } from '@/lib/utils'
+import { useAppStore } from '@/store'
+import { ArtifactPublishedLinkPanel } from './ArtifactPublishedLinkPanel'
+import { getPublishedArtifactLink } from './artifact-published-link-client'
+import { publishArtifactFromSurface } from './artifact-publish-flow'
+
+type PublishedLinkLookup = {
+ key: string
+ status: 'loading' | 'loaded' | 'error'
+ shareUrl: string | null
+}
+
+export function ArtifactPublishButton({
+ sourceKey,
+ createRequest,
+ className,
+ disabled
+}: {
+ sourceKey: string
+ createRequest: () => Promise
+ className?: string
+ disabled?: boolean
+}): React.JSX.Element {
+ const [open, setOpen] = useState(false)
+ const [publishing, setPublishing] = useState(false)
+ const [lookupRevision, setLookupRevision] = useState(0)
+ const [linkLookup, setLinkLookup] = useState(null)
+ const lookupSequence = useRef(0)
+ const popoverContentRef = useRef(null)
+ const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
+ const connecting = useAppStore((state) => state.orcaProfileConnecting)
+ const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
+ const openSettingsPage = useAppStore((state) => state.openSettingsPage)
+ const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
+ const settings = useAppStore((state) => state.settings)
+ const signedIn = authStatus?.state === 'connected'
+ const sharingEnabled = settings?.artifactSharingEnabled === true
+ const accountKey =
+ authStatus?.state === 'connected'
+ ? JSON.stringify([
+ authStatus.activeProfileId,
+ authStatus.cloud?.userId ?? null,
+ authStatus.cloud?.cloudProfileId ?? null,
+ authStatus.cloud?.activeOrgId ?? null
+ ])
+ : null
+ const lookupKey = accountKey ? JSON.stringify([accountKey, sourceKey]) : null
+ const currentLookup = linkLookup?.key === lookupKey ? linkLookup : null
+ const checkingLink =
+ signedIn && currentLookup?.status !== 'loaded' && currentLookup?.status !== 'error'
+ const publishedLink = currentLookup?.status === 'loaded' ? currentLookup.shareUrl : null
+ const busy = publishing || connecting
+ const blocked = disabled || busy
+
+ useEffect(() => {
+ const sequence = ++lookupSequence.current
+ if (!open || !lookupKey) {
+ setLinkLookup(null)
+ return
+ }
+ setLinkLookup({ key: lookupKey, status: 'loading', shareUrl: null })
+ void getPublishedArtifactLink(sourceKey)
+ .then((shareUrl) => {
+ if (lookupSequence.current === sequence) {
+ setLinkLookup({ key: lookupKey, status: 'loaded', shareUrl })
+ }
+ })
+ .catch((error: unknown) => {
+ console.error('Failed to check published artifact link:', error)
+ if (lookupSequence.current === sequence) {
+ setLinkLookup({ key: lookupKey, status: 'error', shareUrl: null })
+ }
+ })
+ return () => {
+ lookupSequence.current += 1
+ }
+ }, [lookupKey, lookupRevision, open, sourceKey])
+
+ const publish = async (): Promise => {
+ if (blocked || !signedIn || !sharingEnabled) {
+ return
+ }
+ setPublishing(true)
+ try {
+ const result = await publishArtifactFromSurface(createRequest)
+ if (result) {
+ if (lookupKey) {
+ setLinkLookup({ key: lookupKey, status: 'loaded', shareUrl: result.item.shareUrl })
+ }
+ }
+ } finally {
+ setPublishing(false)
+ }
+ }
+
+ const openArtifactsSettings = (): void => {
+ setOpen(false)
+ openSettingsTarget({ pane: 'artifacts', repoId: null })
+ openSettingsPage()
+ }
+
+ const label = translate(
+ 'auto.components.artifacts.ArtifactPublishButton.a4a49da6af',
+ 'Share as artifact'
+ )
+ return (
+ !busy && setOpen(nextOpen)}>
+
+
+
+
+ {publishing ? : }
+
+
+
+
+ {label}
+
+
+
+ {
+ event.preventDefault()
+ popoverContentRef.current?.focus({ preventScroll: true })
+ }}
+ >
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishButton.confirmTitle',
+ 'Share as artifact'
+ )}
+
+
+ {publishedLink
+ ? translate(
+ 'auto.components.artifacts.ArtifactPublishButton.publishedDescription',
+ 'Anyone with this link can view the shared file.'
+ )
+ : translate(
+ 'auto.components.artifacts.ArtifactPublishButton.confirmDescription',
+ 'This publishes the current file at a link anyone with the URL can view.'
+ )}
+
+
+
+
+ {!signedIn ? (
+
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishButton.accountTitle',
+ 'Orca account'
+ )}
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishButton.accountDescription',
+ 'Sign in to create and manage this link.'
+ )}
+
+
+
void connect()}
+ >
+ {connecting
+ ? translate(
+ 'auto.components.artifacts.ArtifactPublishButton.signingIn',
+ 'Signing in…'
+ )
+ : authStatus?.state === 'reconnect-required'
+ ? translate(
+ 'auto.components.artifacts.ArtifactPublishButton.signInAgain',
+ 'Sign in again'
+ )
+ : translate(
+ 'auto.components.artifacts.ArtifactPublishButton.signIn',
+ 'Sign in'
+ )}
+
+
+ ) : null}
+
+ {!sharingEnabled ? (
+
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishButton.publishingOffTitle',
+ 'Artifact sharing is off'
+ )}
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishButton.publishingOffDescription',
+ 'Learn about public links and enable sharing in Settings.'
+ )}
+
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishButton.openSettings',
+ 'Open Artifacts settings'
+ )}
+
+
+
+ ) : null}
+
+ {checkingLink ? (
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishButton.checkingLink',
+ 'Checking for an existing link…'
+ )}
+
+ ) : currentLookup?.status === 'error' ? (
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishButton.checkFailed',
+ 'Could not check for an existing link.'
+ )}
+
+
setLookupRevision((current) => current + 1)}
+ >
+ {translate('auto.components.artifacts.ArtifactPublishButton.tryAgain', 'Try again')}
+
+
+ ) : publishedLink ? (
+
void publish()}
+ />
+ ) : (
+ void publish()}
+ >
+ {publishing ? : }
+ {publishing
+ ? translate('auto.components.artifacts.ArtifactPublishButton.sharing', 'Sharing…')
+ : translate(
+ 'auto.components.artifacts.ArtifactPublishButton.sharePublicLink',
+ 'Share public link'
+ )}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/artifacts/ArtifactPublishedLinkPanel.tsx b/src/renderer/src/components/artifacts/ArtifactPublishedLinkPanel.tsx
new file mode 100644
index 000000000..4e5aa292b
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactPublishedLinkPanel.tsx
@@ -0,0 +1,132 @@
+import { useCallback, useRef, useState } from 'react'
+import { Check, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { translate } from '@/i18n/i18n'
+import { copyArtifactLink, openArtifactInBrowser } from './artifact-link-actions'
+
+export function ArtifactPublishedLinkPanel({
+ shareUrl,
+ publishing,
+ sharingEnabled,
+ onUpdate
+}: {
+ shareUrl: string
+ publishing: boolean
+ sharingEnabled: boolean
+ onUpdate: () => void
+}): React.JSX.Element {
+ const [copied, setCopied] = useState(false)
+ const copiedResetTimerRef = useRef(null)
+ const mountedRef = useRef(false)
+
+ const clearCopiedResetTimer = useCallback((): void => {
+ if (copiedResetTimerRef.current !== null) {
+ window.clearTimeout(copiedResetTimerRef.current)
+ copiedResetTimerRef.current = null
+ }
+ }, [])
+ const setPanelRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ mountedRef.current = node !== null
+ if (!node) {
+ clearCopiedResetTimer()
+ }
+ },
+ [clearCopiedResetTimer]
+ )
+
+ const copyLink = async (): Promise => {
+ if (!(await copyArtifactLink(shareUrl, { showSuccessToast: false }))) {
+ return
+ }
+ if (!mountedRef.current) {
+ return
+ }
+ setCopied(true)
+ clearCopiedResetTimer()
+ copiedResetTimerRef.current = window.setTimeout(() => {
+ copiedResetTimerRef.current = null
+ setCopied(false)
+ }, 1_500)
+ }
+
+ const copyLabel = copied
+ ? translate('auto.components.artifacts.copySuccess', 'Artifact link copied')
+ : translate('auto.components.artifacts.ArtifactPublishedLinkPanel.copyLink', 'Copy link')
+
+ return (
+
+
+
+ {shareUrl}
+
+
+
+ void copyLink()}
+ aria-label={copyLabel}
+ >
+ {copied ? : }
+
+
+
+ {copyLabel}
+
+
+
+
+ openArtifactInBrowser(shareUrl)}
+ aria-label={translate(
+ 'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink',
+ 'Open link'
+ )}
+ >
+
+
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink',
+ 'Open link'
+ )}
+
+
+
+
+ {sharingEnabled ? (
+
+ {publishing ? : }
+ {publishing
+ ? translate(
+ 'auto.components.artifacts.ArtifactPublishedLinkPanel.updating',
+ 'Updating…'
+ )
+ : translate(
+ 'auto.components.artifacts.ArtifactPublishedLinkPanel.update',
+ 'Update shared content'
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx b/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx
index 1b87ff051..b04cd4395 100644
--- a/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx
@@ -198,7 +198,9 @@ describe('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.')
+ screen.getByText(
+ 'Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.'
+ )
).toBeInTheDocument()
expect(screen.queryByText(/orca artifacts share/)).not.toBeInTheDocument()
expect(
diff --git a/src/renderer/src/components/artifacts/ArtifactsPage.tsx b/src/renderer/src/components/artifacts/ArtifactsPage.tsx
index 58d5ad9a2..d442e7a11 100644
--- a/src/renderer/src/components/artifacts/ArtifactsPage.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactsPage.tsx
@@ -328,11 +328,11 @@ export default function ArtifactsPage(): React.JSX.Element {
: publishingBlocked
? translate(
'auto.components.artifacts.ArtifactsPage.publishingOffCopy',
- 'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then ask your agent to share an HTML or Markdown file.'
+ 'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.emptyCopy',
- 'Ask your agent to share an HTML or Markdown file, and it will appear here.'
+ 'Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.'
)}
{!nextCursor && publishingBlocked ? (
diff --git a/src/renderer/src/components/artifacts/artifact-link-actions.ts b/src/renderer/src/components/artifacts/artifact-link-actions.ts
index 31685b2b7..39ec549e0 100644
--- a/src/renderer/src/components/artifacts/artifact-link-actions.ts
+++ b/src/renderer/src/components/artifacts/artifact-link-actions.ts
@@ -1,12 +1,19 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
-export async function copyArtifactLink(shareUrl: string): Promise {
+export async function copyArtifactLink(
+ shareUrl: string,
+ options: { showSuccessToast?: boolean } = {}
+): Promise {
try {
await window.api.ui.writeClipboardText(shareUrl)
- toast.success(translate('auto.components.artifacts.copySuccess', 'Artifact link copied'))
+ if (options.showSuccessToast !== false) {
+ toast.success(translate('auto.components.artifacts.copySuccess', 'Artifact link copied'))
+ }
+ return true
} catch {
toast.error(translate('auto.components.artifacts.copyFailed', 'Could not copy artifact link'))
+ return false
}
}
diff --git a/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts b/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts
new file mode 100644
index 000000000..36ec7d0f2
--- /dev/null
+++ b/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts
@@ -0,0 +1,110 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
+import { publishArtifactFromSurface } from './artifact-publish-flow'
+
+const mocks = vi.hoisted(() => ({
+ callRuntimeRpc: vi.fn(),
+ connect: vi.fn(),
+ toastError: vi.fn(),
+ toastSuccess: vi.fn(),
+ state: {
+ orcaProfileAuthStatus: { state: 'connected' } as { state: string } | null,
+ connectCurrentOrcaProfile: vi.fn()
+ }
+}))
+
+vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
+vi.mock('@/store', () => ({
+ useAppStore: { getState: () => mocks.state }
+}))
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+vi.mock('sonner', () => ({
+ toast: { error: mocks.toastError, success: mocks.toastSuccess }
+}))
+const request = {
+ sourceKey: '/repo/report.html',
+ content: 'Report ',
+ contentType: 'text/html' as const,
+ fileName: 'report.html'
+}
+const published = {
+ change: 'created' as const,
+ item: {
+ artifact: { slug: 'artifact-a' },
+ shareUrl: 'https://share.onorca.dev/a/artifact-a'
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.state.orcaProfileAuthStatus = { state: 'connected' }
+ mocks.state.connectCurrentOrcaProfile = mocks.connect
+})
+
+describe('artifact publish flow', () => {
+ it('signs in before preparing and publishing the request', async () => {
+ mocks.state.orcaProfileAuthStatus = { state: 'local' }
+ mocks.connect.mockResolvedValue({ status: 'connected' })
+ mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published })
+ const createRequest = vi.fn().mockResolvedValue(request)
+
+ await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published)
+ expect(mocks.connect).toHaveBeenCalledOnce()
+ expect(createRequest).toHaveBeenCalledOnce()
+ expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
+ { kind: 'local' },
+ 'artifacts.publish',
+ request
+ )
+ })
+
+ it('resumes with fresh content after reconnecting', async () => {
+ mocks.connect.mockResolvedValue({ status: 'connected' })
+ mocks.callRuntimeRpc
+ .mockResolvedValueOnce({ status: 'reconnect-required' })
+ .mockResolvedValueOnce({ status: 'ok', value: published })
+ const createRequest = vi
+ .fn()
+ .mockResolvedValueOnce(request)
+ .mockResolvedValueOnce({ ...request, content: 'Fresh ' })
+
+ await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published)
+ expect(mocks.connect).toHaveBeenCalledOnce()
+ expect(createRequest).toHaveBeenCalledTimes(2)
+ expect(mocks.callRuntimeRpc.mock.calls[1]?.[2]).toMatchObject({
+ content: 'Fresh '
+ })
+ })
+
+ it('surfaces sign-in failures without preparing the file', async () => {
+ mocks.state.orcaProfileAuthStatus = { state: 'local' }
+ mocks.connect.mockRejectedValue(new Error('login failed'))
+ const createRequest = vi.fn().mockResolvedValue(request)
+
+ await expect(publishArtifactFromSurface(createRequest)).resolves.toBeNull()
+ expect(createRequest).not.toHaveBeenCalled()
+ expect(mocks.toastError).toHaveBeenCalledWith('Could not share artifact', undefined)
+ })
+
+ it('rejects an oversized request before RPC', async () => {
+ const createRequest = vi.fn().mockResolvedValue({
+ ...request,
+ content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2))
+ })
+
+ await expect(publishArtifactFromSurface(createRequest)).resolves.toBeNull()
+ expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
+ expect(mocks.toastError).toHaveBeenCalledWith('Could not share artifact', {
+ description: 'Artifacts shared from Orca must be smaller than 800 KB.'
+ })
+ })
+
+ it('shows confirmation without putting the public link in the toast', async () => {
+ mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published })
+ await publishArtifactFromSurface(() => Promise.resolve(request))
+
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Artifact shared')
+ })
+})
diff --git a/src/renderer/src/components/artifacts/artifact-publish-flow.ts b/src/renderer/src/components/artifacts/artifact-publish-flow.ts
new file mode 100644
index 000000000..715bb036b
--- /dev/null
+++ b/src/renderer/src/components/artifacts/artifact-publish-flow.ts
@@ -0,0 +1,144 @@
+import { toast } from 'sonner'
+import type {
+ ArtifactCloudOperation,
+ ArtifactPublishResult,
+ ArtifactWriteRequest
+} from '../../../../shared/artifacts'
+import {
+ ARTIFACT_CLI_MAX_RPC_BYTES,
+ artifactWriteRequestByteLength
+} from '../../../../shared/artifacts'
+import { translate } from '@/i18n/i18n'
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import { useAppStore } from '@/store'
+
+const LOCAL_RUNTIME = { kind: 'local' } as const
+
+export type ArtifactPublishPreparationErrorCode =
+ | 'empty'
+ | 'too-large'
+ | 'unreadable'
+ | 'unsupported'
+ | 'binary'
+
+export class ArtifactPublishPreparationError extends Error {
+ constructor(readonly code: ArtifactPublishPreparationErrorCode) {
+ super(code)
+ }
+}
+
+export function validateArtifactPublishRequest(
+ request: ArtifactWriteRequest
+): ArtifactWriteRequest {
+ if (!request.content) {
+ throw new ArtifactPublishPreparationError('empty')
+ }
+ if (artifactWriteRequestByteLength(request) > ARTIFACT_CLI_MAX_RPC_BYTES) {
+ throw new ArtifactPublishPreparationError('too-large')
+ }
+ return request
+}
+
+export async function publishArtifactFromSurface(
+ createRequest: () => Promise
+): Promise {
+ try {
+ if (!(await ensureArtifactAccountConnected())) {
+ return null
+ }
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ const request = validateArtifactPublishRequest(await createRequest())
+ const result = await callRuntimeRpc>(
+ LOCAL_RUNTIME,
+ 'artifacts.publish',
+ request
+ )
+ if (result.status === 'ok') {
+ showArtifactPublishedToast(result.value)
+ return result.value
+ }
+ if (result.status === 'unconfigured') {
+ toast.error(
+ translate(
+ 'auto.components.artifacts.artifact-publish-flow.9a078a0c65',
+ 'Artifact sharing is unavailable'
+ ),
+ { description: result.message }
+ )
+ return null
+ }
+ if (attempt === 0 && (await reconnectArtifactAccount())) {
+ continue
+ }
+ toast.error(
+ translate(
+ 'auto.components.artifacts.artifact-publish-flow.bba20daa6d',
+ 'Sign in to Orca and try again.'
+ )
+ )
+ return null
+ }
+ } catch (error) {
+ console.error('Failed to publish artifact:', error)
+ toast.error(
+ translate(
+ 'auto.components.artifacts.artifact-publish-flow.54b1805328',
+ 'Could not share artifact'
+ ),
+ error instanceof ArtifactPublishPreparationError
+ ? { description: artifactPreparationErrorDescription(error.code) }
+ : undefined
+ )
+ }
+ return null
+}
+
+async function ensureArtifactAccountConnected(): Promise {
+ const state = useAppStore.getState()
+ if (state.orcaProfileAuthStatus?.state === 'connected') {
+ return true
+ }
+ return (await state.connectCurrentOrcaProfile())?.status === 'connected'
+}
+
+async function reconnectArtifactAccount(): Promise {
+ return (await useAppStore.getState().connectCurrentOrcaProfile())?.status === 'connected'
+}
+
+function showArtifactPublishedToast(result: ArtifactPublishResult): void {
+ toast.success(
+ result.change === 'created'
+ ? translate('auto.components.artifacts.artifact-publish-flow.430019efd0', 'Artifact shared')
+ : translate('auto.components.artifacts.artifact-publish-flow.2fc727c831', 'Artifact updated')
+ )
+}
+
+function artifactPreparationErrorDescription(code: ArtifactPublishPreparationErrorCode): string {
+ switch (code) {
+ case 'empty':
+ return translate(
+ 'auto.components.artifacts.artifact-publish-flow.fbb5018602',
+ 'This file is empty.'
+ )
+ case 'too-large':
+ return translate(
+ 'auto.components.artifacts.artifact-publish-flow.6112db5a1c',
+ 'Artifacts shared from Orca must be smaller than 800 KB.'
+ )
+ case 'unreadable':
+ return translate(
+ 'auto.components.artifacts.artifact-publish-flow.e2ed5acd8c',
+ "Orca couldn't read this file. Open it from a workspace and try again."
+ )
+ case 'unsupported':
+ return translate(
+ 'auto.components.artifacts.artifact-publish-flow.6d475e9b25',
+ 'Only local HTML and Markdown files can be shared as artifacts.'
+ )
+ case 'binary':
+ return translate(
+ 'auto.components.artifacts.artifact-publish-flow.29a406be09',
+ 'Artifacts must contain text.'
+ )
+ }
+}
diff --git a/src/renderer/src/components/artifacts/artifact-published-link-client.test.ts b/src/renderer/src/components/artifacts/artifact-published-link-client.test.ts
new file mode 100644
index 000000000..7b758db41
--- /dev/null
+++ b/src/renderer/src/components/artifacts/artifact-published-link-client.test.ts
@@ -0,0 +1,38 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { getPublishedArtifactLink } from './artifact-published-link-client'
+
+const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
+
+vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('published artifact link client', () => {
+ it('returns the locally persisted public link', async () => {
+ mocks.callRuntimeRpc.mockResolvedValue({
+ status: 'ok',
+ value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
+ })
+
+ await expect(getPublishedArtifactLink('/repo/report.md')).resolves.toBe(
+ 'https://share.onorca.dev/a/artifact-a'
+ )
+ expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
+ { kind: 'local' },
+ 'artifacts.getPublishedLink',
+ { sourceKey: '/repo/report.md' }
+ )
+ })
+
+ it('returns null when the source has not been shared', async () => {
+ mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: null })
+ await expect(getPublishedArtifactLink('/repo/report.md')).resolves.toBeNull()
+ })
+
+ it('rejects when the account must reconnect', async () => {
+ mocks.callRuntimeRpc.mockResolvedValue({ status: 'reconnect-required' })
+ await expect(getPublishedArtifactLink('/repo/report.md')).rejects.toThrow('reconnect-required')
+ })
+})
diff --git a/src/renderer/src/components/artifacts/artifact-published-link-client.ts b/src/renderer/src/components/artifacts/artifact-published-link-client.ts
new file mode 100644
index 000000000..48d9818e6
--- /dev/null
+++ b/src/renderer/src/components/artifacts/artifact-published-link-client.ts
@@ -0,0 +1,16 @@
+import type { ArtifactCloudOperation, ArtifactPublishedLink } from '../../../../shared/artifacts'
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+
+const LOCAL_RUNTIME = { kind: 'local' } as const
+
+export async function getPublishedArtifactLink(sourceKey: string): Promise {
+ const result = await callRuntimeRpc>(
+ LOCAL_RUNTIME,
+ 'artifacts.getPublishedLink',
+ { sourceKey }
+ )
+ if (result.status === 'ok') {
+ return result.value?.shareUrl ?? null
+ }
+ throw new Error(result.status)
+}
diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx
index 4ed12bc4f..90a7e1fdc 100644
--- a/src/renderer/src/components/browser-pane/BrowserPane.tsx
+++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx
@@ -12,7 +12,7 @@ import {
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { createBrowserUuid } from '@/lib/browser-uuid'
-import { getConnectionId } from '@/lib/connection-context'
+import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import { getWorkspaceFileBrowserOpenTarget } from '@/lib/file-preview'
@@ -193,6 +193,12 @@ import { MarkupOverlay } from './markup/MarkupOverlay'
import { MarkupDrawButton } from './markup/MarkupDrawButton'
import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery'
import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay'
+import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
+import {
+ browserFileUrlToAbsolutePath,
+ getShareableBrowserArtifactFile,
+ readBrowserHtmlArtifactRequest
+} from './browser-artifact-upload'
import {
BROWSER_GUEST_RECOVERY_ERROR_CODE,
createBrowserPageGuestRecovery
@@ -591,26 +597,8 @@ function isChromiumErrorPage(url: string): boolean {
return url.startsWith('chrome-error://')
}
-function fileUrlToAbsolutePath(url: string): string | null {
- try {
- const parsed = new URL(url)
- if (parsed.protocol !== 'file:') {
- return null
- }
- const hostPrefix =
- parsed.hostname && parsed.hostname !== 'localhost' ? `//${parsed.hostname}` : ''
- let absolutePath = `${hostPrefix}${decodeURIComponent(parsed.pathname)}`
- if (/^\/[A-Za-z]:\//.test(absolutePath)) {
- absolutePath = absolutePath.slice(1)
- }
- return absolutePath
- } catch {
- return null
- }
-}
-
function getNotebookPathFromBrowserUrl(url: string): string | null {
- const filePath = fileUrlToAbsolutePath(url)
+ const filePath = browserFileUrlToAbsolutePath(url)
return filePath?.toLowerCase().endsWith('.ipynb') ? filePath : null
}
@@ -2517,6 +2505,7 @@ function BrowserPagePane({
const handleInternalFileDragOverRef = useRef<(event: DragEvent) => void>(() => {})
const handleInternalFileDropRef = useRef<(event: DragEvent) => void>(() => {})
const keybindings = useAppStore((state) => state.keybindings)
+ const workspaceConnectionId = useAppStore((state) => getConnectionIdFromState(state, worktreeId))
const browserDefaultZoomLevel = useAppStore(
(state) => state.browserDefaultZoomLevel ?? DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
)
@@ -4569,6 +4558,8 @@ function BrowserPagePane({
const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL
const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url)
const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url)
+ const shareableArtifactFile =
+ workspaceConnectionId === null ? getShareableBrowserArtifactFile(currentBrowserUrl) : null
const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl
const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl)
const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab
@@ -5042,6 +5033,14 @@ function BrowserPagePane({
surfaceActive={isActive}
/>
+ {shareableArtifactFile ? (
+ readBrowserHtmlArtifactRequest(currentBrowserUrl)}
+ />
+ ) : null}
+
{
+ vi.clearAllMocks()
+ window.api = { fs: { stat, readFile } } as never
+ stat.mockResolvedValue({ size: 20, isDirectory: false, mtime: 1 })
+ readFile.mockResolvedValue({ content: 'Mock ', isBinary: false })
+})
+
+describe('browser artifact upload', () => {
+ it('recognizes local HTML URLs across path flavors', () => {
+ expect(getShareableBrowserArtifactFile('file:///tmp/Design%20Review.html')).toEqual({
+ fileName: 'Design Review.html',
+ filePath: '/tmp/Design Review.html'
+ })
+ expect(getShareableBrowserArtifactFile('file:///C:/repo/report.HTM')).toEqual({
+ fileName: 'report.HTM',
+ filePath: 'C:\\repo\\report.HTM'
+ })
+ expect(getShareableBrowserArtifactFile('file://server/share/report.html')).toEqual({
+ fileName: 'report.html',
+ filePath: '\\\\server\\share\\report.html'
+ })
+ expect(getShareableBrowserArtifactFile('https://example.com/report.html')).toBeNull()
+ expect(getShareableBrowserArtifactFile('file:///tmp/report.md')).toBeNull()
+ })
+
+ it('reads the backing file through the authorized filesystem API', async () => {
+ await expect(readBrowserHtmlArtifactRequest('file:///tmp/report.html')).resolves.toEqual({
+ sourceKey: '/tmp/report.html',
+ content: 'Mock ',
+ contentType: 'text/html',
+ fileName: 'report.html'
+ })
+ expect(stat).toHaveBeenCalledWith({ filePath: '/tmp/report.html' })
+ expect(readFile).toHaveBeenCalledWith({ filePath: '/tmp/report.html' })
+ })
+
+ it('rejects oversized and unreadable files before upload', async () => {
+ stat.mockResolvedValueOnce({
+ size: ARTIFACT_CLI_MAX_RPC_BYTES + 1,
+ isDirectory: false,
+ mtime: 1
+ })
+ await expect(readBrowserHtmlArtifactRequest('file:///tmp/large.html')).rejects.toMatchObject({
+ code: 'too-large'
+ } satisfies Partial)
+ expect(readFile).not.toHaveBeenCalled()
+
+ stat.mockRejectedValueOnce(new Error('access denied'))
+ await expect(readBrowserHtmlArtifactRequest('file:///tmp/private.html')).rejects.toMatchObject({
+ code: 'unreadable'
+ } satisfies Partial)
+ })
+})
diff --git a/src/renderer/src/components/browser-pane/browser-artifact-upload.ts b/src/renderer/src/components/browser-pane/browser-artifact-upload.ts
new file mode 100644
index 000000000..35c3afb07
--- /dev/null
+++ b/src/renderer/src/components/browser-pane/browser-artifact-upload.ts
@@ -0,0 +1,70 @@
+import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
+import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
+import { getRuntimePathBasename } from '../../../../shared/cross-platform-path'
+import { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
+
+export type ShareableBrowserArtifactFile = {
+ fileName: string
+ filePath: string
+}
+
+export function browserFileUrlToAbsolutePath(url: string): string | null {
+ try {
+ const parsed = new URL(url)
+ if (parsed.protocol !== 'file:') {
+ return null
+ }
+ const hostPrefix =
+ parsed.hostname && parsed.hostname !== 'localhost' ? `//${parsed.hostname}` : ''
+ let absolutePath = `${hostPrefix}${decodeURIComponent(parsed.pathname)}`
+ if (/^\/[A-Za-z]:\//.test(absolutePath)) {
+ absolutePath = absolutePath.slice(1)
+ }
+ if (/^[A-Za-z]:\//.test(absolutePath) || absolutePath.startsWith('//')) {
+ absolutePath = absolutePath.replaceAll('/', '\\')
+ }
+ return absolutePath
+ } catch {
+ return null
+ }
+}
+
+export function getShareableBrowserArtifactFile(url: string): ShareableBrowserArtifactFile | null {
+ const filePath = browserFileUrlToAbsolutePath(url)
+ if (!filePath || !/\.html?$/i.test(filePath)) {
+ return null
+ }
+ const fileName = getRuntimePathBasename(filePath)
+ return fileName ? { fileName, filePath } : null
+}
+
+export async function readBrowserHtmlArtifactRequest(url: string): Promise {
+ const file = getShareableBrowserArtifactFile(url)
+ if (!file) {
+ throw new ArtifactPublishPreparationError('unsupported')
+ }
+ try {
+ const stat = await window.api.fs.stat({ filePath: file.filePath })
+ if (stat.isDirectory) {
+ throw new ArtifactPublishPreparationError('unsupported')
+ }
+ if (stat.size > ARTIFACT_CLI_MAX_RPC_BYTES) {
+ throw new ArtifactPublishPreparationError('too-large')
+ }
+ const result = await window.api.fs.readFile({ filePath: file.filePath })
+ if (result.isBinary) {
+ throw new ArtifactPublishPreparationError('binary')
+ }
+ return {
+ sourceKey: file.filePath,
+ content: result.content,
+ contentType: 'text/html',
+ fileName: file.fileName
+ }
+ } catch (error) {
+ if (error instanceof ArtifactPublishPreparationError) {
+ throw error
+ }
+ throw new ArtifactPublishPreparationError('unreadable')
+ }
+}
diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx
index 58bb890d1..a62a4cce9 100644
--- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx
+++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx
@@ -370,7 +370,7 @@ describe('AgentKanbanBoard', () => {
renderBoard([card({ bucket: 'done' })])
expect(screen.getByText('完了')).toBeInTheDocument()
- expect(screen.getByLabelText('エージェントを検索')).toBeInTheDocument()
+ expect(screen.getByLabelText('Agent を検索')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^フィルター/ })).toBeInTheDocument()
})
diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx
index 40041410c..2e07c10ef 100644
--- a/src/renderer/src/components/editor/EditorPanel.tsx
+++ b/src/renderer/src/components/editor/EditorPanel.tsx
@@ -24,6 +24,7 @@ import {
} from './editor-panel-git-entry-selector'
import { createEditorPanelDraftSelector } from './editor-panel-draft-selector'
import { attemptEditorFileSave } from './editor-file-save-attempt'
+import { createCurrentMarkdownArtifactRequest } from './markdown-artifact-upload'
function EditorPanelInner({
activeFileId: activeFileIdProp,
@@ -348,6 +349,14 @@ function EditorPanelInner({
markdownFrontmatterVisible[markdownDocumentStateFileId] ?? true
const isMarkdownTableOfContentsVisible =
markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false
+ const createActiveMarkdownArtifactRequest = () =>
+ Promise.resolve(
+ createCurrentMarkdownArtifactRequest(
+ activeFile,
+ markdownDocumentStateFileId,
+ activeMarkdownContent ?? ''
+ )
+ )
return (
// Why: each split pane needs an isolated bridge between its diff editor and header controls.
@@ -389,6 +398,9 @@ function EditorPanelInner({
onExportMarkdownToPdf={() =>
void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
}
+ createMarkdownArtifactRequest={
+ activeMarkdownContent === null ? undefined : createActiveMarkdownArtifactRequest
+ }
onContentChange={handleContentChange}
onContentChangeForFile={handleContentChangeForFile}
onDirtyStateHint={handleDirtyStateHint}
diff --git a/src/renderer/src/components/editor/EditorPanelHeader.test.tsx b/src/renderer/src/components/editor/EditorPanelHeader.test.tsx
index 0a03d312f..49f682073 100644
--- a/src/renderer/src/components/editor/EditorPanelHeader.test.tsx
+++ b/src/renderer/src/components/editor/EditorPanelHeader.test.tsx
@@ -1,4 +1,5 @@
import { renderToStaticMarkup } from 'react-dom/server'
+import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { EditorPanelHeader } from './EditorPanelHeader'
@@ -41,6 +42,10 @@ vi.mock('./EditorPanelMarkdownActionsMenu', () => ({
EditorPanelMarkdownActionsMenu: () => null
}))
+vi.mock('@/components/artifacts/ArtifactPublishButton', () => ({
+ ArtifactPublishButton: () =>
+}))
+
vi.mock('./diff-navigation-context', () => ({
useDiffNavigation: () => ({
changeCount: 2,
@@ -59,43 +64,47 @@ const activeFile: OpenFile = {
mode: 'diff'
}
+const baseProps = {
+ activeFile,
+ copiedPathVisible: false,
+ isSingleDiff: false,
+ isDiffSurface: true,
+ isMarkdown: false,
+ isCsv: false,
+ isNotebook: false,
+ hasEditorToggle: false,
+ availableEditorToggleModes: [],
+ effectiveToggleValue: 'edit',
+ canOpenPreviewToSide: false,
+ canShowMarkdownPreview: false,
+ canShowMarkdownTableOfContents: false,
+ isMarkdownTableOfContentsDisabled: false,
+ shouldShowMarkdownExportAction: false,
+ canExportMarkdownToPdf: false,
+ showMarkdownTableOfContents: false,
+ canShowMarkdownFrontmatterToggle: false,
+ markdownFrontmatterVisible: false,
+ sideBySide: false,
+ openFileState: { canOpen: false },
+ onCopyPath: vi.fn(),
+ onOpenDiffTargetFile: vi.fn(),
+ onOpenPreviewToSide: vi.fn(),
+ onOpenMarkdownPreview: vi.fn(),
+ onOpenContainingFolder: vi.fn(),
+ onToggleSideBySide: vi.fn(),
+ onEditorToggleChange: vi.fn(),
+ onToggleMarkdownTableOfContents: vi.fn(),
+ onToggleMarkdownFrontmatter: vi.fn(),
+ onExportMarkdownToPdf: vi.fn()
+} satisfies ComponentProps
+
+function renderHeader(overrides: Partial> = {}): string {
+ return renderToStaticMarkup( )
+}
+
describe('EditorPanelHeader', () => {
it('shares one tooltip provider across the diff header controls', () => {
- const html = renderToStaticMarkup(
-
- )
+ const html = renderHeader()
expect(html.match(/data-tooltip-provider/g)).toHaveLength(1)
expect(html.match(/data-tooltip="true"/g)).toHaveLength(3)
@@ -103,4 +112,30 @@ describe('EditorPanelHeader', () => {
expect(html).toContain('aria-label="Previous change"')
expect(html).toContain('aria-label="Next change"')
})
+
+ it('offers artifact sharing only on non-diff Markdown surfaces', () => {
+ const createRequest = vi.fn()
+
+ expect(
+ renderHeader({
+ isDiffSurface: false,
+ isMarkdown: true,
+ createMarkdownArtifactRequest: createRequest
+ })
+ ).toContain('data-artifact-publish="true"')
+ expect(
+ renderHeader({
+ isDiffSurface: true,
+ isMarkdown: true,
+ createMarkdownArtifactRequest: createRequest
+ })
+ ).not.toContain('data-artifact-publish')
+ expect(
+ renderHeader({
+ isDiffSurface: false,
+ isMarkdown: false,
+ createMarkdownArtifactRequest: createRequest
+ })
+ ).not.toContain('data-artifact-publish')
+ })
})
diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx
index a24914bea..8aeb972ba 100644
--- a/src/renderer/src/components/editor/EditorPanelHeader.tsx
+++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx
@@ -17,6 +17,9 @@ import { EditorPanelHeaderPath } from './EditorPanelHeaderPath'
import { useDiffNavigation } from './diff-navigation-context'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
+import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
+import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
+import { markdownArtifactSourceKey } from './markdown-artifact-upload'
type EditorPanelHeaderProps = {
activeFile: OpenFile
@@ -50,6 +53,7 @@ type EditorPanelHeaderProps = {
onToggleMarkdownTableOfContents: () => void
onToggleMarkdownFrontmatter: () => void
onExportMarkdownToPdf: () => void
+ createMarkdownArtifactRequest?: () => Promise
}
export function EditorPanelHeader({
@@ -83,7 +87,8 @@ export function EditorPanelHeader({
onEditorToggleChange,
onToggleMarkdownTableOfContents,
onToggleMarkdownFrontmatter,
- onExportMarkdownToPdf
+ onExportMarkdownToPdf,
+ createMarkdownArtifactRequest
}: EditorPanelHeaderProps): React.JSX.Element {
const diffComments = useAppStore((s) =>
selectWorktreeDiffCommentsOrEmpty(s, activeFile.worktreeId)
@@ -311,6 +316,13 @@ export function EditorPanelHeader({
)}
+ {isMarkdown && !isDiffSurface && createMarkdownArtifactRequest ? (
+
+ ) : null}
@@ -41,6 +42,7 @@ type EditorPanelShellProps = {
onToggleMarkdownTableOfContents: () => void
onToggleMarkdownFrontmatter: () => void
onExportMarkdownToPdf: () => void
+ createMarkdownArtifactRequest?: () => Promise
onContentChange: (content: string) => void
onContentChangeForFile: (file: OpenFile, content: string) => void
onDirtyStateHint: (dirty: boolean) => void
@@ -81,6 +83,7 @@ export function EditorPanelShell({
onToggleMarkdownTableOfContents,
onToggleMarkdownFrontmatter,
onExportMarkdownToPdf,
+ createMarkdownArtifactRequest,
onContentChange,
onContentChangeForFile,
onDirtyStateHint,
@@ -127,6 +130,7 @@ export function EditorPanelShell({
onToggleMarkdownTableOfContents={onToggleMarkdownTableOfContents}
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
onExportMarkdownToPdf={onExportMarkdownToPdf}
+ createMarkdownArtifactRequest={createMarkdownArtifactRequest}
/>
)}
}>
diff --git a/src/renderer/src/components/editor/markdown-artifact-upload.test.ts b/src/renderer/src/components/editor/markdown-artifact-upload.test.ts
new file mode 100644
index 000000000..9a8f03a3c
--- /dev/null
+++ b/src/renderer/src/components/editor/markdown-artifact-upload.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { OpenFile } from '@/store/slices/editor'
+import {
+ createCurrentMarkdownArtifactRequest,
+ createMarkdownArtifactRequest,
+ markdownArtifactSourceKey
+} from './markdown-artifact-upload'
+
+const mocks = vi.hoisted(() => ({
+ drafts: {} as Record,
+ flush: vi.fn()
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: { getState: () => ({ editorDrafts: mocks.drafts }) }
+}))
+vi.mock('./editor-pending-flush', () => ({
+ flushPendingEditorChange: mocks.flush
+}))
+
+beforeEach(() => {
+ mocks.drafts = {}
+ mocks.flush.mockReset()
+})
+
+function openFile(overrides: Partial = {}): OpenFile {
+ return {
+ id: '/repo/notes.md',
+ filePath: '/repo/notes.md',
+ relativePath: 'notes.md',
+ worktreeId: 'worktree-1',
+ language: 'markdown',
+ isDirty: false,
+ mode: 'edit',
+ ...overrides
+ }
+}
+
+describe('Markdown artifact upload', () => {
+ it('uses the ordinary file path for local and folder workspaces', () => {
+ expect(markdownArtifactSourceKey(openFile())).toBe('/repo/notes.md')
+ expect(createMarkdownArtifactRequest(openFile(), '# Draft')).toEqual({
+ sourceKey: '/repo/notes.md',
+ content: '# Draft',
+ contentType: 'text/markdown',
+ fileName: 'notes.md'
+ })
+ })
+
+ it('isolates source identity by runtime owner', () => {
+ const file = openFile({
+ runtimeEnvironmentId: 'server-1',
+ operationProvenance: {
+ ownershipProjection: 'explicit',
+ generation: {
+ route: {
+ runtimeEnvironmentId: 'server-1',
+ executionHostId: 'ssh:build-box'
+ },
+ runtimeConnectionGeneration: 1,
+ runtimePairingRevision: 1,
+ runtimeSshGeneration: 1,
+ nestedSshGeneration: 1,
+ directSshGeneration: null
+ }
+ }
+ })
+ expect(JSON.parse(markdownArtifactSourceKey(file))).toEqual([
+ 'ssh',
+ 'build-box',
+ '/repo/notes.md'
+ ])
+ })
+
+ it('matches the SSH CLI source identity for external files', () => {
+ expect(
+ JSON.parse(markdownArtifactSourceKey(openFile({ externalSshTargetId: 'build-box' })))
+ ).toEqual(['ssh', 'build-box', '/repo/notes.md'])
+ })
+
+ it('flushes and reads the latest unsaved editor buffer', () => {
+ mocks.flush.mockImplementation((fileId: string) => {
+ mocks.drafts[fileId] = '# Latest edit'
+ })
+
+ expect(
+ createCurrentMarkdownArtifactRequest(openFile(), '/repo/notes.md', '# Stale content').content
+ ).toBe('# Latest edit')
+ expect(mocks.flush).toHaveBeenCalledWith('/repo/notes.md')
+ })
+})
diff --git a/src/renderer/src/components/editor/markdown-artifact-upload.ts b/src/renderer/src/components/editor/markdown-artifact-upload.ts
new file mode 100644
index 000000000..dd97483e6
--- /dev/null
+++ b/src/renderer/src/components/editor/markdown-artifact-upload.ts
@@ -0,0 +1,52 @@
+import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
+import { sshArtifactSourceKey } from '../../../../shared/artifact-cli-bridge'
+import { parseExecutionHostId } from '../../../../shared/execution-host'
+import { basename } from '@/lib/path'
+import { useAppStore } from '@/store'
+import type { OpenFile } from '@/store/slices/editor'
+import { flushPendingEditorChange } from './editor-pending-flush'
+
+export function markdownArtifactSourceKey(file: OpenFile): string {
+ const route = file.operationProvenance?.generation.route
+ const host = parseExecutionHostId(route?.executionHostId)
+ if (host?.kind === 'ssh') {
+ return sshArtifactSourceKey(host.targetId, file.filePath)
+ }
+ if (file.externalSshTargetId) {
+ return sshArtifactSourceKey(file.externalSshTargetId, file.filePath)
+ }
+ if (route && (route.runtimeEnvironmentId || route.executionHostId !== 'local')) {
+ return JSON.stringify([
+ 'editor',
+ route.runtimeEnvironmentId ?? null,
+ route.executionHostId,
+ file.filePath
+ ])
+ }
+ if (file.runtimeEnvironmentId) {
+ return JSON.stringify(['editor', file.runtimeEnvironmentId ?? null, 'remote', file.filePath])
+ }
+ return file.filePath
+}
+
+export function createMarkdownArtifactRequest(
+ file: OpenFile,
+ content: string
+): ArtifactWriteRequest {
+ return {
+ sourceKey: markdownArtifactSourceKey(file),
+ content,
+ contentType: 'text/markdown',
+ fileName: basename(file.filePath)
+ }
+}
+
+export function createCurrentMarkdownArtifactRequest(
+ file: OpenFile,
+ contentFileId: string,
+ fallbackContent: string
+): ArtifactWriteRequest {
+ flushPendingEditorChange(contentFileId)
+ const content = useAppStore.getState().editorDrafts[contentFileId] ?? fallbackContent
+ return createMarkdownArtifactRequest(file, content)
+}
diff --git a/src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx b/src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx
index 176c718ca..ff4a3c1aa 100644
--- a/src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx
+++ b/src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx
@@ -61,13 +61,15 @@ describe('ArtifactsSettingsPane', () => {
)
expect(screen.getByText('How to use Artifacts')).toBeInTheDocument()
- expect(screen.getByText('Ask your agent to share it')).toBeInTheDocument()
+ expect(screen.getByText('Choose a file to share')).toBeInTheDocument()
expect(
- screen.getByText('For example: “Share this HTML mock as an artifact.”')
+ screen.getByText(
+ 'Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.'
+ )
).toBeInTheDocument()
- expect(screen.getByText('Share the public link')).toBeInTheDocument()
+ expect(screen.getByText('Copy the public link')).toBeInTheDocument()
expect(
- screen.getByText('Your agent returns a link that anyone with the URL can view.')
+ screen.getByText('After publishing, copy the link and send it to your team.')
).toBeInTheDocument()
expect(screen.getByText('Manage it in Orca')).toBeInTheDocument()
expect(
@@ -137,15 +139,15 @@ describe('ArtifactsSettingsPane', () => {
expect(mocks.openArtifactsPage).toHaveBeenCalledOnce()
})
- it('shows the publish capability off by default and describes it as device-wide', () => {
+ it('describes public publishing and existing-link retention', () => {
render( )
expect(
screen.getByRole('switch', { name: 'Allow publishing public artifact links' })
).toHaveAttribute('aria-checked', 'false')
- expect(screen.getByText(/your agents and the orca CLI/)).toBeInTheDocument()
- expect(screen.getByText(/mint links anyone with the URL can open/)).toBeInTheDocument()
- expect(screen.getByText(/does not delete existing links/)).toBeInTheDocument()
+ expect(screen.getByText(/Publish HTML and Markdown files as links/)).toBeInTheDocument()
+ expect(screen.getByText(/Existing links remain until you delete them/)).toBeInTheDocument()
+ expect(screen.queryByText(/Off by default/)).not.toBeInTheDocument()
})
it('grants and revokes the publish capability through the toggle', async () => {
@@ -198,9 +200,11 @@ describe('ArtifactsSettingsPane', () => {
)
- expect(screen.getByText('Allow publishing first')).toBeInTheDocument()
- expect(screen.getByText(/artifact_sharing_disabled/)).toBeInTheDocument()
- expect(screen.getByText(/Publishing is off, so nothing on this device/)).toBeInTheDocument()
+ expect(screen.getByText('Enable artifact sharing')).toBeInTheDocument()
+ expect(
+ screen.getByText('Turn on “Allow publishing public artifact links” above.')
+ ).toBeInTheDocument()
+ expect(screen.getByText(/Enable artifact sharing above/)).toBeInTheDocument()
rerender(
{
updateSettings={vi.fn()}
/>
)
- expect(screen.queryByText('Allow publishing first')).not.toBeInTheDocument()
- expect(screen.getByText('Ask your agent to share it')).toBeInTheDocument()
+ expect(screen.queryByText('Enable artifact sharing')).not.toBeInTheDocument()
+ expect(screen.getByText('Choose a file to share')).toBeInTheDocument()
})
it('points web clients at the desktop app for the opt-in step', () => {
diff --git a/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx b/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx
index 9a56bcf8a..f561908d5 100644
--- a/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx
+++ b/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx
@@ -41,16 +41,16 @@ export function ArtifactsSettingsPane({
key: 'enable',
title: translate(
'auto.components.settings.artifacts.enableStepTitle',
- 'Allow publishing first'
+ 'Enable artifact sharing'
),
description: isWebClient
? translate(
'auto.components.settings.artifacts.enableStepWebDescription',
- 'Open Settings → Artifacts in the Orca desktop app on the host device and turn publishing on. Until then every share fails with artifact_sharing_disabled.'
+ 'Open Settings → Artifacts in the Orca desktop app on the host device and enable publishing.'
)
: translate(
'auto.components.settings.artifacts.enableStepDescription',
- 'Turn on “Allow publishing public artifact links” above. Until then every share fails with artifact_sharing_disabled.'
+ 'Turn on “Allow publishing public artifact links” above.'
)
}
]),
@@ -58,19 +58,19 @@ export function ArtifactsSettingsPane({
key: 'share',
title: translate(
'auto.components.settings.artifacts.shareStepTitle',
- 'Ask your agent to share it'
+ 'Choose a file to share'
),
description: translate(
'auto.components.settings.artifacts.shareStepDescription',
- 'For example: “Share this HTML mock as an artifact.”'
+ 'Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.'
)
},
{
key: 'link',
- title: translate('auto.components.settings.artifacts.linkStepTitle', 'Share the public link'),
+ title: translate('auto.components.settings.artifacts.linkStepTitle', 'Copy the public link'),
description: translate(
'auto.components.settings.artifacts.linkStepDescription',
- 'Your agent returns a link that anyone with the URL can view.'
+ 'After publishing, copy the link and send it to your team.'
)
},
{
@@ -78,7 +78,7 @@ export function ArtifactsSettingsPane({
title: translate('auto.components.settings.artifacts.manageStepTitle', 'Manage it in Orca'),
description: translate(
'auto.components.settings.artifacts.manageStepDescription',
- 'Open Artifacts from the sidebar to revisit or delete links owned by your account.'
+ 'Open Artifacts from the sidebar to preview or remove links.'
)
}
]
@@ -94,11 +94,11 @@ export function ArtifactsSettingsPane({
isWebClient
? translate(
'auto.components.settings.artifacts.allowPublishingWebDescription',
- 'Desktop only. This capability is granted on the device running Orca — open Settings → Artifacts there to change it. Shown read-only here so it matches what that device enforces.'
+ 'Desktop only. Open Settings → Artifacts on the host device to change this setting.'
)
: translate(
'auto.components.settings.artifacts.allowPublishingDescription',
- 'Off by default. When on, anything running on this device — your agents and the orca CLI — can upload HTML and Markdown files to your Orca account and mint links anyone with the URL can open. Turning it off does not delete existing links — remove those from Artifacts.'
+ 'Publish HTML and Markdown files as links anyone with the URL can open. Existing links remain until you delete them from Artifacts.'
)
}
checked={sharingEnabled}
@@ -153,11 +153,11 @@ export function ArtifactsSettingsPane({
{sharingEnabled
? translate(
'auto.components.settings.artifacts.howToDescription',
- 'Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.'
+ 'Publish HTML or Markdown files as public links, then share them with your team.'
)
: translate(
'auto.components.settings.artifacts.howToDescriptionDisabled',
- 'Publishing is off, so nothing on this device can create a public link yet. Allow it first, then ask your agent to share an HTML or Markdown file.'
+ 'Enable artifact sharing above to publish HTML or Markdown files as public links.'
)}
diff --git a/src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx b/src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx
index 86d2b48e3..1fe94509a 100644
--- a/src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx
+++ b/src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx
@@ -82,7 +82,11 @@ describe('OrcaAccountSettingsPane', () => {
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
render( )
- expect(screen.getByText('Sign in to use Artifacts and Orca Relay.')).toBeInTheDocument()
+ expect(
+ screen.getByText(
+ 'Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.'
+ )
+ ).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
expect(mocks.connect).toHaveBeenCalledOnce()
})
diff --git a/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx b/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx
index 5e350bf9e..4a9ce1034 100644
--- a/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx
+++ b/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx
@@ -28,7 +28,7 @@ function accountStatusCopy(
if (state === 'local') {
return translate(
'auto.components.settings.orcaAccount.signedOut',
- 'Sign in to use Artifacts and Orca Relay.'
+ 'Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.'
)
}
return translate('auto.components.settings.orcaAccount.checking', 'Checking account status…')
diff --git a/src/renderer/src/components/settings/artifacts-settings-search.ts b/src/renderer/src/components/settings/artifacts-settings-search.ts
index 9719352d2..00a0d2307 100644
--- a/src/renderer/src/components/settings/artifacts-settings-search.ts
+++ b/src/renderer/src/components/settings/artifacts-settings-search.ts
@@ -10,7 +10,7 @@ export const getArtifactsSettingsSearchEntries = createLocalizedCatalog(() => [
),
description: translate(
'auto.components.settings.artifacts.allowPublishingSearchDescription',
- 'Let agents and the orca CLI upload files to your Orca account and mint public links.'
+ 'Allow Orca to publish HTML and Markdown files as public links.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.artifacts.keywordArtifacts', 'artifacts'),
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 87ba4f675..bcdd65e24 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -10432,13 +10432,13 @@
"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.",
+ "howToDescription": "Publish HTML or Markdown files as public links, then share them with your team.",
+ "shareStepTitle": "Choose a file to share",
+ "shareStepDescription": "Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.",
+ "linkStepTitle": "Copy the public link",
+ "linkStepDescription": "After publishing, copy the link and send it to your team.",
"manageStepTitle": "Manage it in Orca",
- "manageStepDescription": "Open Artifacts from the sidebar to revisit or delete links owned by your account.",
+ "manageStepDescription": "Open Artifacts from the sidebar to preview or remove links.",
"openArtifacts": "Open Artifacts",
"openArtifactsDescription": "View and delete links shared through your account.",
"showButton": "Show Artifacts Button",
@@ -10447,20 +10447,20 @@
"signInTitle": "Sign in to share artifacts",
"signInDescription": "Use your Orca account to upload artifacts and manage their public links.",
"signInAgain": "Sign in again",
- "enableStepTitle": "Allow publishing first",
- "enableStepWebDescription": "Open Settings → Artifacts in the Orca desktop app on the host device and turn publishing on. Until then every share fails with artifact_sharing_disabled.",
- "enableStepDescription": "Turn on “Allow publishing public artifact links” above. Until then every share fails with artifact_sharing_disabled.",
+ "enableStepTitle": "Enable artifact sharing",
+ "enableStepWebDescription": "Open Settings → Artifacts in the Orca desktop app on the host device and enable publishing.",
+ "enableStepDescription": "Turn on “Allow publishing public artifact links” above.",
"allowPublishing": "Allow publishing public artifact links",
- "allowPublishingWebDescription": "Desktop only. This capability is granted on the device running Orca — open Settings → Artifacts there to change it. Shown read-only here so it matches what that device enforces.",
- "allowPublishingDescription": "Off by default. When on, anything running on this device — your agents and the orca CLI — can upload HTML and Markdown files to your Orca account and mint links anyone with the URL can open. Turning it off does not delete existing links — remove those from Artifacts.",
- "howToDescriptionDisabled": "Publishing is off, so nothing on this device can create a public link yet. Allow it first, then ask your agent to share an HTML or Markdown file.",
- "allowPublishingSearchDescription": "Let agents and the orca CLI upload files to your Orca account and mint public links."
+ "allowPublishingWebDescription": "Desktop only. Open Settings → Artifacts on the host device to change this setting.",
+ "allowPublishingDescription": "Publish HTML and Markdown files as links anyone with the URL can open. Existing links remain until you delete them from Artifacts.",
+ "howToDescriptionDisabled": "Enable artifact sharing above to publish HTML or Markdown files as public links.",
+ "allowPublishingSearchDescription": "Allow Orca to publish HTML and Markdown files as public links."
},
"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.",
+ "signedOut": "Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.",
"checking": "Checking account status…",
"account": "Orca account",
"signOut": "Sign out",
@@ -14968,14 +14968,14 @@
"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.",
+ "emptyCopy": "Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.",
"openArtifact": "Open artifact",
"deleteArtifact": "Delete artifact",
"moreAvailable": "More artifacts are available",
"moreAvailableCopy": "Load the next page to continue.",
"loadMoreFailed": "Could not load more artifacts.",
"publishingOff": "Publishing is turned off",
- "publishingOffCopy": "Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then ask your agent to share an HTML or Markdown file.",
+ "publishingOffCopy": "Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.",
"openArtifactsSettings": "Open Settings → Artifacts",
"loadedCountMore": "{{count}} loaded · more available",
"loadedCount": "{{count}} shared",
@@ -15009,7 +15009,45 @@
"updatedRecently": "recently",
"expiryUnknown": "Expiry unknown",
"expired": "Link expired",
- "expires": "Link expires {{when}}"
+ "expires": "Link expires {{when}}",
+ "ArtifactPublishButton": {
+ "a4a49da6af": "Share as artifact",
+ "confirmTitle": "Share as artifact",
+ "confirmDescription": "This publishes the current file at a link anyone with the URL can view.",
+ "accountTitle": "Orca account",
+ "accountDescription": "Sign in to create and manage this link.",
+ "signingIn": "Signing in…",
+ "signInAgain": "Sign in again",
+ "signIn": "Sign in",
+ "publishingOffTitle": "Artifact sharing is off",
+ "publishingOffDescription": "Learn about public links and enable sharing in Settings.",
+ "openSettings": "Open Artifacts settings",
+ "sharing": "Sharing…",
+ "sharePublicLink": "Share public link",
+ "publishedDescription": "Anyone with this link can view the shared file.",
+ "checkingLink": "Checking for an existing link…",
+ "checkFailed": "Could not check for an existing link.",
+ "tryAgain": "Try again"
+ },
+ "artifact-publish-flow": {
+ "9a078a0c65": "Artifact sharing is unavailable",
+ "bba20daa6d": "Sign in to Orca and try again.",
+ "54b1805328": "Could not share artifact",
+ "430019efd0": "Artifact shared",
+ "2fc727c831": "Artifact updated",
+ "5cb4f5ec36": "Copy link",
+ "fbb5018602": "This file is empty.",
+ "6112db5a1c": "Artifacts shared from Orca must be smaller than 800 KB.",
+ "e2ed5acd8c": "Orca couldn't read this file. Open it from a workspace and try again.",
+ "6d475e9b25": "Only local HTML and Markdown files can be shared as artifacts.",
+ "29a406be09": "Artifacts must contain text."
+ },
+ "ArtifactPublishedLinkPanel": {
+ "copyLink": "Copy link",
+ "openLink": "Open link",
+ "updating": "Updating…",
+ "update": "Update shared content"
+ }
}
},
"i18n": {
diff --git a/src/shared/artifact-cli-bridge.ts b/src/shared/artifact-cli-bridge.ts
index 954001bc5..eea4fcd5c 100644
--- a/src/shared/artifact-cli-bridge.ts
+++ b/src/shared/artifact-cli-bridge.ts
@@ -1,5 +1,9 @@
export const REMOTE_ARTIFACT_INPUT_ENV = 'ORCA_REMOTE_ARTIFACT_INPUT'
+export function sshArtifactSourceKey(targetId: string, sourceKey: string): string {
+ return JSON.stringify(['ssh', targetId, sourceKey])
+}
+
export type RemoteArtifactInput = {
sourceKey: string
fileName: string
diff --git a/src/shared/artifacts.ts b/src/shared/artifacts.ts
index 9768e7a50..02e1f674f 100644
--- a/src/shared/artifacts.ts
+++ b/src/shared/artifacts.ts
@@ -1,5 +1,9 @@
export const ARTIFACT_CLI_MAX_RPC_BYTES = 800 * 1024
+export function artifactWriteRequestByteLength(request: ArtifactWriteRequest): number {
+ return new TextEncoder().encode(JSON.stringify(request)).byteLength
+}
+
export type ArtifactMetadata = {
version: 1
slug: string
@@ -34,6 +38,15 @@ export type ArtifactWriteRequest = {
authToken?: string
}
+export type ArtifactPublishResult = {
+ change: 'created' | 'updated'
+ item: ArtifactListItem
+}
+
+export type ArtifactPublishedLink = {
+ shareUrl: string
+}
+
export type ArtifactCloudOptions = {
apiUrl?: string
authToken?: string