Redesign artifacts page as full-width table with drawer (#15233)

* refactor(artifacts): redesign as full-width table with detail drawer

- Artifacts list displays as a compact data table with columns (Name, Type, Size, Updated, Expires)
- Selected artifact opens in a right-side drawer instead of inline preview
- Search and refresh consolidated in top toolbar
- Better space utilization for browsing the artifact list

* refactor(artifacts,automations): extract shared list-table layout

- Extract common list-table styles (container, header, row) to @/lib for
  consistency across artifacts and automations tables
- Move row interaction utilities to @/lib/list-row-interaction for reuse
- Fix drawer width to calc(100vw-80px) to avoid macOS traffic-light controls
- Extract WINDOW_CONTROLS_WIDTH/HEIGHT constants so portaled surfaces avoid
  the Windows/Linux overlay without hardcoding pixels
- Clamp artifact search query to 2KB to prevent multi-MB pastes from pinning
  renderer memory
- Remove unused artifact list visual mock

* Extract shared artifact row actions and use CSS var for traffic lights

- Unify dropdown and context menu actions via artifactRowActions() to prevent
  them from diverging during future maintenance.
- Replace hardcoded 80px with platform-aware CSS variable
  (--mac-traffic-lights-width) so only macOS reserves space for traffic lights;
  Windows and Linux controls sit on the right edge instead.
This commit is contained in:
Jinjing
2026-08-17 21:55:57 -07:00
committed by GitHub
parent f48bdf8f59
commit 314b02ba2d
36 changed files with 1280 additions and 649 deletions
+21 -4
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useEffect } from 'react'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
@@ -13,7 +13,12 @@ import { AppBackgroundServices } from './app-shell/AppBackgroundServices'
import { AppRootSurfaces } from './app-shell/AppRootSurfaces'
import { AppWorkspaceShell } from './app-shell/AppWorkspaceShell'
import { WindowControls } from './app-shell/WindowControls'
import { hasCustomTitleBar } from './app-shell/app-window-chrome'
import {
MAC_TRAFFIC_LIGHTS_WIDTH,
WINDOW_CONTROLS_HEIGHT,
WINDOW_CONTROLS_WIDTH,
hasCustomTitleBar
} from './app-shell/app-window-chrome'
import { useAppChromeLayout } from './app-shell/use-app-chrome-layout'
import { useAppSessionPersistence } from './app-shell/use-app-session-persistence'
import { useAppShellServices } from './app-shell/use-app-shell-services'
@@ -41,6 +46,16 @@ function App(): React.JSX.Element {
useWindowVisibilityEffects()
useGlobalKeybindings({ layout, floatingWorkspace })
// Why: the same vars are set inline on .app-layout below, but portaled surfaces
// (sheets, dialogs) mount outside it and would otherwise fall back to 0px and
// render their controls under the Windows/Linux window-controls overlay.
useEffect(() => {
const root = document.documentElement.style
root.setProperty('--window-controls-width', WINDOW_CONTROLS_WIDTH)
root.setProperty('--window-controls-height', WINDOW_CONTROLS_HEIGHT)
root.setProperty('--mac-traffic-lights-width', MAC_TRAFFIC_LIGHTS_WIDTH)
}, [])
const { cancelReturnFocusFrame } = floatingWorkspace
const setAppRootNode = useCallback(
(node: HTMLDivElement | null): void => {
@@ -61,9 +76,11 @@ function App(): React.JSX.Element {
{
'--collapsed-sidebar-header-width': `${layout.collapsedSidebarHeaderWidth}px`,
// Shared so surfaces can avoid the Windows/Linux window-controls overlay without hardcoding 138px everywhere.
'--window-controls-width': hasCustomTitleBar ? '138px' : '0px',
'--window-controls-width': WINDOW_CONTROLS_WIDTH,
// Side-position activity bar uses this to push icons below the Windows/Linux window-controls overlay.
'--window-controls-height': hasCustomTitleBar ? '36px' : '0px'
'--window-controls-height': WINDOW_CONTROLS_HEIGHT,
// Full-bleed surfaces use this to keep the macOS traffic lights uncovered.
'--mac-traffic-lights-width': MAC_TRAFFIC_LIGHTS_WIDTH
} as React.CSSProperties
}
>
@@ -155,8 +155,10 @@ export function AppWorkspaceShell(props: {
)
) : null}
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */}
{layout.stackedSidebarOpen && layout.activeView !== 'automations' ? (
{/* Why: automations/artifacts own their page headers; the stacked titlebar would be an empty 36px stripe. */}
{layout.stackedSidebarOpen &&
layout.activeView !== 'automations' &&
layout.activeView !== 'artifacts' ? (
<div className="titlebar">{titlebarMainStrip}</div>
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
@@ -11,3 +11,12 @@ export const hasCustomTitleBar = shouldRenderDesktopWindowChrome({
platform: shortcutPlatform,
isWebClient: isPairedWebClientWindow()
})
// Why: the three 46px window-control buttons and the 36px titlebar they sit in.
// Surfaces offset by these instead of hardcoding the pixels.
export const WINDOW_CONTROLS_WIDTH = hasCustomTitleBar ? '138px' : '0px'
export const WINDOW_CONTROLS_HEIGHT = hasCustomTitleBar ? '36px' : '0px'
// Why: macOS paints traffic lights on the window's top-left edge. Windows and Linux paint their
// controls on the right, so only macOS needs a surface to keep the left edge uncovered.
export const MAC_TRAFFIC_LIGHTS_WIDTH = isMac ? '80px' : '0px'
@@ -1,6 +1,12 @@
import { Copy, ExternalLink, Loader2, Trash2 } from 'lucide-react'
import { Copy, ExternalLink, Loader2, MoreHorizontal, Trash2 } from 'lucide-react'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { copyArtifactLink, openArtifactInBrowser } from './artifact-link-actions'
@@ -41,26 +47,33 @@ export function ArtifactActions({
{translate('auto.components.artifacts.openInBrowser', 'Open in browser')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
className="text-muted-foreground hover:text-foreground"
disabled={deleting}
onClick={() => onDelete(item)}
aria-label={translate(
'auto.components.artifacts.ArtifactsPage.deleteArtifact',
'Delete artifact'
'auto.components.artifacts.ArtifactActions.more',
'More artifact actions'
)}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
{deleting ? <Loader2 className="animate-spin" /> : <MoreHorizontal />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.artifacts.ArtifactsPage.deleteArtifact', 'Delete artifact')}
</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
variant="destructive"
disabled={deleting}
onSelect={() => onDelete(item)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.artifacts.ArtifactsPage.deleteArtifact', 'Delete artifact')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -10,12 +10,9 @@ vi.mock('./ArtifactPreview', () => ({
ArtifactPreview: ({ shareUrl }: { shareUrl: string }) => <div>{`Preview ${shareUrl}`}</div>
}))
vi.mock('./ArtifactActions', () => ({
ArtifactActions: () => <div>Artifact actions</div>
}))
import { TooltipProvider } from '@/components/ui/tooltip'
import { ArtifactCollection } from './ArtifactCollection'
import { LIST_TABLE_CONTAINER_CLASS } from '@/lib/list-table-layout'
const DAY_MS = 24 * 60 * 60 * 1000
@@ -52,81 +49,75 @@ describe('ArtifactCollection', () => {
<ArtifactCollection
artifacts={items}
deletingId={null}
selectedArtifact={items[0]}
selectedSlug={items[0]?.artifact.slug ?? null}
selectArtifact={selectArtifact}
deleteArtifact={vi.fn()}
hasMore={false}
loadingMore={false}
loadMore={vi.fn()}
onRefresh={vi.fn()}
isRefreshing={false}
/>
</TooltipProvider>
)
return { container, selectArtifact }
}
it('keeps the artifact list beside a contained preview', async () => {
it('renders a full-width table list without an inline preview', async () => {
const items = [artifact('first', 'First artifact'), artifact('second', 'Second artifact')]
const { container, selectArtifact } = renderCollection(items)
const collection = container.firstElementChild
// 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()
const table = container.querySelector(`.${LIST_TABLE_CONTAINER_CLASS.split(' ')[0]}`)
expect(table).toHaveClass('rounded-md', 'border')
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByText('Type')).toBeInTheDocument()
expect(screen.queryByText(/Preview https:\/\//)).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('option', { name: /Second artifact/ }))
await userEvent.click(screen.getByRole('button', { name: /Second artifact/ }))
expect(selectArtifact).toHaveBeenCalledWith('second')
})
it('exposes the list as a single-tab-stop listbox', () => {
it('highlights only the selected row', () => {
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')
const first = screen.getByRole('button', { name: /First artifact/ })
const second = screen.getByRole('button', { name: /Second artifact/ })
expect(first).toHaveAttribute('data-current', 'true')
expect(second).not.toHaveAttribute('data-current')
})
it('moves focus with arrows and commits selection on Enter', async () => {
it('commits selection on Enter from the focused row', 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()
const second = screen.getByRole('button', { name: /Second artifact/ })
second.focus()
await userEvent.keyboard('{Enter}')
expect(selectArtifact).toHaveBeenCalledWith('second')
})
it('filters the list by name and keeps the preview mounted', async () => {
it('filters the list by name', 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.type(screen.getByPlaceholderText('Search...'), 'second')
expect(screen.getByRole('button', { name: /Second artifact/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /First artifact/ })).not.toBeInTheDocument()
await userEvent.clear(screen.getByPlaceholderText('Search artifacts'))
await userEvent.type(screen.getByPlaceholderText('Search artifacts'), 'nothing')
expect(screen.queryAllByRole('option')).toHaveLength(0)
await userEvent.clear(screen.getByPlaceholderText('Search...'))
await userEvent.type(screen.getByPlaceholderText('Search...'), 'nothing')
expect(screen.queryByRole('button', { name: /Second artifact/ })).not.toBeInTheDocument()
expect(screen.getByText('No matches')).toBeInTheDocument()
})
it('shows the share url and expiry instead of repeating the row metadata', () => {
it('shows compact type, size, and expiry in the row', () => {
const items = [artifact('first', 'First artifact')]
renderCollection(items)
expect(screen.getByText('https://share.onorca.dev/a/first')).toBeInTheDocument()
expect(screen.getByText(/Link expires/)).toBeInTheDocument()
expect(screen.getByText('HTML')).toBeInTheDocument()
expect(screen.getByText('1.2 KB')).toBeInTheDocument()
expect(screen.getByText(/in \d+ days/)).toBeInTheDocument()
expect(screen.queryByText('https://share.onorca.dev/a/first')).not.toBeInTheDocument()
})
})
@@ -1,49 +1,88 @@
import { useMemo, useState } from 'react'
import { Loader2 } from 'lucide-react'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { ArtifactDetailHeader } from './ArtifactDetailHeader'
import { ArtifactListPane } from './ArtifactListPane'
import { ArtifactPreview } from './ArtifactPreview'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { clampArtifactListSearchQuery, filterArtifactsBySearchQuery } from './artifact-list-search'
import { ArtifactListRows } from './ArtifactListRows'
import { ArtifactListTableHeader } from './ArtifactListTableHeader'
import { ArtifactListToolbar } from './ArtifactListToolbar'
import { LIST_TABLE_CONTAINER_CLASS } from '@/lib/list-table-layout'
export function ArtifactCollection({
artifacts,
deletingId,
selectedArtifact,
selectedSlug,
selectArtifact,
deleteArtifact,
hasMore,
loadingMore,
loadMore
loadMore,
onRefresh,
isRefreshing
}: {
artifacts: readonly ArtifactListItem[]
deletingId: string | null
selectedArtifact: ArtifactListItem
selectedSlug: string | null
selectArtifact: (slug: string) => void
deleteArtifact: (item: ArtifactListItem) => void
hasMore: boolean
loadingMore: boolean
loadMore: () => void
onRefresh: () => void
isRefreshing: boolean
}): React.JSX.Element {
const [query, setQuery] = useState('')
// Why: clamp on the way in so a multi-MB paste never reaches state or filtering.
const onQueryChange = (next: string): void => setQuery(clampArtifactListSearchQuery(next))
const matches = useMemo(() => filterArtifactsBySearchQuery(artifacts, query), [artifacts, query])
return (
// 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">
<ArtifactDetailHeader
deleting={deletingId === selectedArtifact.artifact.slug}
item={selectedArtifact}
onDelete={deleteArtifact}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden px-3 pb-4 md:px-5">
<div className="flex min-h-0 flex-1 flex-col gap-4">
<ArtifactListToolbar
query={query}
onQueryChange={onQueryChange}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
/>
<ArtifactPreview shareUrl={selectedArtifact.shareUrl} />
</section>
</div>
<div
className={cn('scrollbar-sleek min-h-0 flex-1 overflow-auto', LIST_TABLE_CONTAINER_CLASS)}
>
<ArtifactListTableHeader />
{matches.length > 0 ? (
<div className="divide-y divide-border/50">
<ArtifactListRows
artifacts={matches}
deletingId={deletingId}
selectedSlug={selectedSlug}
selectArtifact={selectArtifact}
deleteArtifact={deleteArtifact}
/>
</div>
) : (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
{translate('auto.components.artifacts.ArtifactCollection.noMatches', 'No matches')}
</p>
)}
{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>
</section>
)
}
@@ -0,0 +1,59 @@
import { VisuallyHidden } from 'radix-ui'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { translate } from '@/i18n/i18n'
import { artifactName } from './artifact-display-labels'
import { ArtifactDetailHeader } from './ArtifactDetailHeader'
import { ArtifactPreview } from './ArtifactPreview'
export function ArtifactDetailDrawer({
item,
deleting,
onClose,
onDelete
}: {
item: ArtifactListItem | null
deleting: boolean
onClose: () => void
onDelete: (item: ArtifactListItem) => void
}): React.JSX.Element {
return (
<Sheet open={item !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
showCloseButton={false}
// Why: Electron webviews do not paint inside transformed ancestors, so this
// sheet must not use the default slide translate.
// Why: leave the native macOS traffic-light area uncovered when the drawer is
// intentionally wider than the standard sheet max-width. The var resolves to 0px on
// Windows and Linux, whose controls sit on the right edge instead.
className="h-full w-[min(96rem,calc(100vw-var(--mac-traffic-lights-width,0px)))] max-w-none translate-x-0 p-0 sm:max-w-[min(96rem,calc(100vw-var(--mac-traffic-lights-width,0px)))] data-[state=closed]:translate-x-0 data-[state=open]:translate-x-0"
>
{item ? (
<div className="flex h-full min-h-0 flex-col">
<VisuallyHidden.Root asChild>
<SheetDescription>
{translate(
'auto.components.artifacts.ArtifactDetailDrawer.description',
'Preview and manage this shared artifact.'
)}
</SheetDescription>
</VisuallyHidden.Root>
<ArtifactDetailHeader
deleting={deleting}
item={item}
title={
<SheetTitle className="truncate text-base font-semibold">
{artifactName(item)}
</SheetTitle>
}
onClose={onClose}
onDelete={onDelete}
/>
<ArtifactPreview shareUrl={item.shareUrl} />
</div>
) : null}
</SheetContent>
</Sheet>
)
}
@@ -1,10 +1,11 @@
import { Globe } from 'lucide-react'
import type { ReactNode } from 'react'
import { Globe, X } from 'lucide-react'
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 { ArtifactActions } from './ArtifactActions'
import {
artifactName,
formatArtifactExpiry,
formatArtifactUpdatedAt,
formatByteSize
@@ -13,17 +14,23 @@ import {
export function ArtifactDetailHeader({
deleting,
item,
title,
onClose,
onDelete
}: {
deleting: boolean
item: ArtifactListItem
title: ReactNode
onClose: () => void
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: the drawer is right-anchored under the fixed Windows/Linux window-controls
// overlay, which paints above it — inset the actions so they stay clickable.
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/50 px-4 py-3 pr-[max(1rem,var(--window-controls-width,0px))]">
{/* 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>
{title}
<div className="flex min-w-0 items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
@@ -49,7 +56,18 @@ export function ArtifactDetailHeader({
{formatByteSize(item.artifact.byteSize)} · {formatArtifactExpiry(item.artifact.expiresAt)}
</p>
</div>
<ArtifactActions deleting={deleting} item={item} onDelete={onDelete} />
<div className="flex shrink-0 items-center gap-2">
<ArtifactActions deleting={deleting} item={item} onDelete={onDelete} />
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onClose}
aria-label={translate('auto.components.artifacts.ArtifactDetailHeader.close', 'Close')}
>
<X />
</Button>
</div>
</div>
)
}
@@ -1,209 +0,0 @@
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>
)
}
@@ -0,0 +1,196 @@
import { Fragment } from 'react'
import { Copy, ExternalLink, MoreHorizontal, Trash2 } from 'lucide-react'
import type { LucideIcon } 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import {
artifactName,
artifactTypeLabel,
formatArtifactExpiryCompact,
formatArtifactUpdatedCompact,
formatByteSize
} from './artifact-display-labels'
import { copyArtifactLink, openArtifactInBrowser } from './artifact-link-actions'
import { ARTIFACTS_TABLE_GRID_CLASS } from './artifacts-table-layout'
import { LIST_TABLE_ROW_CLASS, LIST_TABLE_ROW_SELECTED_CLASS } from '@/lib/list-table-layout'
type ArtifactRowAction = {
key: string
label: string
icon: LucideIcon
onSelect: () => void
/** Rendered after a separator, styled as destructive. */
destructive?: boolean
disabled?: boolean
}
// Why: the row dropdown and the row context menu must offer the same actions; one source keeps them from drifting.
function artifactRowActions(
item: ArtifactListItem,
deleting: boolean,
deleteArtifact: (item: ArtifactListItem) => void
): readonly ArtifactRowAction[] {
return [
{
key: 'copy',
label: translate('auto.components.artifacts.copyLink', 'Copy link'),
icon: Copy,
onSelect: () => void copyArtifactLink(item.shareUrl)
},
{
key: 'open',
label: translate('auto.components.artifacts.openInBrowser', 'Open in browser'),
icon: ExternalLink,
onSelect: () => openArtifactInBrowser(item.shareUrl)
},
{
key: 'delete',
label: translate('auto.components.artifacts.ArtifactsPage.deleteArtifact', 'Delete artifact'),
icon: Trash2,
onSelect: () => deleteArtifact(item),
destructive: true,
disabled: deleting
}
]
}
export function ArtifactListRows({
artifacts,
deletingId,
selectedSlug,
selectArtifact,
deleteArtifact
}: {
artifacts: readonly ArtifactListItem[]
deletingId: string | null
selectedSlug: string | null
selectArtifact: (slug: string) => void
deleteArtifact: (item: ArtifactListItem) => void
}): React.JSX.Element {
return (
<>
{artifacts.map((item) => {
const name = artifactName(item)
const typeLabel = artifactTypeLabel(item)
const updatedLabel = formatArtifactUpdatedCompact(item.artifact.updatedAt)
const expiryLabel = formatArtifactExpiryCompact(item.artifact.expiresAt)
const sizeLabel = formatByteSize(item.artifact.byteSize)
const isSelected = selectedSlug === item.artifact.slug
const deleting = deletingId === item.artifact.slug
const rowActions = artifactRowActions(item, deleting, deleteArtifact)
return (
<ContextMenu key={item.artifact.slug}>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
if (isPortaledRowMenuClick(event)) {
return
}
selectArtifact(item.artifact.slug)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
selectArtifact(item.artifact.slug)
}}
className={cn(
ARTIFACTS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className="min-w-0 truncate font-medium" title={name}>
{name}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={typeLabel}>
{typeLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={sizeLabel}>
{sizeLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={updatedLabel}>
{updatedLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={expiryLabel}>
{expiryLabel}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.artifacts.actions',
'Artifact actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{rowActions.map(
({ key, label, icon: Icon, onSelect, destructive, disabled }) => (
<Fragment key={key}>
{destructive ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
variant={destructive ? 'destructive' : 'default'}
disabled={disabled}
onSelect={onSelect}
>
<Icon className="size-3.5" />
{label}
</DropdownMenuItem>
</Fragment>
)
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{rowActions.map(({ key, label, icon: Icon, onSelect, destructive, disabled }) => (
<Fragment key={key}>
{destructive ? <ContextMenuSeparator /> : null}
<ContextMenuItem
variant={destructive ? 'destructive' : 'default'}
disabled={disabled}
onSelect={onSelect}
>
<Icon className="size-3.5" />
{label}
</ContextMenuItem>
</Fragment>
))}
</ContextMenuContent>
</ContextMenu>
)
})}
</>
)
}
@@ -0,0 +1,75 @@
import { useRef } from 'react'
import { Search, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
export function ArtifactListSearchField({
query,
onQueryChange,
onClear,
className
}: {
query: string
onQueryChange: (query: string) => void
onClear: () => void
className?: string
}): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)
const hasText = query !== ''
return (
<div className={cn('relative', className)}>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
type="text"
autoFocus
value={query}
aria-label={translate(
'auto.components.artifacts.ArtifactListSearchField.label',
'Search artifacts'
)}
placeholder={translate(
'auto.components.artifacts.ArtifactListSearchField.placeholder',
'Search...'
)}
// Why: the page-level Escape handler blurs inputs; this opts out so the
// first Escape clears the query without also losing focus.
data-escape-clears-value={hasText ? 'true' : undefined}
className={cn(
'h-8 border-border bg-background pl-8 text-xs shadow-none focus-visible:border-ring/70 focus-visible:ring-0 dark:bg-background',
hasText && 'pr-7'
)}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing || !hasText) {
return
}
event.preventDefault()
onClear()
}}
/>
{hasText ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1/2 -translate-y-1/2"
aria-label={translate(
'auto.components.artifacts.ArtifactListSearchField.clear',
'Clear search'
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onClear()
inputRef.current?.focus()
}}
>
<X className="size-3.5" />
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,23 @@
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { ARTIFACTS_TABLE_GRID_CLASS } from './artifacts-table-layout'
import { LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout'
export function ArtifactListTableHeader(): React.JSX.Element {
return (
<div className={cn(ARTIFACTS_TABLE_GRID_CLASS, LIST_TABLE_HEADER_CLASS)}>
<span>{translate('auto.components.artifacts.ArtifactListTableHeader.name', 'Name')}</span>
<span>{translate('auto.components.artifacts.ArtifactListTableHeader.type', 'Type')}</span>
<span>{translate('auto.components.artifacts.ArtifactListTableHeader.size', 'Size')}</span>
<span>
{translate('auto.components.artifacts.ArtifactListTableHeader.updated', 'Updated')}
</span>
<span>
{translate('auto.components.artifacts.ArtifactListTableHeader.expires', 'Expires')}
</span>
<span className="sr-only">
{translate('auto.components.artifacts.ArtifactListTableHeader.actions', 'Actions')}
</span>
</div>
)
}
@@ -0,0 +1,47 @@
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { ArtifactListSearchField } from './ArtifactListSearchField'
export function ArtifactListToolbar({
query,
onQueryChange,
onRefresh,
isRefreshing
}: {
query: string
onQueryChange: (query: string) => void
onRefresh: () => void
isRefreshing: boolean
}): React.JSX.Element {
return (
<div className="flex shrink-0 items-center gap-2">
<ArtifactListSearchField
query={query}
className="w-56"
onQueryChange={onQueryChange}
onClear={() => onQueryChange('')}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
onClick={onRefresh}
disabled={isRefreshing}
className="shrink-0 border border-border bg-background shadow-none hover:bg-muted/50"
>
<RefreshCw className={cn('size-4', isRefreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
</TooltipContent>
</Tooltip>
</div>
)
}
@@ -3,6 +3,7 @@
import '@testing-library/jest-dom/vitest'
import type { ReactNode } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
@@ -130,19 +131,33 @@ describe('ArtifactsPage', () => {
afterEach(cleanup)
it('renders the selected artifact in-app with copy link as the primary action', async () => {
it('renders the selected artifact in a right drawer with copy link as the primary action', async () => {
render(<ArtifactsPage />)
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')
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Refresh' })).toHaveClass(
'border',
'border-border/50'
const row = await screen.findByRole('button', { name: /Quarterly report/ })
expect(row).toBeInTheDocument()
expect(screen.queryByRole('heading', { level: 2, name: 'Quarterly report' })).toBeNull()
const title = screen.getByRole('heading', { level: 1, name: 'Artifacts' })
expect(title).toHaveClass('text-base', 'font-semibold', 'leading-8')
expect(title.closest('header')).toHaveClass('px-3', 'pb-3', 'md:px-5')
expect(screen.getByRole('main')).toHaveClass('pt-5', 'md:pt-6')
expect(screen.queryByRole('button', { name: 'Close artifacts' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Refresh' })).toHaveClass('border', 'border-border')
fireEvent.click(row)
expect(
await screen.findByRole('heading', { level: 2, name: 'Quarterly report' })
).toBeInTheDocument()
expect(document.querySelector('[data-slot="sheet-content"]')).toHaveClass(
'w-[min(96rem,calc(100vw-var(--mac-traffic-lights-width,0px)))]'
)
// Why: the drawer is right-anchored under the fixed Windows/Linux window-controls
// overlay, so its actions must sit inside an element inset past that overlay.
expect(
screen
.getByRole('button', { name: 'Close' })
.closest('.pr-\\[max\\(1rem\\,var\\(--window-controls-width\\,0px\\)\\)\\]')
).not.toBeNull()
const copyButton = screen.getByRole('button', { name: 'Copy link' })
expect(copyButton).toHaveAttribute('data-variant', 'default')
expect(copyButton.parentElement).toHaveAttribute('aria-label', 'Artifact actions')
@@ -150,10 +165,8 @@ describe('ArtifactsPage', () => {
'data-variant',
'ghost'
)
expect(screen.getByRole('button', { name: 'Delete artifact' })).toHaveClass(
'text-muted-foreground',
'hover:text-destructive'
)
expect(screen.queryByRole('button', { name: 'Delete artifact' })).toBeNull()
expect(screen.getByRole('button', { name: 'More artifact actions' })).toBeInTheDocument()
await waitFor(() => {
const preview = document.querySelector('webview[aria-label="Artifact preview"]')
@@ -175,20 +188,29 @@ describe('ArtifactsPage', () => {
mocks.resolvePartition.mockResolvedValue(null)
render(<ArtifactsPage />)
fireEvent.click(await screen.findByRole('button', { name: /Quarterly report/ }))
expect(await screen.findByText('Preview unavailable')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Copy link' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Open in browser' })).toBeEnabled()
})
it('closes from the header button and Escape', async () => {
it('closes the drawer on Escape, then the page', async () => {
render(<ArtifactsPage />)
await waitFor(() => expect(mocks.rpc).toHaveBeenCalledOnce())
fireEvent.click(await screen.findByRole('button', { name: /Quarterly report/ }))
expect(
await screen.findByRole('heading', { level: 2, name: 'Quarterly report' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Close artifacts' }))
fireEvent.keyDown(document.querySelector('[data-slot="sheet-content"]') as Element, {
key: 'Escape'
})
await waitFor(() =>
expect(screen.queryByRole('heading', { level: 2, name: 'Quarterly report' })).toBeNull()
)
expect(mocks.closePage).not.toHaveBeenCalled()
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(mocks.closePage).toHaveBeenCalledOnce()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(mocks.closePage).toHaveBeenCalledTimes(2)
})
it('explains the agent-first sharing workflow', async () => {
@@ -266,8 +288,8 @@ describe('ArtifactsPage', () => {
value: { artifacts: [artifactListItem('Second page', 'second-page')] }
})
expect(await screen.findByRole('option', { name: /Second page/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /First page/ })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /Second page/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /First page/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
})
@@ -287,7 +309,7 @@ describe('ArtifactsPage', () => {
expect(screen.queryByText('No shared artifacts')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findByRole('option', { name: /Older artifact/ })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /Older artifact/ })).toBeInTheDocument()
})
it('keeps loaded artifacts when loading another page fails', async () => {
@@ -302,11 +324,11 @@ describe('ArtifactsPage', () => {
.mockRejectedValueOnce(new Error('network down'))
render(<ArtifactsPage />)
await screen.findByRole('option', { name: /Still visible/ })
await screen.findByRole('button', { name: /Still visible/ })
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findByText('Could not load more artifacts.')).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Still visible/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Still visible/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Load more' })).toBeEnabled()
})
@@ -331,7 +353,7 @@ describe('ArtifactsPage', () => {
state: 'connected'
}
view.rerender(<ArtifactsPage />)
expect(await screen.findByRole('option', { name: /Account B/ })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /Account B/ })).toBeInTheDocument()
resolveRefresh()
await waitFor(() =>
@@ -371,7 +393,7 @@ describe('ArtifactsPage', () => {
state: 'connected'
}
view.rerender(<ArtifactsPage />)
expect(await screen.findByRole('option', { name: /Account B/ })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /Account B/ })).toBeInTheDocument()
resolveRefresh()
await waitFor(() =>
@@ -439,7 +461,7 @@ describe('ArtifactsPage', () => {
const view = render(<ArtifactsPage />)
await screen.findAllByText('Shared slug A')
fireEvent.click(screen.getByRole('button', { name: 'Delete artifact' }))
await deleteFirstArtifactFromDrawerMenu()
await waitFor(() => expect(mocks.rpc).toHaveBeenCalledTimes(2))
mocks.authStatus = {
@@ -455,7 +477,7 @@ describe('ArtifactsPage', () => {
view.rerender(<ArtifactsPage />)
resolveDelete({ status: 'ok', value: undefined })
expect(await screen.findByRole('option', { name: /Shared slug B/ })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /Shared slug B/ })).toBeInTheDocument()
})
it('does not resurrect a deletion from an older refresh', async () => {
@@ -476,7 +498,7 @@ describe('ArtifactsPage', () => {
await screen.findAllByText('Delete me')
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete artifact' }))
await deleteFirstArtifactFromDrawerMenu()
await waitFor(() => expect(screen.queryByText('Delete me')).not.toBeInTheDocument())
resolveRefresh({
status: 'ok',
@@ -493,12 +515,12 @@ describe('ArtifactsPage', () => {
value: { artifacts: [artifactListItem('Skip me', 'skip-me')] }
})
render(<ArtifactsPage />)
await screen.findByRole('option', { name: /Skip me/ })
await screen.findByRole('button', { name: /Skip me/ })
mocks.rpc.mockResolvedValueOnce({ status: 'ok', value: undefined })
fireEvent.click(screen.getByRole('button', { name: 'Delete artifact' }))
await deleteFirstArtifactFromDrawerMenu()
await waitFor(() => expect(screen.queryByRole('option', { name: /Skip me/ })).toBeNull())
await waitFor(() => expect(screen.queryByRole('button', { name: /Skip me/ })).toBeNull())
expect(mocks.confirm).not.toHaveBeenCalled()
})
@@ -509,10 +531,10 @@ describe('ArtifactsPage', () => {
value: { artifacts: [artifactListItem('Ask me', 'ask-me')] }
})
render(<ArtifactsPage />)
await screen.findByRole('option', { name: /Ask me/ })
await screen.findByRole('button', { name: /Ask me/ })
mocks.rpc.mockResolvedValueOnce({ status: 'ok', value: undefined })
fireEvent.click(screen.getByRole('button', { name: 'Delete artifact' }))
await deleteFirstArtifactFromDrawerMenu()
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
// Why: the dialog owns the checkbox; the page only supplies what to persist when it is checked.
@@ -545,6 +567,17 @@ describe('ArtifactsPage', () => {
})
})
/** Opens the drawer from the first rendered row, then deletes through the drawer's action menu. */
async function deleteFirstArtifactFromDrawerMenu(): Promise<void> {
const row = document.querySelector('[data-slot="context-menu-trigger"]')
if (!(row instanceof HTMLElement)) {
throw new Error('Expected an artifact row')
}
await userEvent.click(row)
await userEvent.click(screen.getByRole('button', { name: 'More artifact actions' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete artifact' }))
}
function artifactListItem(title: string, slug: string): Record<string, unknown> {
return {
artifact: {
@@ -1,14 +1,18 @@
import { useEffect, useState } from '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'
import { ArtifactCollection } from './ArtifactCollection'
import { ArtifactDetailDrawer } from './ArtifactDetailDrawer'
import { ArtifactsPageSkeleton } from './ArtifactsPageSkeleton'
import {
ArtifactsPageAuthState,
ArtifactsPageEmptyState,
ArtifactsPageErrorBanner
} from './ArtifactsPageStates'
import { artifactAccountIdentity, useArtifactPagination } from './useArtifactPagination'
const LOCAL_RUNTIME = { kind: 'local' } as const
@@ -49,48 +53,51 @@ export default function ArtifactsPage(): React.JSX.Element {
} = useArtifactPagination(authStatus, refreshAuth)
const deletingId = deleting?.identity === accountIdentity ? deleting.slug : null
const selectedArtifact =
artifacts.find(({ artifact }) => artifact.slug === selectedSlug) ?? artifacts[0] ?? null
selectedSlug === null
? null
: (artifacts.find(({ artifact }) => artifact.slug === selectedSlug) ?? null)
useEffect(() => {
setSelectedSlug((current) => {
if (current && artifacts.some(({ artifact }) => artifact.slug === current)) {
return current
}
return artifacts[0]?.artifact.slug ?? null
})
}, [artifacts])
if (selectedSlug && !artifacts.some(({ artifact }) => artifact.slug === selectedSlug)) {
setSelectedSlug(null)
}
}, [selectedSlug, artifacts])
useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
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 HTMLElement)) {
return
}
if (target.dataset.escapeClearsValue === 'true') {
return
}
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
target.isContentEditable
) {
event.preventDefault()
target.blur()
return
}
if (selectedSlug) {
event.preventDefault()
setSelectedSlug(null)
return
}
event.preventDefault()
closePage()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [closePage])
}, [closePage, selectedSlug])
const deleteArtifact = async (item: ArtifactListItem): Promise<void> => {
const requestedIdentity = accountIdentity
if (!requestedIdentity) {
return
}
const requestedAccountIsCurrent = (): boolean =>
artifactAccountIdentity(useAppStore.getState().orcaProfileAuthStatus) === requestedIdentity
const name = item.artifact.title || item.artifact.originalFileName || item.artifact.slug
if (!settings?.skipDeleteArtifactConfirm) {
const accepted = await confirm({
@@ -117,6 +124,12 @@ export default function ArtifactsPage(): React.JSX.Element {
return
}
}
const requestedIdentity = accountIdentity
if (!requestedIdentity) {
return
}
const requestedAccountIsCurrent = (): boolean =>
artifactAccountIdentity(useAppStore.getState().orcaProfileAuthStatus) === requestedIdentity
if (!requestedAccountIsCurrent()) {
return
}
@@ -155,232 +168,70 @@ export default function ArtifactsPage(): React.JSX.Element {
}
return (
<main className="relative flex h-full min-h-0 flex-1 flex-col bg-background text-foreground">
<header className="flex shrink-0 items-center justify-between px-5 pb-3 pt-1.5 md:px-8">
<div className="flex min-w-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-full"
onClick={closePage}
aria-label={translate(
'auto.components.artifacts.ArtifactsPage.closeArtifacts',
'Close artifacts'
)}
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.artifacts.ArtifactsPage.closeTooltip', 'Close · Esc')}
</TooltipContent>
</Tooltip>
<div className="mx-1 h-5 w-px bg-border/50" aria-hidden />
<Files className="size-4 shrink-0 text-muted-foreground" />
<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>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="border border-border/50 bg-transparent hover:bg-muted/50"
onClick={() => void loadArtifacts()}
disabled={loading}
aria-label={translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
>
<RefreshCw className={loading ? 'animate-spin' : undefined} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
</TooltipContent>
</Tooltip>
) : null}
<main className="relative flex h-full min-h-0 flex-1 flex-col bg-background pt-5 text-foreground md:pt-6">
<header
className="flex shrink-0 items-center px-3 pb-3 md:px-5"
// Why: no stacked center titlebar on this page; keep the title clear of Windows/Linux window controls.
style={
{
paddingRight: 'max(0.75rem, var(--window-controls-width, 0px))'
} as React.CSSProperties
}
>
<h1 className="truncate text-base font-semibold leading-8">
{translate('auto.components.artifacts.ArtifactsPage.title', 'Artifacts')}
</h1>
</header>
{/* 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">
{needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.reconnectHeading',
'Sign in to Orca again'
)
: translate(
'auto.components.artifacts.ArtifactsPage.signInHeading',
'Sign in to share artifacts'
)}
</h2>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.reconnectCopy',
'Sign in again to view and manage the artifacts shared through your account.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.signInCopy',
'Use your Orca account to upload artifacts and manage their public links.'
)}
</p>
</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'
)
: publishingBlocked
? translate(
'auto.components.artifacts.ArtifactsPage.publishingOff',
'Publishing is turned off'
)
: translate(
'auto.components.artifacts.ArtifactsPage.empty',
'No shared artifacts'
)}
</h2>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{nextCursor
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailableCopy',
'Load the next page to continue.'
)
: publishingBlocked
? translate(
'auto.components.artifacts.ArtifactsPage.publishingOffCopy',
'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.emptyCopy',
'Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.'
)}
</p>
{!nextCursor && publishingBlocked ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => {
openSettingsTarget({ pane: 'artifacts', repoId: null })
openSettingsPage()
}}
>
{translate(
'auto.components.artifacts.ArtifactsPage.openArtifactsSettings',
'Open Settings → Artifacts'
)}
</Button>
) : null}
{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>
{error ? (
<ArtifactsPageErrorBanner
error={error}
loading={loading}
onRetry={() => void loadArtifacts()}
/>
) : null}
{!signedIn ? (
<ArtifactsPageAuthState
connecting={connecting}
needsReconnect={needsReconnect}
configured={authStatus?.configured === true}
onConnect={() => void connect()}
onOpenAccountSettings={openAccountSettings}
/>
) : loading && artifacts.length === 0 ? (
<ArtifactsPageSkeleton />
) : artifacts.length === 0 ? (
<ArtifactsPageEmptyState
hasMore={Boolean(nextCursor)}
loadingMore={loadingMore}
publishingBlocked={publishingBlocked}
onLoadMore={() => void loadMoreArtifacts()}
onOpenArtifactsSettings={() => {
openSettingsTarget({ pane: 'artifacts', repoId: null })
openSettingsPage()
}}
/>
) : (
<ArtifactCollection
artifacts={artifacts}
deletingId={deletingId}
selectedSlug={selectedSlug}
selectArtifact={setSelectedSlug}
deleteArtifact={(target) => void deleteArtifact(target)}
hasMore={Boolean(nextCursor)}
loadingMore={loadingMore}
loadMore={() => void loadMoreArtifacts()}
onRefresh={() => void loadArtifacts()}
isRefreshing={loading}
/>
)}
<ArtifactDetailDrawer
item={selectedArtifact}
deleting={deletingId === selectedArtifact?.artifact.slug}
onClose={() => setSelectedSlug(null)}
onDelete={(target) => void deleteArtifact(target)}
/>
</main>
)
}
@@ -0,0 +1,62 @@
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { ARTIFACTS_TABLE_GRID_CLASS } from './artifacts-table-layout'
import { LIST_TABLE_CONTAINER_CLASS, LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout'
function SkeletonBar({ className }: { className?: string }): React.JSX.Element {
return <div className={cn('animate-pulse rounded bg-muted/60', className)} />
}
const TABLE_ROW_SKELETONS = [
{ id: 'row-1', name: 'w-36', type: 'w-12', size: 'w-10', updated: 'w-16', expires: 'w-20' },
{ id: 'row-2', name: 'w-28', type: 'w-16', size: 'w-12', updated: 'w-20', expires: 'w-16' },
{ id: 'row-3', name: 'w-44', type: 'w-12', size: 'w-10', updated: 'w-14', expires: 'w-24' },
{ id: 'row-4', name: 'w-32', type: 'w-14', size: 'w-11', updated: 'w-16', expires: 'w-16' },
{ id: 'row-5', name: 'w-40', type: 'w-12', size: 'w-10', updated: 'w-20', expires: 'w-20' },
{ id: 'row-6', name: 'w-24', type: 'w-16', size: 'w-12', updated: 'w-16', expires: 'w-14' }
] as const
export function ArtifactsPageSkeleton(): React.JSX.Element {
return (
<div
className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-3 pb-4 md:px-5"
// Why: aria-label on a roleless div is not exposed to screen readers.
role="status"
aria-busy="true"
aria-label={translate(
'auto.components.artifacts.ArtifactsPageSkeleton.loading',
'Loading artifacts'
)}
>
<div className="flex shrink-0 items-center gap-2">
<SkeletonBar className="h-8 w-56 shrink-0 rounded-md" />
<SkeletonBar className="size-8 shrink-0 rounded-md" />
</div>
<div className={cn('min-h-0 flex-1 overflow-hidden', LIST_TABLE_CONTAINER_CLASS)}>
<div className={cn(ARTIFACTS_TABLE_GRID_CLASS, LIST_TABLE_HEADER_CLASS)}>
<SkeletonBar className="h-2.5 w-12" />
<SkeletonBar className="h-2.5 w-10" />
<SkeletonBar className="h-2.5 w-8" />
<SkeletonBar className="h-2.5 w-14" />
<SkeletonBar className="h-2.5 w-14" />
<span />
</div>
<div className="divide-y divide-border/50">
{TABLE_ROW_SKELETONS.map((row) => (
<div
key={row.id}
className={cn(ARTIFACTS_TABLE_GRID_CLASS, 'min-h-11 items-center gap-3 px-3 py-3')}
>
<SkeletonBar className={cn('h-3.5', row.name)} />
<SkeletonBar className={cn('h-3.5', row.type)} />
<SkeletonBar className={cn('h-3.5', row.size)} />
<SkeletonBar className={cn('h-3.5', row.updated)} />
<SkeletonBar className={cn('h-3.5', row.expires)} />
<SkeletonBar className="size-6 rounded-md" />
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,170 @@
import { ArrowRight, Files, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
export function ArtifactsPageErrorBanner({
error,
loading,
onRetry
}: {
error: string
loading: boolean
onRetry: () => void
}): React.JSX.Element {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-destructive/30 bg-destructive/10 px-3 py-2 md:px-5">
<p className="min-w-0 flex-1 text-xs text-destructive">{error}</p>
<Button type="button" variant="outline" size="xs" disabled={loading} onClick={onRetry}>
{translate('auto.components.artifacts.ArtifactsPage.retry', 'Retry')}
</Button>
</div>
)
}
export function ArtifactsPageAuthState({
connecting,
needsReconnect,
configured,
onConnect,
onOpenAccountSettings
}: {
connecting: boolean
needsReconnect: boolean
configured: boolean
onConnect: () => void
onOpenAccountSettings: () => void
}): React.JSX.Element {
return (
<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">
{needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.reconnectHeading',
'Sign in to Orca again'
)
: translate(
'auto.components.artifacts.ArtifactsPage.signInHeading',
'Sign in to share artifacts'
)}
</h2>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.reconnectCopy',
'Sign in again to view and manage the artifacts shared through your account.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.signInCopy',
'Use your Orca account to upload artifacts and manage their public links.'
)}
</p>
</div>
{configured ? (
<Button size="sm" disabled={connecting} onClick={onConnect}>
{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={onOpenAccountSettings}>
{translate(
'auto.components.artifacts.ArtifactsPage.openAccountSettings',
'Open account settings'
)}
<ArrowRight />
</Button>
</div>
)}
</div>
)
}
export function ArtifactsPageEmptyState({
hasMore,
loadingMore,
publishingBlocked,
onLoadMore,
onOpenArtifactsSettings
}: {
hasMore: boolean
loadingMore: boolean
publishingBlocked: boolean
onLoadMore: () => void
onOpenArtifactsSettings: () => void
}): React.JSX.Element {
return (
<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">
{hasMore
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailable',
'More artifacts are available'
)
: publishingBlocked
? translate(
'auto.components.artifacts.ArtifactsPage.publishingOff',
'Publishing is turned off'
)
: translate('auto.components.artifacts.ArtifactsPage.empty', 'No shared artifacts')}
</h2>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{hasMore
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailableCopy',
'Load the next page to continue.'
)
: publishingBlocked
? translate(
'auto.components.artifacts.ArtifactsPage.publishingOffCopy',
'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.emptyCopy',
'Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.'
)}
</p>
{!hasMore && publishingBlocked ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={onOpenArtifactsSettings}
>
{translate(
'auto.components.artifacts.ArtifactsPage.openArtifactsSettings',
'Open Settings → Artifacts'
)}
</Button>
) : null}
{hasMore ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
disabled={loadingMore}
onClick={onLoadMore}
>
{loadingMore ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import {
artifactTypeLabel,
formatArtifactExpiryCompact,
formatArtifactUpdatedCompact
} from './artifact-display-labels'
function item(sourceContentType: string): ArtifactListItem {
return {
artifact: {
version: 1,
slug: 'doc',
title: 'Doc',
originalFileName: 'doc.md',
sourceContentType,
renderedContentType: 'text/html',
createdAt: '2026-08-01T12:00:00.000Z',
updatedAt: '2026-08-02T12:00:00.000Z',
expiresAt: '2026-09-01T12:00:00.000Z',
byteSize: 1,
deletedAt: null
},
shareUrl: 'https://share.onorca.dev/a/doc'
}
}
describe('artifact display labels', () => {
afterEach(() => {
vi.useRealTimers()
})
it('labels known artifact types', () => {
expect(artifactTypeLabel(item('text/html'))).toBe('HTML')
expect(artifactTypeLabel(item('text/markdown'))).toBe('Markdown')
expect(artifactTypeLabel(item('application/pdf'))).toBe('application/pdf')
})
it('uses compact relative times for table cells', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-10T12:00:00.000Z'))
expect(formatArtifactUpdatedCompact('2026-08-08T12:00:00.000Z')).toBe('2 days ago')
expect(formatArtifactUpdatedCompact('not-a-date')).toBe('recently')
expect(formatArtifactExpiryCompact('2026-08-20T12:00:00.000Z')).toBe('in 10 days')
expect(formatArtifactExpiryCompact('2026-08-01T12:00:00.000Z')).toBe('Expired')
expect(formatArtifactExpiryCompact('not-a-date')).toBe('Expiry unknown')
})
})
@@ -1,19 +1,11 @@
import { FileCode2, FileText, type LucideIcon } from 'lucide-react'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { getIntlLocale, translate } from '@/i18n/i18n'
import { 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`
@@ -26,13 +18,17 @@ export function formatByteSize(value: number): string {
export function formatArtifactUpdatedAt(value: string): string {
return translate('auto.components.artifacts.updatedAt', 'Updated {{when}}', {
when: formatUiRelativeTimeFromDate(
value,
translate('auto.components.artifacts.updatedRecently', 'recently')
)
when: formatArtifactUpdatedCompact(value)
})
}
export function formatArtifactUpdatedCompact(value: string): string {
return 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)
@@ -47,6 +43,23 @@ export function formatArtifactExpiry(value: string): string {
})
}
export function artifactTypeIcon(item: ArtifactListItem): LucideIcon {
return item.artifact.sourceContentType === 'text/markdown' ? FileText : FileCode2
export function formatArtifactExpiryCompact(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.expiredCompact', 'Expired')
: formatUiRelativeTime(remainingMs)
}
export function artifactTypeLabel(item: ArtifactListItem): string {
if (item.artifact.sourceContentType === 'text/markdown') {
return translate('auto.components.artifacts.typeMarkdown', 'Markdown')
}
if (item.artifact.sourceContentType === 'text/html') {
return translate('auto.components.artifacts.typeHtml', 'HTML')
}
return item.artifact.sourceContentType
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { ArtifactListItem } from '../../../../shared/artifacts'
import {
ARTIFACT_LIST_SEARCH_QUERY_MAX_BYTES,
artifactMatchesSearchQuery,
clampArtifactListSearchQuery,
filterArtifactsBySearchQuery
} from './artifact-list-search'
function item(overrides: Partial<ArtifactListItem['artifact']> = {}): ArtifactListItem {
return {
artifact: {
version: 1,
slug: 'report-123',
title: 'Quarterly report',
originalFileName: 'report.html',
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-01T12:00:00.000Z',
updatedAt: '2026-08-02T12:00:00.000Z',
expiresAt: '2026-09-01T12:00:00.000Z',
byteSize: 1024,
deletedAt: null,
...overrides
},
shareUrl: 'https://share.onorca.dev/a/report-123'
}
}
describe('artifact list search', () => {
it('matches title, filename, slug, and type', () => {
expect(artifactMatchesSearchQuery(item(), 'quarterly')).toBe(true)
expect(artifactMatchesSearchQuery(item(), 'report.html')).toBe(true)
expect(artifactMatchesSearchQuery(item(), 'report-123')).toBe(true)
expect(artifactMatchesSearchQuery(item(), 'html')).toBe(true)
expect(artifactMatchesSearchQuery(item(), 'markdown')).toBe(false)
})
it('treats blank queries as a match', () => {
expect(artifactMatchesSearchQuery(item(), ' ')).toBe(true)
})
it('filters a list without mutating it', () => {
const items = [
item(),
item({ slug: 'notes', title: 'Notes', sourceContentType: 'text/markdown' })
]
const filtered = filterArtifactsBySearchQuery(items, 'notes')
expect(filtered).toHaveLength(1)
expect(filtered[0]?.artifact.slug).toBe('notes')
expect(items).toHaveLength(2)
})
it('leaves the list unfiltered rather than scanning an oversized query', () => {
const oversized = 'a'.repeat(ARTIFACT_LIST_SEARCH_QUERY_MAX_BYTES + 1)
const items = [item()]
expect(filterArtifactsBySearchQuery(items, oversized)).toBe(items)
expect(artifactMatchesSearchQuery(item(), oversized)).toBe(true)
})
it('clamps a multi-MB paste before it reaches state', () => {
const paste = 'x'.repeat(4 * 1024 * 1024)
expect(clampArtifactListSearchQuery(paste)).toHaveLength(
ARTIFACT_LIST_SEARCH_QUERY_MAX_BYTES + 1
)
expect(clampArtifactListSearchQuery('short')).toBe('short')
})
})
@@ -0,0 +1,59 @@
import type { ArtifactListItem } from '../../../../shared/artifacts'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import { artifactName, artifactTypeLabel } from './artifact-display-labels'
/** Pasted queries above this are clamped so filtering never runs on unbounded input. */
export const ARTIFACT_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024
export function artifactSearchHaystack(item: ArtifactListItem): string {
return [
artifactName(item),
item.artifact.originalFileName,
item.artifact.slug,
artifactTypeLabel(item)
]
.filter((value): value is string => Boolean(value))
.join('\n')
.toLowerCase()
}
/**
* Caps the controlled input value so a multi-MB paste cannot pin renderer memory.
* Keeping maxBytes+1 code units is enough for the over-limit check while
* discarding the rest of the paste.
*/
export function clampArtifactListSearchQuery(
rawQuery: string,
maxBytes = ARTIFACT_LIST_SEARCH_QUERY_MAX_BYTES
): string {
return rawQuery.length <= maxBytes + 1 ? rawQuery : rawQuery.slice(0, maxBytes + 1)
}
/** Active lowercase query, or null when the list must stay unfiltered. */
export function activeArtifactListSearchQuery(
rawQuery: string,
maxBytes = ARTIFACT_LIST_SEARCH_QUERY_MAX_BYTES
): string | null {
// Why: length pre-check short-circuits multi-MB pastes before the UTF-8 scan.
if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) {
return null
}
return rawQuery.trim().toLowerCase() || null
}
export function artifactMatchesSearchQuery(item: ArtifactListItem, query: string): boolean {
const activeQuery = activeArtifactListSearchQuery(query)
return activeQuery === null || artifactSearchHaystack(item).includes(activeQuery)
}
export function filterArtifactsBySearchQuery(
artifacts: readonly ArtifactListItem[],
query: string
): readonly ArtifactListItem[] {
// Why: normalize once per filter, not once per artifact.
const activeQuery = activeArtifactListSearchQuery(query)
if (activeQuery === null) {
return artifacts
}
return artifacts.filter((item) => artifactSearchHaystack(item).includes(activeQuery))
}
@@ -0,0 +1,4 @@
/** Column template for the artifacts list table; shared chrome lives in @/lib/list-table-layout. */
// Name | Type | Size | Updated | Expires | Actions
export const ARTIFACTS_TABLE_GRID_CLASS =
'grid grid-cols-[minmax(0,1.6fr)_minmax(4.5rem,6.5rem)_minmax(4rem,5.5rem)_minmax(6.5rem,9rem)_minmax(6.5rem,9rem)_2.5rem]'
@@ -30,12 +30,9 @@ import {
} from './external-automation-display'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
import {
AUTOMATIONS_TABLE_GRID_CLASS,
AUTOMATIONS_TABLE_ROW_CLASS,
AUTOMATIONS_TABLE_ROW_SELECTED_CLASS
} from './automations-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import { LIST_TABLE_ROW_CLASS, LIST_TABLE_ROW_SELECTED_CLASS } from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { AutomationListStatusCell } from './AutomationListStatusCell'
@@ -113,8 +110,8 @@ export function AutomationListExternalRows({
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
AUTOMATIONS_TABLE_ROW_CLASS,
isSelected && AUTOMATIONS_TABLE_ROW_SELECTED_CLASS
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className="min-w-0 truncate font-medium">{entry.job.name}</span>
@@ -39,12 +39,9 @@ import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { getAgentLabel } from './automation-draft-model'
import { formatAutomationCost } from './automation-usage-model'
import {
AUTOMATIONS_TABLE_GRID_CLASS,
AUTOMATIONS_TABLE_ROW_CLASS,
AUTOMATIONS_TABLE_ROW_SELECTED_CLASS
} from './automations-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import { LIST_TABLE_ROW_CLASS, LIST_TABLE_ROW_SELECTED_CLASS } from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
@@ -191,8 +188,8 @@ export function AutomationListLocalRows({
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
AUTOMATIONS_TABLE_ROW_CLASS,
isSelected && AUTOMATIONS_TABLE_ROW_SELECTED_CLASS
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className="min-w-0 truncate font-medium">{automation.name}</span>
@@ -1,10 +1,8 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
AUTOMATIONS_TABLE_GRID_CLASS,
AUTOMATIONS_TABLE_HEADER_CLASS
} from './automations-table-layout'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import { LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout'
import { AutomationListSortHeader } from './AutomationListSortHeader'
import type { AutomationListSort, AutomationListSortField } from './automation-list-view'
@@ -16,7 +14,7 @@ export function AutomationListTableHeader({
onSort: (field: AutomationListSortField) => void
}): React.JSX.Element {
return (
<div className={cn(AUTOMATIONS_TABLE_GRID_CLASS, AUTOMATIONS_TABLE_HEADER_CLASS)}>
<div className={cn(AUTOMATIONS_TABLE_GRID_CLASS, LIST_TABLE_HEADER_CLASS)}>
<AutomationListSortHeader
field="name"
label={translate('auto.components.automations.AutomationsPage.tableName', 'Name')}
@@ -20,7 +20,7 @@ import type { AutomationPaneTab } from './automation-page-state'
import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
import { AutomationListLocalRows } from './AutomationListLocalRows'
import { AutomationListExternalRows } from './AutomationListExternalRows'
import { AUTOMATIONS_TABLE_CONTAINER_CLASS } from './automations-table-layout'
import { LIST_TABLE_CONTAINER_CLASS } from '@/lib/list-table-layout'
import { translate } from '@/i18n/i18n'
import type {
AutomationListFilter,
@@ -183,7 +183,7 @@ export function AutomationsListPanel({
<div
className={cn(
'scrollbar-sleek min-h-0 flex-1 overflow-auto',
AUTOMATIONS_TABLE_CONTAINER_CLASS
LIST_TABLE_CONTAINER_CLASS
)}
>
{hasFilteredListItems ? (
@@ -1,10 +1,7 @@
import React from 'react'
import { cn } from '@/lib/utils'
import {
AUTOMATIONS_TABLE_CONTAINER_CLASS,
AUTOMATIONS_TABLE_GRID_CLASS,
AUTOMATIONS_TABLE_HEADER_CLASS
} from './automations-table-layout'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import { LIST_TABLE_CONTAINER_CLASS, LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout'
import { translate } from '@/i18n/i18n'
function SkeletonBar({ className }: { className?: string }): React.JSX.Element {
@@ -116,10 +113,10 @@ export function AutomationsPageSkeleton(): React.JSX.Element {
<SkeletonBar className="h-8 w-32 shrink-0 rounded-md" />
</div>
<div
className={cn('min-h-0 flex-1 overflow-hidden', AUTOMATIONS_TABLE_CONTAINER_CLASS)}
className={cn('min-h-0 flex-1 overflow-hidden', LIST_TABLE_CONTAINER_CLASS)}
data-contextual-fix-target="automations-list"
>
<div className={cn(AUTOMATIONS_TABLE_GRID_CLASS, AUTOMATIONS_TABLE_HEADER_CLASS)}>
<div className={cn(AUTOMATIONS_TABLE_GRID_CLASS, LIST_TABLE_HEADER_CLASS)}>
<SkeletonBar className="h-2.5 w-12" />
<SkeletonBar className="h-2.5 w-14" />
<SkeletonBar className="h-2.5 w-14" />
@@ -1,15 +1,4 @@
/** Shared layout classes for the automations list table.
* Matches AutomationRunHistory / ExternalAutomationRunTable / Tasks list tables. */
/** Column template for the automations list table; shared chrome lives in @/lib/list-table-layout. */
// Name | Schedule | Project | Next run | Last run | Status | Agent | Actions
export const AUTOMATIONS_TABLE_GRID_CLASS =
'grid grid-cols-[minmax(0,1.4fr)_minmax(7.5rem,10rem)_minmax(4.5rem,8rem)_minmax(8.5rem,1fr)_minmax(8rem,11rem)_minmax(4.5rem,6rem)_2.5rem_2.5rem]'
export const AUTOMATIONS_TABLE_CONTAINER_CLASS = 'rounded-md border border-border/50 bg-muted/20'
export const AUTOMATIONS_TABLE_HEADER_CLASS =
'sticky top-0 z-10 h-8 items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground'
export const AUTOMATIONS_TABLE_ROW_CLASS =
'w-full min-h-11 cursor-pointer items-center gap-3 px-3 py-3 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50'
export const AUTOMATIONS_TABLE_ROW_SELECTED_CLASS = 'bg-accent text-accent-foreground'
+30 -12
View File
@@ -15726,8 +15726,6 @@
"deleteDescription": "“{{name}}” will no longer be available at its public link.",
"delete": "Delete",
"deleteFailed": "Could not delete the artifact.",
"closeArtifacts": "Close artifacts",
"closeTooltip": "Close · Esc",
"title": "Artifacts",
"refresh": "Refresh",
"signInHeading": "Sign in to Orca",
@@ -15744,8 +15742,6 @@
"publishingOff": "Publishing is turned off",
"publishingOffCopy": "Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.",
"openArtifactsSettings": "Open Settings → Artifacts",
"loadedCountMore": "{{count}} loaded · more available",
"loadedCount": "{{count}} shared",
"retry": "Retry",
"reconnectHeading": "Sign in to Orca again",
"reconnectCopy": "Sign in again to view and manage the artifacts shared through your account.",
@@ -15761,16 +15757,16 @@
"previewUnavailable": "Preview unavailable",
"previewUnavailableDescription": "Open this artifact in your browser to view it.",
"actions": "Artifact actions",
"ArtifactActions": {
"more": "More artifact actions"
},
"ArtifactCollection": {
"loadMore": "Load more"
"loadMore": "Load more",
"noMatches": "No matches"
},
"ArtifactDetailHeader": {
"publicLink": "Anyone with this link can view it"
},
"ArtifactListPane": {
"search": "Search artifacts",
"listLabel": "Shared artifacts",
"noMatches": "No matches"
"publicLink": "Anyone with this link can view it",
"close": "Close"
},
"updatedAt": "Updated {{when}}",
"updatedRecently": "recently",
@@ -15814,7 +15810,29 @@
"openLink": "Open link",
"updating": "Updating…",
"update": "Update shared content"
}
},
"ArtifactDetailDrawer": {
"description": "Preview and manage this shared artifact."
},
"ArtifactListSearchField": {
"label": "Search artifacts",
"placeholder": "Search...",
"clear": "Clear search"
},
"ArtifactListTableHeader": {
"name": "Name",
"type": "Type",
"size": "Size",
"updated": "Updated",
"expires": "Expires",
"actions": "Actions"
},
"ArtifactsPageSkeleton": {
"loading": "Loading artifacts"
},
"expiredCompact": "Expired",
"typeMarkdown": "Markdown",
"typeHtml": "HTML"
}
},
"i18n": {
+7
View File
@@ -14279,6 +14279,13 @@
"pendingChip": "{{value0}} pendiente",
"needsActionChip": "{{value0}} requiere acción",
"unresolvedChip": "{{value0}} sin resolver"
},
"artifacts": {
"ArtifactsPage": {
"deleteTitle": "¿Eliminar el artefacto?",
"deleteDescription": "“{{name}}” ya no estará disponible en su enlace público.",
"delete": "Eliminar"
}
}
},
"i18n": {
+7
View File
@@ -14279,6 +14279,13 @@
"pendingChip": "{{value0}} 件保留中",
"needsActionChip": "{{value0}} 件の対応が必要",
"unresolvedChip": "{{value0}} 件未解決"
},
"artifacts": {
"ArtifactsPage": {
"deleteTitle": "成果物を削除しますか?",
"deleteDescription": "「{{name}}」は公開リンクで利用できなくなります。",
"delete": "削除"
}
}
},
"i18n": {
+7
View File
@@ -14308,6 +14308,13 @@
"pendingChip": "{{value0}} 보류 중",
"needsActionChip": "{{value0}}개 조치 필요",
"unresolvedChip": "{{value0}}개 미해결"
},
"artifacts": {
"ArtifactsPage": {
"deleteTitle": "아티팩트를 삭제할까요?",
"deleteDescription": "“{{name}}”은(는) 공개 링크에서 더 이상 사용할 수 없습니다.",
"delete": "삭제"
}
}
},
"i18n": {
+7
View File
@@ -14299,6 +14299,13 @@
"pendingChip": "{{value0}} 待处理",
"needsActionChip": "{{value0}} 个需要操作",
"unresolvedChip": "{{value0}} 个未解决"
},
"artifacts": {
"ArtifactsPage": {
"deleteTitle": "删除工件?",
"deleteDescription": "“{{name}}” 将无法再通过其公共链接访问。",
"delete": "删除"
}
}
},
"i18n": {
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction'
import { isPortaledRowMenuClick, isRowActivationKey } from './list-row-interaction'
describe('isPortaledRowMenuClick', () => {
it('detects clicks whose target is outside the row DOM', () => {
@@ -41,6 +41,7 @@ describe('isRowActivationKey', () => {
expect(isRowActivationKey({ key: 'Enter', target: row, currentTarget: row })).toBe(true)
expect(isRowActivationKey({ key: ' ', target: row, currentTarget: row })).toBe(true)
expect(isRowActivationKey({ key: 'a', target: row, currentTarget: row })).toBe(false)
expect(isRowActivationKey({ key: 'Tab', target: row, currentTarget: row })).toBe(false)
})
it('ignores keys pressed on a nested control', () => {
@@ -1,3 +1,5 @@
/** Shared row semantics for the list tables (automations, artifacts, …). */
/** True when a portaled Radix menu click re-bubbles through a row's React tree. */
export function isPortaledRowMenuClick(event: {
target: EventTarget
+14
View File
@@ -0,0 +1,14 @@
/**
* Shared chrome for the full-width list tables (automations, artifacts, …).
* Each list owns only its own column template; everything else lives here so
* the tables cannot drift apart.
*/
export const LIST_TABLE_CONTAINER_CLASS = 'rounded-md border border-border/50 bg-muted/20'
export const LIST_TABLE_HEADER_CLASS =
'sticky top-0 z-10 h-8 items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground'
export const LIST_TABLE_ROW_CLASS =
'w-full min-h-11 cursor-pointer items-center gap-3 px-3 py-3 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50'
export const LIST_TABLE_ROW_SELECTED_CLASS = 'bg-accent text-accent-foreground'