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:
parent
6169d67bdf
commit
3eab34253c
|
|
@ -140,6 +140,7 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
|
|||
skipDeleteWorktreeConfirm: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
skipDeleteArtifactConfirm: false,
|
||||
skipCodexRateLimitResetConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
defaultTaskSource: 'github',
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
skipDeleteWorktreeConfirm: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
skipDeleteArtifactConfirm: false,
|
||||
skipCodexRateLimitResetConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
defaultTaskSource: 'github',
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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.'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
skipDeleteWorktreeConfirm: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
skipDeleteArtifactConfirm: false,
|
||||
skipCodexRateLimitResetConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
defaultTaskSource: 'github',
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue