Add manual artifact sharing from HTML and Markdown views (#13369)

(cherry picked from commit 6397668271)
This commit is contained in:
Jinwoo Hong 2026-08-09 16:11:16 -07:00 committed by Jinjing
parent 8bd61feb97
commit ded700760f
39 changed files with 2067 additions and 228 deletions

View File

@ -0,0 +1,39 @@
import type { ArtifactWriteRequest } from '../../shared/artifacts'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
export function artifactWriteBody(request: ArtifactWriteRequest): Record<string, string> {
return {
content: request.content,
contentType: request.contentType,
fileName: request.fileName,
...(request.title ? { title: request.title } : {})
}
}
export async function artifactRequest<T>(
apiUrl: string,
token: string,
path: string,
options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
): Promise<T> {
const response = await fetch(`${apiUrl}/v1/artifacts${path}`, {
method: options.method ?? 'GET',
headers: {
authorization: `Bearer ${token}`,
...(options.editToken ? { 'x-orca-edit-token': options.editToken } : {}),
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
...(options.body ? { 'content-type': 'application/json' } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined,
redirect: 'error',
signal: AbortSignal.timeout(20_000)
})
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { code?: string } | null
throw new OrcaCloudRequestError(response.status, body?.code)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}

View File

@ -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`)

View File

@ -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<Response>((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<Response>((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<Response>((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 })

View File

@ -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<ArtifactCloudOperation<ArtifactListPage>> {
return this.withAuth(options, async (token, apiUrl) => {
@ -131,88 +135,121 @@ export class ArtifactCloudService {
})
}
getPublishedLink(
request: ArtifactCloudOptions & { sourceKey: string }
): Promise<ArtifactCloudOperation<ArtifactPublishedLink | null>> {
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<ArtifactCloudOperation<ArtifactListItem>> {
assertArtifactSharingAllowed(this.isSharingEnabled)
const idempotencyKey = randomUUID()
return this.withAuth(request, async (token, apiUrl, auth) => {
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
method: 'POST',
body: writeBody(request),
idempotencyKey
})
auth.assertCurrent()
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
slug: response.artifact.slug,
editToken: response.editToken,
shareUrl: response.shareUrl,
expiresAt: response.artifact.expiresAt,
...auth.scope
})
return { artifact: response.artifact, shareUrl: response.shareUrl }
})
return this.withAuth(request, (token, apiUrl, auth) =>
this.publisher.share(request, token, apiUrl, auth, idempotencyKey)
)
}
async publish(
request: ArtifactWriteRequest
): Promise<ArtifactCloudOperation<ArtifactPublishResult>> {
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<ArtifactCloudOperation<ArtifactListItem>> {
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<ArtifactListItem>(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<ArtifactListItem>(
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<ArtifactCloudOperation<void>> {
return this.withAuth(request, async (token, apiUrl, auth) => {
const record = getArtifactShareRecord(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope
)
if (!record) {
throw new Error('This file has not been shared from the active Orca profile.')
}
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
method: 'DELETE',
editToken: record.editToken
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<void>(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<ArtifactCloudOperation<void>> {
return this.withAuth(options, async (token, apiUrl, auth) => {
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, { method: 'DELETE' })
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
})
return this.withAuth(options, (token, apiUrl, auth) =>
this.publisher.runForSlug(id, auth, async () => {
auth.assertCurrent()
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, {
method: 'DELETE'
})
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
})
)
}
private async withAuth<T>(
@ -256,40 +293,3 @@ export class ArtifactCloudService {
: { status: 'reconnect-required' }
}
}
function writeBody(request: ArtifactWriteRequest): Record<string, string> {
return {
content: request.content,
contentType: request.contentType,
fileName: request.fileName,
...(request.title ? { title: request.title } : {})
}
}
async function artifactRequest<T>(
apiUrl: string,
token: string,
path: string,
options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
): Promise<T> {
const response = await fetch(`${apiUrl}/v1/artifacts${path}`, {
method: options.method ?? 'GET',
headers: {
authorization: `Bearer ${token}`,
...(options.editToken ? { 'x-orca-edit-token': options.editToken } : {}),
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
...(options.body ? { 'content-type': 'application/json' } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined,
redirect: 'error',
signal: AbortSignal.timeout(20_000)
})
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { code?: string } | null
throw new OrcaCloudRequestError(response.status, body?.code)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}

View File

@ -0,0 +1,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<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
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<string, Promise<void>>()
constructor(private readonly userDataPath: string) {}
async share(
request: ArtifactWriteRequest,
token: string,
apiUrl: string,
auth: ArtifactPublishAuthContext,
idempotencyKey: string
): Promise<ArtifactListItem> {
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<ArtifactPublishResult> {
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<ArtifactListItem>(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<T>(
sourceKey: string,
auth: Pick<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
operation: () => Promise<T>
): Promise<T> {
return this.runSerialized(artifactOperationQueueKey('source', auth, sourceKey), operation)
}
runForSlug<T>(
slug: string,
auth: Pick<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
operation: () => Promise<T>
): Promise<T> {
return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation)
}
private async create(
request: ArtifactWriteRequest,
token: string,
apiUrl: string,
auth: ArtifactPublishAuthContext,
idempotencyKey: string
): Promise<ArtifactPublishResult> {
const response = await artifactRequest<ArtifactCreateResponse>(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<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = this.queues.get(key) ?? Promise.resolve()
let release = (): void => {}
const released = new Promise<void>((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)
}
}
}
}

View File

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

View File

@ -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<ArtifactCloudOperation<ArtifactPublishedLink | null>> {
return this.requireArtifactService().getPublishedLink(request)
}
shareArtifact(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
return this.requireArtifactService().share(request)
}
publishArtifact(
request: ArtifactWriteRequest
): Promise<ArtifactCloudOperation<ArtifactPublishResult>> {
return this.requireArtifactService().publish(request)
}
updateArtifact(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
return this.requireArtifactService().update(request)
}

View File

@ -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: '<h1>Report</h1>',
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)
})
})

View File

@ -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({

View File

@ -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')
})
})

View File

@ -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 })
}

View File

@ -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<string, unknown>,
orcaProfileConnecting: false,
settings: { artifactSharingEnabled: true }
}
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => 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 }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => (
<span onClick={() => mocks.openPopover?.(true)}>{children}</span>
)
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
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(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={createRequest} />)
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(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
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(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
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(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
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(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={createRequest} />)
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(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
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(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
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<boolean>((resolve) => {
finishCopy = resolve
})
)
const timeoutSpy = vi.spyOn(window, 'setTimeout')
const view = render(
<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />
)
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)
})
})

View File

@ -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<ArtifactWriteRequest>
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<PublishedLinkLookup | null>(null)
const lookupSequence = useRef(0)
const popoverContentRef = useRef<HTMLDivElement>(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<void> => {
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 (
<Popover open={open} onOpenChange={(nextOpen) => !busy && setOpen(nextOpen)}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn('shrink-0', className)}
disabled={blocked}
aria-label={label}
>
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
<PopoverContent
ref={popoverContentRef}
tabIndex={-1}
align="end"
sideOffset={6}
className="w-80 p-0"
onOpenAutoFocus={(event) => {
event.preventDefault()
popoverContentRef.current?.focus({ preventScroll: true })
}}
>
<div className="space-y-1 border-b border-border/60 px-4 py-3.5">
<h3 className="text-sm font-semibold">
{translate(
'auto.components.artifacts.ArtifactPublishButton.confirmTitle',
'Share as artifact'
)}
</h3>
<p className="text-xs leading-5 text-muted-foreground">
{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.'
)}
</p>
</div>
<div className="space-y-3 p-4">
{!signedIn ? (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<p className="text-xs font-medium">
{translate(
'auto.components.artifacts.ArtifactPublishButton.accountTitle',
'Orca account'
)}
</p>
<p className="text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.artifacts.ArtifactPublishButton.accountDescription',
'Sign in to create and manage this link.'
)}
</p>
</div>
<Button
type="button"
variant="outline"
size="xs"
disabled={connecting || authStatus?.configured !== true}
onClick={() => 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'
)}
</Button>
</div>
) : null}
{!sharingEnabled ? (
<div className={cn('space-y-2', !signedIn && 'border-t border-border/60 pt-3')}>
<div className="space-y-0.5">
<p className="text-xs font-medium">
{translate(
'auto.components.artifacts.ArtifactPublishButton.publishingOffTitle',
'Artifact sharing is off'
)}
</p>
<p className="text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.artifacts.ArtifactPublishButton.publishingOffDescription',
'Learn about public links and enable sharing in Settings.'
)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={openArtifactsSettings}
>
{translate(
'auto.components.artifacts.ArtifactPublishButton.openSettings',
'Open Artifacts settings'
)}
<ArrowRight />
</Button>
</div>
) : null}
{checkingLink ? (
<div className="flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{translate(
'auto.components.artifacts.ArtifactPublishButton.checkingLink',
'Checking for an existing link…'
)}
</div>
) : currentLookup?.status === 'error' ? (
<div className="space-y-2">
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.artifacts.ArtifactPublishButton.checkFailed',
'Could not check for an existing link.'
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setLookupRevision((current) => current + 1)}
>
{translate('auto.components.artifacts.ArtifactPublishButton.tryAgain', 'Try again')}
</Button>
</div>
) : publishedLink ? (
<ArtifactPublishedLinkPanel
key={publishedLink}
shareUrl={publishedLink}
publishing={publishing}
sharingEnabled={sharingEnabled}
onUpdate={() => void publish()}
/>
) : (
<Button
type="button"
size="sm"
className="w-full"
disabled={!signedIn || !sharingEnabled || busy}
onClick={() => void publish()}
>
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
{publishing
? translate('auto.components.artifacts.ArtifactPublishButton.sharing', 'Sharing…')
: translate(
'auto.components.artifacts.ArtifactPublishButton.sharePublicLink',
'Share public link'
)}
</Button>
)}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -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<number | null>(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<void> => {
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 (
<div ref={setPanelRef} className="space-y-3">
<div className="flex min-w-0 items-center gap-0.5">
<p
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground"
title={shareUrl}
>
{shareUrl}
</p>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={() => void copyLink()}
aria-label={copyLabel}
>
{copied ? <Check className="size-3.5" /> : <Copy />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{copyLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={() => openArtifactInBrowser(shareUrl)}
aria-label={translate(
'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink',
'Open link'
)}
>
<ExternalLink />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink',
'Open link'
)}
</TooltipContent>
</Tooltip>
</div>
{sharingEnabled ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={publishing}
onClick={onUpdate}
>
{publishing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{publishing
? translate(
'auto.components.artifacts.ArtifactPublishedLinkPanel.updating',
'Updating…'
)
: translate(
'auto.components.artifacts.ArtifactPublishedLinkPanel.update',
'Update shared content'
)}
</Button>
) : null}
</div>
)
}

View File

@ -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(

View File

@ -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.'
)}
</p>
{!nextCursor && publishingBlocked ? (

View File

@ -1,12 +1,19 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export async function copyArtifactLink(shareUrl: string): Promise<void> {
export async function copyArtifactLink(
shareUrl: string,
options: { showSuccessToast?: boolean } = {}
): Promise<boolean> {
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
}
}

View File

@ -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: '<h1>Report</h1>',
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: '<h1>Fresh</h1>' })
await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published)
expect(mocks.connect).toHaveBeenCalledOnce()
expect(createRequest).toHaveBeenCalledTimes(2)
expect(mocks.callRuntimeRpc.mock.calls[1]?.[2]).toMatchObject({
content: '<h1>Fresh</h1>'
})
})
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')
})
})

View File

@ -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<ArtifactWriteRequest>
): Promise<ArtifactPublishResult | null> {
try {
if (!(await ensureArtifactAccountConnected())) {
return null
}
for (let attempt = 0; attempt < 2; attempt += 1) {
const request = validateArtifactPublishRequest(await createRequest())
const result = await callRuntimeRpc<ArtifactCloudOperation<ArtifactPublishResult>>(
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<boolean> {
const state = useAppStore.getState()
if (state.orcaProfileAuthStatus?.state === 'connected') {
return true
}
return (await state.connectCurrentOrcaProfile())?.status === 'connected'
}
async function reconnectArtifactAccount(): Promise<boolean> {
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.'
)
}
}

View File

@ -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')
})
})

View File

@ -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<string | null> {
const result = await callRuntimeRpc<ArtifactCloudOperation<ArtifactPublishedLink | null>>(
LOCAL_RUNTIME,
'artifacts.getPublishedLink',
{ sourceKey }
)
if (result.status === 'ok') {
return result.value?.shareUrl ?? null
}
throw new Error(result.status)
}

View File

@ -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<HTMLDivElement>) => void>(() => {})
const handleInternalFileDropRef = useRef<(event: DragEvent<HTMLDivElement>) => 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 ? (
<ArtifactPublishButton
sourceKey={shareableArtifactFile.filePath}
className="h-7 w-7"
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
/>
) : null}
<Button
size="icon"
variant="ghost"

View File

@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
import type { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
import {
getShareableBrowserArtifactFile,
readBrowserHtmlArtifactRequest
} from './browser-artifact-upload'
const stat = vi.fn()
const readFile = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
window.api = { fs: { stat, readFile } } as never
stat.mockResolvedValue({ size: 20, isDirectory: false, mtime: 1 })
readFile.mockResolvedValue({ content: '<h1>Mock</h1>', 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: '<h1>Mock</h1>',
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<ArtifactPublishPreparationError>)
expect(readFile).not.toHaveBeenCalled()
stat.mockRejectedValueOnce(new Error('access denied'))
await expect(readBrowserHtmlArtifactRequest('file:///tmp/private.html')).rejects.toMatchObject({
code: 'unreadable'
} satisfies Partial<ArtifactPublishPreparationError>)
})
})

View File

@ -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<ArtifactWriteRequest> {
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')
}
}

View File

@ -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()
})

View File

@ -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}

View File

@ -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: () => <button data-artifact-publish />
}))
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<typeof EditorPanelHeader>
function renderHeader(overrides: Partial<ComponentProps<typeof EditorPanelHeader>> = {}): string {
return renderToStaticMarkup(<EditorPanelHeader {...baseProps} {...overrides} />)
}
describe('EditorPanelHeader', () => {
it('shares one tooltip provider across the diff header controls', () => {
const html = renderToStaticMarkup(
<EditorPanelHeader
activeFile={activeFile}
copiedPathVisible={false}
isSingleDiff={false}
isDiffSurface
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()}
/>
)
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')
})
})

View File

@ -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<ArtifactWriteRequest>
}
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({
</Tooltip>
</TooltipProvider>
)}
{isMarkdown && !isDiffSurface && createMarkdownArtifactRequest ? (
<ArtifactPublishButton
sourceKey={markdownArtifactSourceKey(activeFile)}
className="size-6 [&_svg]:size-3.5!"
createRequest={createMarkdownArtifactRequest}
/>
) : null}
<EditorPanelMarkdownActionsMenu
isMarkdown={isMarkdown}
isDiffSurface={isDiffSurface}

View File

@ -10,6 +10,7 @@ import type { DiffContent, FileContent } from './editor-panel-content-types'
import type { EditorToggleValue } from './EditorViewToggle'
import { getUntitledFileRoot } from './untitled-file-rename-path'
import { translate } from '@/i18n/i18n'
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
type EditorPanelRenderModel = ReturnType<typeof getEditorPanelRenderModel>
@ -41,6 +42,7 @@ type EditorPanelShellProps = {
onToggleMarkdownTableOfContents: () => void
onToggleMarkdownFrontmatter: () => void
onExportMarkdownToPdf: () => void
createMarkdownArtifactRequest?: () => Promise<ArtifactWriteRequest>
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}
/>
)}
<Suspense fallback={<EditorLoadingFallback />}>

View File

@ -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<string, string>,
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> = {}): 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')
})
})

View File

@ -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)
}

View File

@ -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(<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />)
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', () => {
<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
)
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(
<ArtifactsSettingsPane
@ -208,8 +212,8 @@ describe('ArtifactsSettingsPane', () => {
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', () => {

View File

@ -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.'
)}
</p>
</div>

View File

@ -82,7 +82,11 @@ describe('OrcaAccountSettingsPane', () => {
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
render(<OrcaAccountSettingsPane />)
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()
})

View File

@ -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…')

View File

@ -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'),

View File

@ -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": {

View File

@ -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

View File

@ -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