Polish Artifacts management UI (#13356)

* refactor(artifacts): polish artifact management UI

* fix(artifacts): address UI polish review

---------

Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong 2026-08-09 12:11:33 -07:00 committed by Jinjing
parent 6169d67bdf
commit 3eab34253c
21 changed files with 1036 additions and 234 deletions

View File

@ -140,6 +140,7 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
skipDeleteWorktreeConfirm: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
skipDeleteArtifactConfirm: false,
skipCodexRateLimitResetConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',

View File

@ -132,6 +132,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
skipDeleteWorktreeConfirm: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
skipDeleteArtifactConfirm: false,
skipCodexRateLimitResetConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',

View File

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

View File

@ -14,9 +14,14 @@ vi.mock('./ArtifactActions', () => ({
ArtifactActions: () => <div>Artifact actions</div>
}))
import { TooltipProvider } from '@/components/ui/tooltip'
import { ArtifactCollection } from './ArtifactCollection'
const DAY_MS = 24 * 60 * 60 * 1000
// Why: relative to now — the labels under test are relative times, so fixed dates would rot.
function artifact(slug: string, title: string): ArtifactListItem {
const createdAt = new Date(Date.now() - DAY_MS).toISOString()
return {
artifact: {
version: 1,
@ -25,9 +30,9 @@ function artifact(slug: string, title: string): ArtifactListItem {
originalFileName: `${slug}.html`,
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-07T12:00:00.000Z',
updatedAt: '2026-08-07T12:00:00.000Z',
expiresAt: '2026-09-07T12:00:00.000Z',
createdAt,
updatedAt: createdAt,
expiresAt: new Date(Date.now() + 30 * DAY_MS).toISOString(),
byteSize: 1200,
deletedAt: null
},
@ -38,29 +43,90 @@ function artifact(slug: string, title: string): ArtifactListItem {
describe('ArtifactCollection', () => {
afterEach(cleanup)
function renderCollection(
items: ArtifactListItem[],
selectArtifact = vi.fn()
): { container: HTMLElement; selectArtifact: ReturnType<typeof vi.fn> } {
const { container } = render(
<TooltipProvider>
<ArtifactCollection
artifacts={items}
deletingId={null}
selectedArtifact={items[0]}
selectArtifact={selectArtifact}
deleteArtifact={vi.fn()}
hasMore={false}
loadingMore={false}
loadMore={vi.fn()}
/>
</TooltipProvider>
)
return { container, selectArtifact }
}
it('keeps the artifact list beside a contained preview', async () => {
const items = [artifact('first', 'First artifact'), artifact('second', 'Second artifact')]
const selectArtifact = vi.fn()
const { container } = render(
<ArtifactCollection
artifacts={items}
deletingId={null}
selectedArtifact={items[0]}
selectArtifact={selectArtifact}
deleteArtifact={vi.fn()}
hasMore={false}
loadingMore={false}
loadMore={vi.fn()}
/>
)
const { container, selectArtifact } = renderCollection(items)
const collection = container.firstElementChild
expect(collection).toHaveClass('grid-cols-[16rem_minmax(0,1fr)]')
expect(collection?.children[0]?.tagName).toBe('ASIDE')
// Why: full-bleed split — no card frame around the panes.
expect(collection).toHaveClass('lg:grid-cols-[minmax(240px,300px)_minmax(0,1fr)]')
expect(collection).not.toHaveClass('rounded-md')
expect(collection?.children[1]?.tagName).toBe('SECTION')
expect(screen.getByText('Preview https://share.onorca.dev/a/first')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /Second artifact/ }))
await userEvent.click(screen.getByRole('option', { name: /Second artifact/ }))
expect(selectArtifact).toHaveBeenCalledWith('second')
})
it('exposes the list as a single-tab-stop listbox', () => {
const items = [artifact('first', 'First artifact'), artifact('second', 'Second artifact')]
renderCollection(items)
expect(screen.getByRole('listbox', { name: 'Shared artifacts' })).toBeInTheDocument()
const [first, second] = screen.getAllByRole('option')
expect(first).toHaveAttribute('aria-selected', 'true')
expect(first).toHaveAttribute('aria-current', 'page')
expect(first).toHaveAttribute('tabindex', '0')
expect(second).toHaveAttribute('aria-selected', 'false')
expect(second).toHaveAttribute('tabindex', '-1')
})
it('moves focus with arrows and commits selection on Enter', async () => {
const items = [artifact('first', 'First artifact'), artifact('second', 'Second artifact')]
const { selectArtifact } = renderCollection(items)
const [first, second] = screen.getAllByRole('option')
first.focus()
await userEvent.keyboard('{ArrowDown}')
expect(second).toHaveFocus()
// Why: arrows must not commit — each selection reloads the preview webview.
expect(selectArtifact).not.toHaveBeenCalled()
await userEvent.keyboard('{Enter}')
expect(selectArtifact).toHaveBeenCalledWith('second')
})
it('filters the list by name and keeps the preview mounted', async () => {
const items = [artifact('first', 'First artifact'), artifact('second', 'Second artifact')]
renderCollection(items)
await userEvent.type(screen.getByPlaceholderText('Search artifacts'), 'second')
expect(screen.getAllByRole('option')).toHaveLength(1)
expect(screen.getByRole('option', { name: /Second artifact/ })).toBeInTheDocument()
expect(screen.getByText('Preview https://share.onorca.dev/a/first')).toBeInTheDocument()
await userEvent.clear(screen.getByPlaceholderText('Search artifacts'))
await userEvent.type(screen.getByPlaceholderText('Search artifacts'), 'nothing')
expect(screen.queryAllByRole('option')).toHaveLength(0)
expect(screen.getByText('No matches')).toBeInTheDocument()
})
it('shows the share url and expiry instead of repeating the row metadata', () => {
const items = [artifact('first', 'First artifact')]
renderCollection(items)
expect(screen.getByText('https://share.onorca.dev/a/first')).toBeInTheDocument()
expect(screen.getByText(/Link expires/)).toBeInTheDocument()
})
})

View File

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

View File

@ -0,0 +1,55 @@
import { Globe } from 'lucide-react'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { ArtifactActions } from './ArtifactActions'
import {
artifactName,
formatArtifactExpiry,
formatArtifactUpdatedAt,
formatByteSize
} from './artifact-display-labels'
export function ArtifactDetailHeader({
deleting,
item,
onDelete
}: {
deleting: boolean
item: ArtifactListItem
onDelete: (target: ArtifactListItem) => void
}): React.JSX.Element {
return (
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/50 px-4 py-3">
{/* Why: a floor rather than min-w-0 — otherwise the title truncates to nothing before the actions wrap. */}
<div className="min-w-40 flex-1 space-y-0.5">
<h2 className="truncate text-sm font-semibold">{artifactName(item)}</h2>
<div className="flex min-w-0 items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Globe className="size-3 shrink-0 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.artifacts.ArtifactDetailHeader.publicLink',
'Anyone with this link can view it'
)}
</TooltipContent>
</Tooltip>
<span className="sr-only">
{translate(
'auto.components.artifacts.ArtifactDetailHeader.publicLink',
'Anyone with this link can view it'
)}
</span>
<p className="truncate font-mono text-xs text-muted-foreground">{item.shareUrl}</p>
</div>
<p className="truncate text-[11px] text-muted-foreground">
{formatArtifactUpdatedAt(item.artifact.updatedAt)} ·{' '}
{formatByteSize(item.artifact.byteSize)} · {formatArtifactExpiry(item.artifact.expiresAt)}
</p>
</div>
<ArtifactActions deleting={deleting} item={item} onDelete={onDelete} />
</div>
)
}

View File

@ -0,0 +1,209 @@
import { useMemo, useRef, useState } from 'react'
import { Copy, ExternalLink, Loader2, Search, Trash2 } from 'lucide-react'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import {
artifactName,
artifactTypeIcon,
formatArtifactDate,
formatArtifactExpiry,
formatArtifactUpdatedAt,
formatByteSize
} from './artifact-display-labels'
import { copyArtifactLink, openArtifactInBrowser } from './artifact-link-actions'
const OPTION_SELECTOR = '[role="option"]'
function moveOptionFocus(listbox: HTMLElement | null, from: HTMLElement, step: number): void {
const options = [...(listbox?.querySelectorAll<HTMLElement>(OPTION_SELECTOR) ?? [])]
const next = options[options.indexOf(from) + step]
next?.focus()
}
function focusEdgeOption(listbox: HTMLElement | null, edge: 'first' | 'last'): void {
const options = [...(listbox?.querySelectorAll<HTMLElement>(OPTION_SELECTOR) ?? [])]
const target = edge === 'first' ? options.at(0) : options.at(-1)
target?.focus()
}
export function ArtifactListPane({
artifacts,
className,
deletingId,
selectedArtifact,
selectArtifact,
deleteArtifact,
hasMore,
loadingMore,
loadMore
}: {
artifacts: readonly ArtifactListItem[]
className?: string
deletingId: string | null
selectedArtifact: ArtifactListItem
selectArtifact: (slug: string) => void
deleteArtifact: (item: ArtifactListItem) => void
hasMore: boolean
loadingMore: boolean
loadMore: () => void
}): React.JSX.Element {
const listboxRef = useRef<HTMLDivElement>(null)
const [query, setQuery] = useState('')
const normalizedQuery = query.trim().toLowerCase()
const matches = useMemo(
() =>
normalizedQuery
? artifacts.filter((item) => artifactName(item).toLowerCase().includes(normalizedQuery))
: artifacts,
[artifacts, normalizedQuery]
)
// Why: arrows move focus only — committing selection would reload the preview webview on every keypress.
const onOptionKeyDown = (event: React.KeyboardEvent<HTMLDivElement>, slug: string): void => {
const option = event.currentTarget
if (event.key === 'ArrowDown') {
event.preventDefault()
moveOptionFocus(listboxRef.current, option, 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
moveOptionFocus(listboxRef.current, option, -1)
} else if (event.key === 'Home') {
event.preventDefault()
focusEdgeOption(listboxRef.current, 'first')
} else if (event.key === 'End') {
event.preventDefault()
focusEdgeOption(listboxRef.current, 'last')
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
selectArtifact(slug)
}
}
return (
<div className={cn('flex min-h-0 flex-col', className)}>
<div className="relative shrink-0 border-b border-border/40 px-2 py-2">
<Search className="pointer-events-none absolute left-4.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={translate(
'auto.components.artifacts.ArtifactListPane.search',
'Search artifacts'
)}
className="h-8 pl-8 text-sm"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div
ref={listboxRef}
role="listbox"
aria-label={translate(
'auto.components.artifacts.ArtifactListPane.listLabel',
'Shared artifacts'
)}
aria-orientation="vertical"
>
{matches.map((item) => {
const selected = item.artifact.slug === selectedArtifact.artifact.slug
const name = artifactName(item)
const TypeIcon = artifactTypeIcon(item)
return (
<ContextMenu key={item.artifact.slug}>
<ContextMenuTrigger asChild>
<div
role="option"
aria-selected={selected}
aria-current={selected ? 'page' : undefined}
data-current={selected ? 'true' : undefined}
tabIndex={selected ? 0 : -1}
onClick={() => selectArtifact(item.artifact.slug)}
onKeyDown={(event) => onOptionKeyDown(event, item.artifact.slug)}
className={cn(
'flex w-full cursor-pointer items-center gap-3 border-b border-border/50 px-3 py-3 text-left transition-colors last:border-b-0 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
selected && 'bg-accent'
)}
>
<TypeIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate text-sm font-medium">{name}</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
<p className="font-medium">{name}</p>
<p className="text-background/70">
{formatArtifactDate(item.artifact.updatedAt)}
</p>
<p className="text-background/70">
{formatArtifactExpiry(item.artifact.expiresAt)}
</p>
</TooltipContent>
</Tooltip>
<span className="block truncate text-xs text-muted-foreground">
{formatArtifactUpdatedAt(item.artifact.updatedAt)} ·{' '}
{formatByteSize(item.artifact.byteSize)}
</span>
</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => void copyArtifactLink(item.shareUrl)}>
<Copy />
{translate('auto.components.artifacts.copyLink', 'Copy link')}
</ContextMenuItem>
<ContextMenuItem onSelect={() => openArtifactInBrowser(item.shareUrl)}>
<ExternalLink />
{translate('auto.components.artifacts.openInBrowser', 'Open in browser')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={deletingId === item.artifact.slug}
onSelect={() => deleteArtifact(item)}
>
<Trash2 />
{translate(
'auto.components.artifacts.ArtifactsPage.deleteArtifact',
'Delete artifact'
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
{matches.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{translate('auto.components.artifacts.ArtifactListPane.noMatches', 'No matches')}
</p>
) : null}
{hasMore ? (
<div className="border-t border-border/50 p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
disabled={loadingMore}
onClick={loadMore}
>
{loadingMore ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
</Button>
</div>
) : null}
</div>
</div>
)
}

View File

@ -44,7 +44,7 @@ function attachArtifactWebview({
webview.style.width = '100%'
webview.style.height = '100%'
webview.style.border = 'none'
webview.style.background = '#ffffff'
// Why: forcing white flashes beneath dark artifact pages during navigation.
webview.addEventListener('did-start-loading', onLoadStarted)
webview.addEventListener('did-stop-loading', onLoadStopped)
webview.addEventListener('did-fail-load', onLoadFailed)
@ -138,14 +138,17 @@ export function ArtifactPreview({ shareUrl }: { shareUrl: string }): React.JSX.E
}, [shareUrl])
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden bg-white" ref={containerRef}>
<div
className="relative flex min-h-0 flex-1 overflow-hidden bg-editor-surface"
ref={containerRef}
>
{state === 'loading' ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background">
<div className="absolute inset-0 z-10 flex items-center justify-center bg-editor-surface">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : null}
{state === 'unavailable' ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background px-6 text-center">
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-editor-surface px-6 text-center">
<AlertCircle className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">
{translate('auto.components.artifacts.previewUnavailable', 'Preview unavailable')}

View File

@ -18,6 +18,10 @@ const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
refreshAuth: vi.fn(),
rpc: vi.fn(),
settings: { skipDeleteArtifactConfirm: false } as Record<string, unknown>,
updateSettings: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(),
resolvePartition: vi.fn(),
writeClipboardText: vi.fn(),
openUrl: vi.fn(),
@ -56,7 +60,11 @@ function storeState(): Record<string, unknown> {
connectCurrentOrcaProfile: mocks.connect,
orcaProfileAuthStatus: mocks.authStatus,
orcaProfileConnecting: false,
refreshCurrentOrcaProfileAuth: mocks.refreshAuth
refreshCurrentOrcaProfileAuth: mocks.refreshAuth,
settings: mocks.settings,
updateSettings: mocks.updateSettings,
openSettingsPage: mocks.openSettingsPage,
openSettingsTarget: mocks.openSettingsTarget
}
}
@ -76,6 +84,10 @@ describe('ArtifactsPage', () => {
mocks.confirm.mockReset()
mocks.refreshAuth.mockReset()
mocks.rpc.mockReset()
mocks.settings = { skipDeleteArtifactConfirm: false }
mocks.updateSettings.mockReset().mockResolvedValue(undefined)
mocks.openSettingsPage.mockReset()
mocks.openSettingsTarget.mockReset()
mocks.resolvePartition.mockReset().mockResolvedValue('persist:orca-default')
mocks.writeClipboardText.mockReset().mockResolvedValue(undefined)
mocks.openUrl.mockReset().mockResolvedValue(undefined)
@ -118,7 +130,8 @@ describe('ArtifactsPage', () => {
it('renders the selected artifact in-app with copy link as the primary action', async () => {
render(<ArtifactsPage />)
expect(await screen.findAllByText('Quarterly report')).toHaveLength(2)
expect(await screen.findByRole('option', { name: /Quarterly report/ })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: 'Quarterly report' })).toBeInTheDocument()
const closeButton = screen.getByRole('button', { name: 'Close artifacts' })
expect(closeButton).toHaveClass('size-7', 'rounded-full')
expect(closeButton.closest('header')).toHaveClass('px-5', 'pb-3', 'pt-1.5', 'md:px-8')
@ -218,8 +231,8 @@ describe('ArtifactsPage', () => {
value: { artifacts: [artifactListItem('Second page', 'second-page')] }
})
expect(await screen.findByText('Second page')).toBeInTheDocument()
expect(screen.getAllByText('First page')).toHaveLength(2)
expect(await screen.findByRole('option', { name: /Second page/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /First page/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
})
@ -239,7 +252,7 @@ describe('ArtifactsPage', () => {
expect(screen.queryByText('No shared artifacts')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findAllByText('Older artifact')).toHaveLength(2)
expect(await screen.findByRole('option', { name: /Older artifact/ })).toBeInTheDocument()
})
it('keeps loaded artifacts when loading another page fails', async () => {
@ -254,11 +267,11 @@ describe('ArtifactsPage', () => {
.mockRejectedValueOnce(new Error('network down'))
render(<ArtifactsPage />)
await screen.findAllByText('Still visible')
await screen.findByRole('option', { name: /Still visible/ })
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findByText('Could not load more artifacts.')).toBeInTheDocument()
expect(screen.getAllByText('Still visible')).toHaveLength(2)
expect(screen.getByRole('option', { name: /Still visible/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Load more' })).toBeEnabled()
})
@ -283,7 +296,7 @@ describe('ArtifactsPage', () => {
state: 'connected'
}
view.rerender(<ArtifactsPage />)
expect(await screen.findAllByText('Account B')).toHaveLength(2)
expect(await screen.findByRole('option', { name: /Account B/ })).toBeInTheDocument()
resolveRefresh()
await waitFor(() =>
@ -323,7 +336,7 @@ describe('ArtifactsPage', () => {
state: 'connected'
}
view.rerender(<ArtifactsPage />)
expect(await screen.findAllByText('Account B')).toHaveLength(2)
expect(await screen.findByRole('option', { name: /Account B/ })).toBeInTheDocument()
resolveRefresh()
await waitFor(() =>
@ -407,7 +420,7 @@ describe('ArtifactsPage', () => {
view.rerender(<ArtifactsPage />)
resolveDelete({ status: 'ok', value: undefined })
expect(await screen.findAllByText('Shared slug B')).toHaveLength(2)
expect(await screen.findByRole('option', { name: /Shared slug B/ })).toBeInTheDocument()
})
it('does not resurrect a deletion from an older refresh', async () => {
@ -438,6 +451,44 @@ describe('ArtifactsPage', () => {
await waitFor(() => expect(screen.queryByText('Delete me')).not.toBeInTheDocument())
})
it('skips the delete confirmation once the preference is saved', async () => {
mocks.settings = { skipDeleteArtifactConfirm: true }
mocks.rpc.mockResolvedValue({
status: 'ok',
value: { artifacts: [artifactListItem('Skip me', 'skip-me')] }
})
render(<ArtifactsPage />)
await screen.findByRole('option', { name: /Skip me/ })
mocks.rpc.mockResolvedValueOnce({ status: 'ok', value: undefined })
fireEvent.click(screen.getByRole('button', { name: 'Delete artifact' }))
await waitFor(() => expect(screen.queryByRole('option', { name: /Skip me/ })).toBeNull())
expect(mocks.confirm).not.toHaveBeenCalled()
})
it('persists the skip preference only when the confirmation is accepted', async () => {
mocks.confirm.mockResolvedValue(true)
mocks.rpc.mockResolvedValue({
status: 'ok',
value: { artifacts: [artifactListItem('Ask me', 'ask-me')] }
})
render(<ArtifactsPage />)
await screen.findByRole('option', { name: /Ask me/ })
mocks.rpc.mockResolvedValueOnce({ status: 'ok', value: undefined })
fireEvent.click(screen.getByRole('button', { name: 'Delete artifact' }))
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
// Why: the dialog owns the checkbox; the page only supplies what to persist when it is checked.
const options = mocks.confirm.mock.calls[0]?.[0] as {
dontAskAgain?: { onConfirmed: () => void }
}
expect(mocks.updateSettings).not.toHaveBeenCalled()
options.dontAskAgain?.onConfirmed()
expect(mocks.updateSettings).toHaveBeenCalledWith({ skipDeleteArtifactConfirm: true })
})
it('treats an organization switch as an account identity change', () => {
const status = {
activeProfileId: 'profile-a',

View File

@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'
import { Files, Loader2, RefreshCw, X } from 'lucide-react'
import { ArrowRight, Files, Loader2, RefreshCw, X } from 'lucide-react'
import type { ArtifactCloudOperation, ArtifactListItem } from '../../../../shared/artifacts'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { persistConfirmationSkipPreference } from '@/components/confirmation-skip-preference'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
@ -18,10 +19,19 @@ export default function ArtifactsPage(): React.JSX.Element {
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const refreshAuth = useAppStore((state) => state.refreshCurrentOrcaProfileAuth)
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const settings = useAppStore((state) => state.settings)
const updateSettings = useAppStore((state) => state.updateSettings)
const confirm = useConfirmationDialog()
const [deleting, setDeleting] = useState<{ identity: string; slug: string } | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const signedIn = authStatus?.state === 'connected'
const needsReconnect = authStatus?.state === 'reconnect-required'
const openAccountSettings = (): void => {
openSettingsTarget({ pane: 'orca-account', repoId: null })
openSettingsPage()
}
const {
accountIdentity,
artifacts,
@ -52,6 +62,18 @@ export default function ArtifactsPage(): React.JSX.Element {
if (event.key !== 'Escape' || event.defaultPrevented) {
return
}
// Why: Esc clears field focus before closing the page, matching Automations.
const target = event.target
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
event.preventDefault()
target.blur()
return
}
event.preventDefault()
closePage()
}
@ -67,17 +89,32 @@ export default function ArtifactsPage(): React.JSX.Element {
const requestedAccountIsCurrent = (): boolean =>
artifactAccountIdentity(useAppStore.getState().orcaProfileAuthStatus) === requestedIdentity
const name = item.artifact.title || item.artifact.originalFileName || item.artifact.slug
const accepted = await confirm({
title: translate('auto.components.artifacts.ArtifactsPage.deleteTitle', 'Delete artifact?'),
description: translate(
'auto.components.artifacts.ArtifactsPage.deleteDescription',
'“{{name}}” will no longer be available at its public link.',
{ name }
),
confirmLabel: translate('auto.components.artifacts.ArtifactsPage.delete', 'Delete'),
confirmVariant: 'destructive'
})
if (!accepted || !requestedAccountIsCurrent()) {
if (!settings?.skipDeleteArtifactConfirm) {
const accepted = await confirm({
title: translate('auto.components.artifacts.ArtifactsPage.deleteTitle', 'Delete artifact?'),
description: translate(
'auto.components.artifacts.ArtifactsPage.deleteDescription',
'“{{name}}” will no longer be available at its public link.',
{ name }
),
confirmLabel: translate('auto.components.artifacts.ArtifactsPage.delete', 'Delete'),
confirmVariant: 'destructive',
dontAskAgain: {
onConfirmed: () =>
persistConfirmationSkipPreference({
updates: { skipDeleteArtifactConfirm: true },
settingsSectionId: 'general-skip-delete-artifact-confirm',
updateSettings,
openSettingsPage,
openSettingsTarget
})
}
})
if (!accepted) {
return
}
}
if (!requestedAccountIsCurrent()) {
return
}
setDeleting({ identity: requestedIdentity, slug: item.artifact.slug })
@ -139,9 +176,26 @@ export default function ArtifactsPage(): React.JSX.Element {
</Tooltip>
<div className="mx-1 h-5 w-px bg-border/50" aria-hidden />
<Files className="size-4 shrink-0 text-muted-foreground" />
<h1 className="truncate text-sm font-semibold">
{translate('auto.components.artifacts.ArtifactsPage.title', 'Artifacts')}
</h1>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold">
{translate('auto.components.artifacts.ArtifactsPage.title', 'Artifacts')}
</h1>
{signedIn && artifacts.length > 0 ? (
<p className="truncate text-xs text-muted-foreground">
{nextCursor
? translate(
'auto.components.artifacts.ArtifactsPage.loadedCountMore',
'{{count}} loaded · more available',
{ count: artifacts.length }
)
: translate(
'auto.components.artifacts.ArtifactsPage.loadedCount',
'{{count}} shared',
{ count: artifacts.length }
)}
</p>
) : null}
</div>
</div>
{signedIn ? (
<Tooltip>
@ -164,94 +218,135 @@ export default function ArtifactsPage(): React.JSX.Element {
) : null}
</header>
<div className="flex min-h-0 flex-1 border-t border-border/50 px-5 py-5 md:px-8">
<div className="mx-auto flex min-h-0 w-full flex-1 flex-col">
{!signedIn ? (
<div className="flex min-h-72 flex-col items-center justify-center gap-3 text-center">
<Files className="size-8 text-muted-foreground" />
<div className="space-y-1">
<h2 className="text-sm font-semibold">
{translate(
'auto.components.artifacts.ArtifactsPage.signInHeading',
'Sign in to Orca'
)}
</h2>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.artifacts.ArtifactsPage.signInCopy',
'Sign in to view and manage artifacts shared through your account.'
)}
</p>
</div>
<Button
size="sm"
disabled={connecting || authStatus?.configured !== true}
onClick={() => void connect()}
>
{connecting
? translate('auto.components.artifacts.ArtifactsPage.signingIn', 'Signing in…')
: translate('auto.components.artifacts.ArtifactsPage.signIn', 'Sign in to Orca')}
</Button>
</div>
) : loading && artifacts.length === 0 ? (
<div className="flex min-h-72 items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : artifacts.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<Files className="size-8 text-muted-foreground" />
{/* Why: pane edges match the full-bleed Automations layout. */}
<div className="flex min-h-0 w-full flex-1 flex-col border-t border-border/50">
{error ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-destructive/30 bg-destructive/10 px-5 py-2 md:px-8">
<p className="min-w-0 flex-1 text-xs text-destructive">{error}</p>
<Button
type="button"
variant="outline"
size="xs"
disabled={loading}
onClick={() => void loadArtifacts()}
>
{translate('auto.components.artifacts.ArtifactsPage.retry', 'Retry')}
</Button>
</div>
) : null}
{!signedIn ? (
<div className="flex min-h-72 flex-1 flex-col items-center justify-center gap-3 px-5 py-5 text-center md:px-8">
<Files className="size-8 text-muted-foreground" />
<div className="space-y-1">
<h2 className="text-sm font-semibold">
{nextCursor
{needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailable',
'More artifacts are available'
'auto.components.artifacts.ArtifactsPage.reconnectHeading',
'Sign in to Orca again'
)
: translate(
'auto.components.artifacts.ArtifactsPage.empty',
'No shared artifacts'
'auto.components.artifacts.ArtifactsPage.signInHeading',
'Sign in to share artifacts'
)}
</h2>
<p className="text-xs text-muted-foreground">
{nextCursor
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailableCopy',
'Load the next page to continue.'
'auto.components.artifacts.ArtifactsPage.reconnectCopy',
'Sign in again to view and manage the artifacts shared through your account.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.emptyCopy',
'Ask your agent to share an HTML or Markdown file, and it will appear here.'
'auto.components.artifacts.ArtifactsPage.signInCopy',
'Use your Orca account to upload artifacts and manage their public links.'
)}
</p>
{nextCursor ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
disabled={loadingMore}
onClick={() => void loadMoreArtifacts()}
>
{loadingMore ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
</Button>
) : null}
</div>
) : (
selectedArtifact && (
<ArtifactCollection
artifacts={artifacts}
deletingId={deletingId}
selectedArtifact={selectedArtifact}
selectArtifact={setSelectedSlug}
deleteArtifact={(target) => void deleteArtifact(target)}
hasMore={Boolean(nextCursor)}
loadingMore={loadingMore}
loadMore={() => void loadMoreArtifacts()}
/>
)
)}
{error ? <p className="mt-3 text-xs text-destructive">{error}</p> : null}
</div>
{authStatus?.configured === true ? (
<Button size="sm" disabled={connecting} onClick={() => void connect()}>
{connecting
? translate('auto.components.artifacts.ArtifactsPage.signingIn', 'Signing in…')
: needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.signInAgainAction',
'Sign in again'
)
: translate(
'auto.components.artifacts.ArtifactsPage.signIn',
'Sign in to Orca'
)}
</Button>
) : (
<div className="flex flex-col items-center gap-2">
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.artifacts.ArtifactsPage.unconfiguredCopy',
'Orca account sign-in is not configured on this machine yet.'
)}
</p>
<Button variant="outline" size="sm" onClick={openAccountSettings}>
{translate(
'auto.components.artifacts.ArtifactsPage.openAccountSettings',
'Open account settings'
)}
<ArrowRight />
</Button>
</div>
)}
</div>
) : loading && artifacts.length === 0 ? (
<div className="flex min-h-72 flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : artifacts.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-5 py-5 text-center md:px-8">
<Files className="size-8 text-muted-foreground" />
<h2 className="text-sm font-semibold">
{nextCursor
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailable',
'More artifacts are available'
)
: translate('auto.components.artifacts.ArtifactsPage.empty', 'No shared artifacts')}
</h2>
<p className="text-xs text-muted-foreground">
{nextCursor
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailableCopy',
'Load the next page to continue.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.emptyCopy',
'Ask your agent to share an HTML or Markdown file, and it will appear here.'
)}
</p>
{nextCursor ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
disabled={loadingMore}
onClick={() => void loadMoreArtifacts()}
>
{loadingMore ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
</Button>
) : null}
</div>
) : (
selectedArtifact && (
<ArtifactCollection
artifacts={artifacts}
deletingId={deletingId}
selectedArtifact={selectedArtifact}
selectArtifact={setSelectedSlug}
deleteArtifact={(target) => void deleteArtifact(target)}
hasMore={Boolean(nextCursor)}
loadingMore={loadingMore}
loadMore={() => void loadMoreArtifacts()}
/>
)
)}
</div>
</main>
)

View File

@ -0,0 +1,52 @@
import { FileCode2, FileText, type LucideIcon } from 'lucide-react'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { getIntlLocale, translate } from '@/i18n/i18n'
import { formatUiRelativeTime, formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
export function artifactName(item: ArtifactListItem): string {
return item.artifact.title || item.artifact.originalFileName || item.artifact.slug
}
export function formatArtifactDate(value: string): string {
return new Intl.DateTimeFormat(getIntlLocale(), {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value))
}
export function formatByteSize(value: number): string {
if (value < 1024) {
return `${value} B`
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${(value / (1024 * 1024)).toFixed(1)} MB`
}
export function formatArtifactUpdatedAt(value: string): string {
return translate('auto.components.artifacts.updatedAt', 'Updated {{when}}', {
when: formatUiRelativeTimeFromDate(
value,
translate('auto.components.artifacts.updatedRecently', 'recently')
)
})
}
/** Phrased from the stored timestamp alone — never a claim about server-side state. */
export function formatArtifactExpiry(value: string): string {
const expiresAt = new Date(value)
if (Number.isNaN(expiresAt.getTime())) {
return translate('auto.components.artifacts.expiryUnknown', 'Expiry unknown')
}
const remainingMs = expiresAt.getTime() - Date.now()
return remainingMs <= 0
? translate('auto.components.artifacts.expired', 'Link expired')
: translate('auto.components.artifacts.expires', 'Link expires {{when}}', {
when: formatUiRelativeTime(remainingMs)
})
}
export function artifactTypeIcon(item: ArtifactListItem): LucideIcon {
return item.artifact.sourceContentType === 'text/markdown' ? FileText : FileCode2
}

View File

@ -0,0 +1,15 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export async function copyArtifactLink(shareUrl: string): Promise<void> {
try {
await window.api.ui.writeClipboardText(shareUrl)
toast.success(translate('auto.components.artifacts.copySuccess', 'Artifact link copied'))
} catch {
toast.error(translate('auto.components.artifacts.copyFailed', 'Could not copy artifact link'))
}
}
export function openArtifactInBrowser(shareUrl: string): void {
void window.api.shell.openUrl(shareUrl)
}

View File

@ -8,6 +8,8 @@ export type ConfirmationDialogOptions = {
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'default' | 'destructive'
/** Renders a "Don't ask again" checkbox. `onConfirmed` runs only when the user confirms with it checked. */
dontAskAgain?: { label?: string; onConfirmed: () => void }
}
export type ConfirmationDialogContextValue = (

View File

@ -0,0 +1,104 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ setContextualToursBlockingSurfaceVisible: vi.fn() })
}))
import { ConfirmationDialogProvider } from './confirmation-dialog'
import {
useConfirmationDialog,
type ConfirmationDialogOptions
} from './confirmation-dialog-context'
function Harness({
options,
onSettled
}: {
options: ConfirmationDialogOptions
onSettled: (confirmed: boolean) => void
}): React.JSX.Element {
const confirm = useConfirmationDialog()
return (
<button type="button" onClick={() => void confirm(options).then(onSettled)}>
ask
</button>
)
}
function renderDialog(options: ConfirmationDialogOptions): { onSettled: ReturnType<typeof vi.fn> } {
const onSettled = vi.fn()
render(
<ConfirmationDialogProvider>
<Harness options={options} onSettled={onSettled} />
</ConfirmationDialogProvider>
)
return { onSettled }
}
describe('ConfirmationDialogProvider', () => {
afterEach(cleanup)
it('omits the checkbox unless the caller opts in', async () => {
renderDialog({ title: 'Delete artifact?' })
await userEvent.click(screen.getByRole('button', { name: 'ask' }))
expect(await screen.findByText('Delete artifact?')).toBeInTheDocument()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
})
it('runs the skip callback when confirmed with the box checked', async () => {
const onConfirmed = vi.fn()
const { onSettled } = renderDialog({
title: 'Delete artifact?',
confirmLabel: 'Delete',
dontAskAgain: { onConfirmed }
})
await userEvent.click(screen.getByRole('button', { name: 'ask' }))
await userEvent.click(await screen.findByRole('checkbox', { name: "Don't ask again" }))
await userEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onConfirmed).toHaveBeenCalledOnce()
await waitFor(() => expect(onSettled).toHaveBeenCalledWith(true))
})
it('never saves the preference when the user backs out', async () => {
const onConfirmed = vi.fn()
const { onSettled } = renderDialog({
title: 'Delete artifact?',
dontAskAgain: { onConfirmed }
})
await userEvent.click(screen.getByRole('button', { name: 'ask' }))
await userEvent.click(await screen.findByRole('checkbox', { name: "Don't ask again" }))
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onConfirmed).not.toHaveBeenCalled()
await waitFor(() => expect(onSettled).toHaveBeenCalledWith(false))
})
it('does not carry a checked box into the next prompt', async () => {
const onConfirmed = vi.fn()
renderDialog({
title: 'Delete artifact?',
confirmLabel: 'Delete',
dontAskAgain: { onConfirmed }
})
await userEvent.click(screen.getByRole('button', { name: 'ask' }))
await userEvent.click(await screen.findByRole('checkbox', { name: "Don't ask again" }))
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await userEvent.click(screen.getByRole('button', { name: 'ask' }))
expect(await screen.findByRole('checkbox', { name: "Don't ask again" })).toHaveAttribute(
'data-state',
'unchecked'
)
})
})

View File

@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
@ -30,6 +32,7 @@ export function ConfirmationDialogProvider({
}): React.JSX.Element {
const nextIdRef = useRef(0)
const [queue, setQueue] = useState<ConfirmationDialogRequest[]>([])
const [dontAskAgain, setDontAskAgain] = useState(false)
const activeRequest = queue[0] ?? null
const activeRequestRef = useRef<ConfirmationDialogRequest | null>(activeRequest)
const setContextualToursBlockingSurfaceVisible = useAppStore(
@ -62,19 +65,28 @@ export function ConfirmationDialogProvider({
})
}, [])
const settleActiveRequest = useCallback((confirmed: boolean) => {
const request = activeRequestRef.current
if (!request) {
return
}
request.resolve(confirmed)
setQueue((currentQueue) => {
if (currentQueue[0]?.id === request.id) {
return currentQueue.slice(1)
const settleActiveRequest = useCallback(
(confirmed: boolean) => {
const request = activeRequestRef.current
if (!request) {
return
}
return currentQueue.filter((queuedRequest) => queuedRequest.id !== request.id)
})
}, [])
// Why: cancelling must not persist a preference the user backed out of.
if (confirmed && dontAskAgain) {
request.options.dontAskAgain?.onConfirmed()
}
// Why: queued prompts must not inherit this request's preference.
setDontAskAgain(false)
request.resolve(confirmed)
setQueue((currentQueue) => {
if (currentQueue[0]?.id === request.id) {
return currentQueue.slice(1)
}
return currentQueue.filter((queuedRequest) => queuedRequest.id !== request.id)
})
},
[dontAskAgain]
)
return (
<ConfirmationDialogContext.Provider value={confirm}>
@ -90,6 +102,22 @@ export function ConfirmationDialogProvider({
<DialogDescription>{displayedRequest.options.description}</DialogDescription>
) : null}
</DialogHeader>
{displayedRequest?.options.dontAskAgain ? (
<div className="flex items-center gap-2">
<Checkbox
id="confirmation-dialog-dont-ask-again"
checked={dontAskAgain}
onCheckedChange={(checked) => setDontAskAgain(checked === true)}
/>
<Label
htmlFor="confirmation-dialog-dont-ask-again"
className="text-sm font-normal text-foreground/80"
>
{displayedRequest.options.dontAskAgain.label ??
translate('auto.components.confirmation.dialog.92bac3217e', "Don't ask again")}
</Label>
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => settleActiveRequest(false)}>
{displayedRequest?.options.cancelLabel ??

View File

@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError, success: mocks.toastSuccess }
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
import { persistConfirmationSkipPreference } from './confirmation-skip-preference'
function deferred(): {
promise: Promise<void>
reject: (error: Error) => void
resolve: () => void
} {
let reject!: (error: Error) => void
let resolve!: () => void
const promise = new Promise<void>((onResolve, onReject) => {
resolve = onResolve
reject = onReject
})
return { promise, reject, resolve }
}
function persist(updateSettings: () => Promise<void>): void {
persistConfirmationSkipPreference({
updates: { skipDeleteArtifactConfirm: true },
settingsSectionId: 'artifact-confirmation',
updateSettings,
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
})
}
describe('persistConfirmationSkipPreference', () => {
beforeEach(() => {
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
})
it('shows success only after the preference is persisted', async () => {
const write = deferred()
persist(() => write.promise)
expect(mocks.toastSuccess).not.toHaveBeenCalled()
write.resolve()
await vi.waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledOnce())
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('reports a failed preference write without claiming success', async () => {
const write = deferred()
persist(() => write.promise)
write.reject(new Error('write failed'))
await vi.waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith('Could not save the confirmation preference.')
)
expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,54 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type { SettingsNavTarget } from '@/lib/settings-navigation-types'
import type { GlobalSettings } from '../../../shared/types'
/** Persists the preference and keeps its reversal one click away. */
export function persistConfirmationSkipPreference({
updates,
settingsSectionId,
updateSettings,
openSettingsPage,
openSettingsTarget
}: {
updates: Partial<GlobalSettings>
settingsSectionId: string
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
openSettingsPage: () => void
openSettingsTarget: (target: {
pane: SettingsNavTarget
repoId: string | null
sectionId?: string
}) => void
}): void {
void updateSettings(updates).then(
() =>
toast.success(
translate(
'auto.components.confirmation.skip.saved',
"We'll skip this confirmation next time."
),
{
description: translate(
'auto.components.confirmation.skip.savedDescription',
'You can change this in Settings.'
),
duration: 8000,
action: {
label: translate('auto.components.confirmation.skip.openSettings', 'Open Settings'),
onClick: () => {
openSettingsPage()
openSettingsTarget({ pane: 'general', repoId: null, sectionId: settingsSectionId })
}
}
}
),
() =>
toast.error(
translate(
'auto.components.confirmation.skip.preference.0b0cb6e3f9',
'Could not save the confirmation preference.'
)
)
)
}

View File

@ -120,6 +120,37 @@ export function GeneralWorkspaceSettingsSection({
</SearchableSetting>
</div>
<div id="general-skip-delete-artifact-confirm" className="scroll-mt-6">
<SearchableSetting
title={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.31e300af1c',
'Ask Before Deleting Artifacts'
)}
description={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.fb29a73a17',
'Show a confirmation dialog before deleting a shared artifact and breaking its public link.'
)}
keywords={['delete', 'artifact', 'share', 'link', 'confirm', 'dialog', 'skip', 'prompt']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.31e300af1c',
'Ask Before Deleting Artifacts'
)}
description={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.bf46474e33',
'Show a confirmation before deleting a shared artifact. Anyone holding its public link loses access.'
)}
checked={!settings.skipDeleteArtifactConfirm}
onChange={() =>
updateSettings({
skipDeleteArtifactConfirm: !settings.skipDeleteArtifactConfirm
})
}
/>
</SearchableSetting>
</div>
<div
id="general-open-in-apps"
data-settings-section="general-open-in-apps"

View File

@ -6174,7 +6174,10 @@
"5567191a6e": "Browse",
"0e9fc0eadc": "Workspace Directory",
"e2955d9ccb": "Configure where new workspaces are created.",
"7511097c5d": "Workspace"
"7511097c5d": "Workspace",
"31e300af1c": "Ask Before Deleting Artifacts",
"fb29a73a17": "Show a confirmation dialog before deleting a shared artifact and breaking its public link.",
"bf46474e33": "Show a confirmation before deleting a shared artifact. Anyone holding its public link loses access."
},
"GhosttyImportModal": {
"9d3e56ca36": "Apply Changes",
@ -14470,7 +14473,16 @@
"confirmation": {
"dialog": {
"8490e5d36a": "Confirm",
"56f5c60e0c": "Cancel"
"56f5c60e0c": "Cancel",
"92bac3217e": "Don't ask again"
},
"skip": {
"saved": "We'll skip this confirmation next time.",
"savedDescription": "You can change this in Settings.",
"openSettings": "Open Settings",
"preference": {
"0b0cb6e3f9": "Could not save the confirmation preference."
}
}
},
"jira": {
@ -14953,7 +14965,15 @@
"deleteArtifact": "Delete artifact",
"moreAvailable": "More artifacts are available",
"moreAvailableCopy": "Load the next page to continue.",
"loadMoreFailed": "Could not load more artifacts."
"loadMoreFailed": "Could not load more artifacts.",
"loadedCountMore": "{{count}} loaded · more available",
"loadedCount": "{{count}} shared",
"retry": "Retry",
"reconnectHeading": "Sign in to Orca again",
"reconnectCopy": "Sign in again to view and manage the artifacts shared through your account.",
"signInAgainAction": "Sign in again",
"unconfiguredCopy": "Orca account sign-in is not configured on this machine yet.",
"openAccountSettings": "Open account settings"
},
"copySuccess": "Artifact link copied",
"copyFailed": "Could not copy artifact link",
@ -14965,7 +14985,20 @@
"actions": "Artifact actions",
"ArtifactCollection": {
"loadMore": "Load more"
}
},
"ArtifactDetailHeader": {
"publicLink": "Anyone with this link can view it"
},
"ArtifactListPane": {
"search": "Search artifacts",
"listLabel": "Shared artifacts",
"noMatches": "No matches"
},
"updatedAt": "Updated {{when}}",
"updatedRecently": "recently",
"expiryUnknown": "Expiry unknown",
"expired": "Link expired",
"expires": "Link expires {{when}}"
}
},
"i18n": {

View File

@ -322,6 +322,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
skipDeleteWorktreeConfirm: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
skipDeleteArtifactConfirm: false,
skipCodexRateLimitResetConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',

View File

@ -2989,6 +2989,8 @@ export type GlobalSettings = {
skipCloseTerminalWithRunningProcessConfirm: boolean
/** Why: deleting an automation also deletes its run history; keep this skip separate from worktree deletion. */
skipDeleteAutomationConfirm: boolean
/** Why: deleting an artifact breaks a public link others may already hold; keep this skip separate from local deletions. */
skipDeleteArtifactConfirm: boolean
/** Why: a Codex rate-limit reset spends a scarce credit on the live account; keep this skip separate from local confirmations. */
skipCodexRateLimitResetConfirm: boolean
/** Default preset in the new-workspace GitHub task view. */