From 314b02ba2d5611d64ea3a44dc933d384a14325c7 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:55:57 -0700
Subject: [PATCH] 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.
---
src/renderer/src/App.tsx | 25 +-
.../src/app-shell/AppWorkspaceShell.tsx | 6 +-
.../src/app-shell/app-window-chrome.ts | 9 +
.../components/artifacts/ArtifactActions.tsx | 39 +-
.../artifacts/ArtifactCollection.test.tsx | 71 ++--
.../artifacts/ArtifactCollection.tsx | 93 +++--
.../artifacts/ArtifactDetailDrawer.tsx | 59 +++
.../artifacts/ArtifactDetailHeader.tsx | 28 +-
.../components/artifacts/ArtifactListPane.tsx | 209 -----------
.../components/artifacts/ArtifactListRows.tsx | 196 ++++++++++
.../artifacts/ArtifactListSearchField.tsx | 75 ++++
.../artifacts/ArtifactListTableHeader.tsx | 23 ++
.../artifacts/ArtifactListToolbar.tsx | 47 +++
.../artifacts/ArtifactsPage.test.tsx | 103 ++++--
.../components/artifacts/ArtifactsPage.tsx | 339 +++++-------------
.../artifacts/ArtifactsPageSkeleton.tsx | 62 ++++
.../artifacts/ArtifactsPageStates.tsx | 170 +++++++++
.../artifacts/artifact-display-labels.test.ts | 48 +++
.../artifacts/artifact-display-labels.ts | 43 ++-
.../artifacts/artifact-list-search.test.ts | 70 ++++
.../artifacts/artifact-list-search.ts | 59 +++
.../artifacts/artifacts-table-layout.ts | 4 +
.../AutomationListExternalRows.tsx | 13 +-
.../automations/AutomationListLocalRows.tsx | 13 +-
.../automations/AutomationListTableHeader.tsx | 8 +-
.../automations/AutomationsListPanel.tsx | 4 +-
.../automations/AutomationsPageSkeleton.tsx | 11 +-
.../automations/automations-table-layout.ts | 13 +-
src/renderer/src/i18n/locales/en.json | 42 ++-
src/renderer/src/i18n/locales/es.json | 7 +
src/renderer/src/i18n/locales/ja.json | 7 +
src/renderer/src/i18n/locales/ko.json | 7 +
src/renderer/src/i18n/locales/zh.json | 7 +
.../list-row-interaction.test.ts} | 3 +-
.../list-row-interaction.ts} | 2 +
src/renderer/src/lib/list-table-layout.ts | 14 +
36 files changed, 1280 insertions(+), 649 deletions(-)
create mode 100644 src/renderer/src/components/artifacts/ArtifactDetailDrawer.tsx
delete mode 100644 src/renderer/src/components/artifacts/ArtifactListPane.tsx
create mode 100644 src/renderer/src/components/artifacts/ArtifactListRows.tsx
create mode 100644 src/renderer/src/components/artifacts/ArtifactListSearchField.tsx
create mode 100644 src/renderer/src/components/artifacts/ArtifactListTableHeader.tsx
create mode 100644 src/renderer/src/components/artifacts/ArtifactListToolbar.tsx
create mode 100644 src/renderer/src/components/artifacts/ArtifactsPageSkeleton.tsx
create mode 100644 src/renderer/src/components/artifacts/ArtifactsPageStates.tsx
create mode 100644 src/renderer/src/components/artifacts/artifact-display-labels.test.ts
create mode 100644 src/renderer/src/components/artifacts/artifact-list-search.test.ts
create mode 100644 src/renderer/src/components/artifacts/artifact-list-search.ts
create mode 100644 src/renderer/src/components/artifacts/artifacts-table-layout.ts
rename src/renderer/src/{components/automations/automation-list-row-interaction.test.ts => lib/list-row-interaction.test.ts} (90%)
rename src/renderer/src/{components/automations/automation-list-row-interaction.ts => lib/list-row-interaction.ts} (91%)
create mode 100644 src/renderer/src/lib/list-table-layout.ts
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index b6dfd73718f..5ef5ecdd9bc 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -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
}
>
diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx
index 67334267346..af0df59ccd6 100644
--- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx
+++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx
@@ -155,8 +155,10 @@ export function AppWorkspaceShell(props: {
)
) : null}
- {/* 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' ? (
{titlebarMainStrip}
) : null}
diff --git a/src/renderer/src/app-shell/app-window-chrome.ts b/src/renderer/src/app-shell/app-window-chrome.ts
index 03b91d50a27..8305650c4d0 100644
--- a/src/renderer/src/app-shell/app-window-chrome.ts
+++ b/src/renderer/src/app-shell/app-window-chrome.ts
@@ -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'
diff --git a/src/renderer/src/components/artifacts/ArtifactActions.tsx b/src/renderer/src/components/artifacts/ArtifactActions.tsx
index 8caea7f0b33..a7d7478fbd3 100644
--- a/src/renderer/src/components/artifacts/ArtifactActions.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactActions.tsx
@@ -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')}
-
-
+
+
onDelete(item)}
aria-label={translate(
- 'auto.components.artifacts.ArtifactsPage.deleteArtifact',
- 'Delete artifact'
+ 'auto.components.artifacts.ArtifactActions.more',
+ 'More artifact actions'
)}
>
- {deleting ? : }
+ {deleting ? : }
-
-
- {translate('auto.components.artifacts.ArtifactsPage.deleteArtifact', 'Delete artifact')}
-
-
+
+
+ onDelete(item)}
+ >
+
+ {translate('auto.components.artifacts.ArtifactsPage.deleteArtifact', 'Delete artifact')}
+
+
+
)
}
diff --git a/src/renderer/src/components/artifacts/ArtifactCollection.test.tsx b/src/renderer/src/components/artifacts/ArtifactCollection.test.tsx
index 3a5b16e7dea..2c9b2cb405a 100644
--- a/src/renderer/src/components/artifacts/ArtifactCollection.test.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactCollection.test.tsx
@@ -10,12 +10,9 @@ vi.mock('./ArtifactPreview', () => ({
ArtifactPreview: ({ shareUrl }: { shareUrl: string }) =>
{`Preview ${shareUrl}`}
}))
-vi.mock('./ArtifactActions', () => ({
- ArtifactActions: () =>
Artifact actions
-}))
-
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', () => {
)
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()
})
})
diff --git a/src/renderer/src/components/artifacts/ArtifactCollection.tsx b/src/renderer/src/components/artifacts/ArtifactCollection.tsx
index a601a964f38..5e1034ce0aa 100644
--- a/src/renderer/src/components/artifacts/ArtifactCollection.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactCollection.tsx
@@ -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.
-
-
-
-
+
+
+
+ {matches.length > 0 ? (
+
+ ) : (
+
+ {translate('auto.components.artifacts.ArtifactCollection.noMatches', 'No matches')}
+
+ )}
+ {hasMore ? (
+
+
+ {loadingMore ? : null}
+ {translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
+
+
+ ) : null}
+
+
+
)
}
diff --git a/src/renderer/src/components/artifacts/ArtifactDetailDrawer.tsx b/src/renderer/src/components/artifacts/ArtifactDetailDrawer.tsx
new file mode 100644
index 00000000000..a95f5170e77
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactDetailDrawer.tsx
@@ -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 (
+
!open && onClose()}>
+
+ {item ? (
+
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactDetailDrawer.description',
+ 'Preview and manage this shared artifact.'
+ )}
+
+
+
+ {artifactName(item)}
+
+ }
+ onClose={onClose}
+ onDelete={onDelete}
+ />
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/renderer/src/components/artifacts/ArtifactDetailHeader.tsx b/src/renderer/src/components/artifacts/ArtifactDetailHeader.tsx
index 806f2d98bbb..f5a7354ee7c 100644
--- a/src/renderer/src/components/artifacts/ArtifactDetailHeader.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactDetailHeader.tsx
@@ -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 (
-
+ // 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.
+
{/* Why: a floor rather than min-w-0 — otherwise the title truncates to nothing before the actions wrap. */}
-
{artifactName(item)}
+ {title}
@@ -49,7 +56,18 @@ export function ArtifactDetailHeader({
{formatByteSize(item.artifact.byteSize)} · {formatArtifactExpiry(item.artifact.expiresAt)}
-
+
)
}
diff --git a/src/renderer/src/components/artifacts/ArtifactListPane.tsx b/src/renderer/src/components/artifacts/ArtifactListPane.tsx
deleted file mode 100644
index debfcfd07d3..00000000000
--- a/src/renderer/src/components/artifacts/ArtifactListPane.tsx
+++ /dev/null
@@ -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
(OPTION_SELECTOR) ?? [])]
- const next = options[options.indexOf(from) + step]
- next?.focus()
-}
-
-function focusEdgeOption(listbox: HTMLElement | null, edge: 'first' | 'last'): void {
- const options = [...(listbox?.querySelectorAll(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(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, 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 (
-
-
-
- setQuery(event.target.value)}
- placeholder={translate(
- 'auto.components.artifacts.ArtifactListPane.search',
- 'Search artifacts'
- )}
- className="h-8 pl-8 text-sm"
- />
-
-
-
- {matches.map((item) => {
- const selected = item.artifact.slug === selectedArtifact.artifact.slug
- const name = artifactName(item)
- const TypeIcon = artifactTypeIcon(item)
- return (
-
-
- 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'
- )}
- >
-
-
-
-
- {name}
-
-
- {name}
-
- {formatArtifactDate(item.artifact.updatedAt)}
-
-
- {formatArtifactExpiry(item.artifact.expiresAt)}
-
-
-
-
- {formatArtifactUpdatedAt(item.artifact.updatedAt)} ·{' '}
- {formatByteSize(item.artifact.byteSize)}
-
-
-
-
-
- void copyArtifactLink(item.shareUrl)}>
-
- {translate('auto.components.artifacts.copyLink', 'Copy link')}
-
- openArtifactInBrowser(item.shareUrl)}>
-
- {translate('auto.components.artifacts.openInBrowser', 'Open in browser')}
-
-
- deleteArtifact(item)}
- >
-
- {translate(
- 'auto.components.artifacts.ArtifactsPage.deleteArtifact',
- 'Delete artifact'
- )}
-
-
-
- )
- })}
-
- {matches.length === 0 ? (
-
- {translate('auto.components.artifacts.ArtifactListPane.noMatches', 'No matches')}
-
- ) : null}
- {hasMore ? (
-
-
- {loadingMore ? : null}
- {translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
-
-
- ) : null}
-
-
- )
-}
diff --git a/src/renderer/src/components/artifacts/ArtifactListRows.tsx b/src/renderer/src/components/artifacts/ArtifactListRows.tsx
new file mode 100644
index 00000000000..8a1406586e0
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactListRows.tsx
@@ -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 (
+
+
+ {
+ 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
+ )}
+ >
+
+ {name}
+
+
+ {typeLabel}
+
+
+ {sizeLabel}
+
+
+ {updatedLabel}
+
+
+ {expiryLabel}
+
+
+
+ event.stopPropagation()}
+ >
+
+
+
+
+ {rowActions.map(
+ ({ key, label, icon: Icon, onSelect, destructive, disabled }) => (
+
+ {destructive ? : null}
+
+
+ {label}
+
+
+ )
+ )}
+
+
+
+
+
+ {rowActions.map(({ key, label, icon: Icon, onSelect, destructive, disabled }) => (
+
+ {destructive ? : null}
+
+
+ {label}
+
+
+ ))}
+
+
+ )
+ })}
+ >
+ )
+}
diff --git a/src/renderer/src/components/artifacts/ArtifactListSearchField.tsx b/src/renderer/src/components/artifacts/ArtifactListSearchField.tsx
new file mode 100644
index 00000000000..17101c01d60
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactListSearchField.tsx
@@ -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(null)
+ const hasText = query !== ''
+
+ return (
+
+
+ onQueryChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key !== 'Escape' || event.nativeEvent.isComposing || !hasText) {
+ return
+ }
+ event.preventDefault()
+ onClear()
+ }}
+ />
+ {hasText ? (
+ event.preventDefault()}
+ onClick={() => {
+ onClear()
+ inputRef.current?.focus()
+ }}
+ >
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/artifacts/ArtifactListTableHeader.tsx b/src/renderer/src/components/artifacts/ArtifactListTableHeader.tsx
new file mode 100644
index 00000000000..9cae1d0c66b
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactListTableHeader.tsx
@@ -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 (
+
+ {translate('auto.components.artifacts.ArtifactListTableHeader.name', 'Name')}
+ {translate('auto.components.artifacts.ArtifactListTableHeader.type', 'Type')}
+ {translate('auto.components.artifacts.ArtifactListTableHeader.size', 'Size')}
+
+ {translate('auto.components.artifacts.ArtifactListTableHeader.updated', 'Updated')}
+
+
+ {translate('auto.components.artifacts.ArtifactListTableHeader.expires', 'Expires')}
+
+
+ {translate('auto.components.artifacts.ArtifactListTableHeader.actions', 'Actions')}
+
+
+ )
+}
diff --git a/src/renderer/src/components/artifacts/ArtifactListToolbar.tsx b/src/renderer/src/components/artifacts/ArtifactListToolbar.tsx
new file mode 100644
index 00000000000..2ed9a1672da
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactListToolbar.tsx
@@ -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 (
+
+
onQueryChange('')}
+ />
+
+
+
+
+
+
+
+ {translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx b/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx
index b04cd439575..d6db83e62ff 100644
--- a/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx
@@ -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( )
- 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( )
+ 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( )
- 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( )
- 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( )
- 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( )
- 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( )
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( )
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( )
- 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( )
- 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 {
+ 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 {
return {
artifact: {
diff --git a/src/renderer/src/components/artifacts/ArtifactsPage.tsx b/src/renderer/src/components/artifacts/ArtifactsPage.tsx
index d442e7a1142..b325f3c6923 100644
--- a/src/renderer/src/components/artifacts/ArtifactsPage.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactsPage.tsx
@@ -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 => {
- 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 (
-
-
-
-
-
-
-
-
-
-
- {translate('auto.components.artifacts.ArtifactsPage.closeTooltip', 'Close · Esc')}
-
-
-
-
-
-
- {translate('auto.components.artifacts.ArtifactsPage.title', 'Artifacts')}
-
- {signedIn && artifacts.length > 0 ? (
-
- {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 }
- )}
-
- ) : null}
-
-
- {signedIn ? (
-
-
- void loadArtifacts()}
- disabled={loading}
- aria-label={translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
- >
-
-
-
-
- {translate('auto.components.artifacts.ArtifactsPage.refresh', 'Refresh')}
-
-
- ) : null}
+
+
+
+ {translate('auto.components.artifacts.ArtifactsPage.title', 'Artifacts')}
+
- {/* Why: pane edges match the full-bleed Automations layout. */}
-
- {error ? (
-
-
{error}
-
void loadArtifacts()}
- >
- {translate('auto.components.artifacts.ArtifactsPage.retry', 'Retry')}
-
-
- ) : null}
- {!signedIn ? (
-
-
-
-
- {needsReconnect
- ? translate(
- 'auto.components.artifacts.ArtifactsPage.reconnectHeading',
- 'Sign in to Orca again'
- )
- : translate(
- 'auto.components.artifacts.ArtifactsPage.signInHeading',
- 'Sign in to share artifacts'
- )}
-
-
- {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.'
- )}
-
-
- {authStatus?.configured === true ? (
-
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'
- )}
-
- ) : (
-
-
- {translate(
- 'auto.components.artifacts.ArtifactsPage.unconfiguredCopy',
- 'Orca account sign-in is not configured on this machine yet.'
- )}
-
-
- {translate(
- 'auto.components.artifacts.ArtifactsPage.openAccountSettings',
- 'Open account settings'
- )}
-
-
-
- )}
-
- ) : loading && artifacts.length === 0 ? (
-
-
-
- ) : artifacts.length === 0 ? (
-
-
-
- {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'
- )}
-
-
- {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.'
- )}
-
- {!nextCursor && publishingBlocked ? (
-
{
- openSettingsTarget({ pane: 'artifacts', repoId: null })
- openSettingsPage()
- }}
- >
- {translate(
- 'auto.components.artifacts.ArtifactsPage.openArtifactsSettings',
- 'Open Settings → Artifacts'
- )}
-
- ) : null}
- {nextCursor ? (
-
void loadMoreArtifacts()}
- >
- {loadingMore ? : null}
- {translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
-
- ) : null}
-
- ) : (
- selectedArtifact && (
-
void deleteArtifact(target)}
- hasMore={Boolean(nextCursor)}
- loadingMore={loadingMore}
- loadMore={() => void loadMoreArtifacts()}
- />
- )
- )}
-
+ {error ? (
+ void loadArtifacts()}
+ />
+ ) : null}
+ {!signedIn ? (
+ void connect()}
+ onOpenAccountSettings={openAccountSettings}
+ />
+ ) : loading && artifacts.length === 0 ? (
+
+ ) : artifacts.length === 0 ? (
+ void loadMoreArtifacts()}
+ onOpenArtifactsSettings={() => {
+ openSettingsTarget({ pane: 'artifacts', repoId: null })
+ openSettingsPage()
+ }}
+ />
+ ) : (
+ void deleteArtifact(target)}
+ hasMore={Boolean(nextCursor)}
+ loadingMore={loadingMore}
+ loadMore={() => void loadMoreArtifacts()}
+ onRefresh={() => void loadArtifacts()}
+ isRefreshing={loading}
+ />
+ )}
+
+ setSelectedSlug(null)}
+ onDelete={(target) => void deleteArtifact(target)}
+ />
)
}
diff --git a/src/renderer/src/components/artifacts/ArtifactsPageSkeleton.tsx b/src/renderer/src/components/artifacts/ArtifactsPageSkeleton.tsx
new file mode 100644
index 00000000000..1d79ecaf4a6
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactsPageSkeleton.tsx
@@ -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
+}
+
+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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {TABLE_ROW_SKELETONS.map((row) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/artifacts/ArtifactsPageStates.tsx b/src/renderer/src/components/artifacts/ArtifactsPageStates.tsx
new file mode 100644
index 00000000000..bf7c97a133d
--- /dev/null
+++ b/src/renderer/src/components/artifacts/ArtifactsPageStates.tsx
@@ -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 (
+
+
{error}
+
+ {translate('auto.components.artifacts.ArtifactsPage.retry', 'Retry')}
+
+
+ )
+}
+
+export function ArtifactsPageAuthState({
+ connecting,
+ needsReconnect,
+ configured,
+ onConnect,
+ onOpenAccountSettings
+}: {
+ connecting: boolean
+ needsReconnect: boolean
+ configured: boolean
+ onConnect: () => void
+ onOpenAccountSettings: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+
+ {needsReconnect
+ ? translate(
+ 'auto.components.artifacts.ArtifactsPage.reconnectHeading',
+ 'Sign in to Orca again'
+ )
+ : translate(
+ 'auto.components.artifacts.ArtifactsPage.signInHeading',
+ 'Sign in to share artifacts'
+ )}
+
+
+ {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.'
+ )}
+
+
+ {configured ? (
+
+ {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')}
+
+ ) : (
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactsPage.unconfiguredCopy',
+ 'Orca account sign-in is not configured on this machine yet.'
+ )}
+
+
+ {translate(
+ 'auto.components.artifacts.ArtifactsPage.openAccountSettings',
+ 'Open account settings'
+ )}
+
+
+
+ )}
+
+ )
+}
+
+export function ArtifactsPageEmptyState({
+ hasMore,
+ loadingMore,
+ publishingBlocked,
+ onLoadMore,
+ onOpenArtifactsSettings
+}: {
+ hasMore: boolean
+ loadingMore: boolean
+ publishingBlocked: boolean
+ onLoadMore: () => void
+ onOpenArtifactsSettings: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+ {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')}
+
+
+ {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.'
+ )}
+
+ {!hasMore && publishingBlocked ? (
+
+ {translate(
+ 'auto.components.artifacts.ArtifactsPage.openArtifactsSettings',
+ 'Open Settings → Artifacts'
+ )}
+
+ ) : null}
+ {hasMore ? (
+
+ {loadingMore ? : null}
+ {translate('auto.components.artifacts.ArtifactCollection.loadMore', 'Load more')}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/artifacts/artifact-display-labels.test.ts b/src/renderer/src/components/artifacts/artifact-display-labels.test.ts
new file mode 100644
index 00000000000..f2d2efc4462
--- /dev/null
+++ b/src/renderer/src/components/artifacts/artifact-display-labels.test.ts
@@ -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')
+ })
+})
diff --git a/src/renderer/src/components/artifacts/artifact-display-labels.ts b/src/renderer/src/components/artifacts/artifact-display-labels.ts
index 5bd0e510c99..eb66b873a2e 100644
--- a/src/renderer/src/components/artifacts/artifact-display-labels.ts
+++ b/src/renderer/src/components/artifacts/artifact-display-labels.ts
@@ -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
}
diff --git a/src/renderer/src/components/artifacts/artifact-list-search.test.ts b/src/renderer/src/components/artifacts/artifact-list-search.test.ts
new file mode 100644
index 00000000000..70cd490ccc7
--- /dev/null
+++ b/src/renderer/src/components/artifacts/artifact-list-search.test.ts
@@ -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 {
+ 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')
+ })
+})
diff --git a/src/renderer/src/components/artifacts/artifact-list-search.ts b/src/renderer/src/components/artifacts/artifact-list-search.ts
new file mode 100644
index 00000000000..723c3b52879
--- /dev/null
+++ b/src/renderer/src/components/artifacts/artifact-list-search.ts
@@ -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))
+}
diff --git a/src/renderer/src/components/artifacts/artifacts-table-layout.ts b/src/renderer/src/components/artifacts/artifacts-table-layout.ts
new file mode 100644
index 00000000000..d63c00c0602
--- /dev/null
+++ b/src/renderer/src/components/artifacts/artifacts-table-layout.ts
@@ -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]'
diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx
index 8cd712b09c2..b22a648b752 100644
--- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx
+++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx
@@ -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
)}
>
{entry.job.name}
diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx
index cde61bd6201..03506daf331 100644
--- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx
+++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx
@@ -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
)}
>
{automation.name}
diff --git a/src/renderer/src/components/automations/AutomationListTableHeader.tsx b/src/renderer/src/components/automations/AutomationListTableHeader.tsx
index 8adbd127727..11d412f5412 100644
--- a/src/renderer/src/components/automations/AutomationListTableHeader.tsx
+++ b/src/renderer/src/components/automations/AutomationListTableHeader.tsx
@@ -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 (
-
+
{hasFilteredListItems ? (
diff --git a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx
index 172502b29a1..c2df447b04e 100644
--- a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx
+++ b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx
@@ -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 {
-
+
diff --git a/src/renderer/src/components/automations/automations-table-layout.ts b/src/renderer/src/components/automations/automations-table-layout.ts
index b32c09d9fac..0b0db4d5229 100644
--- a/src/renderer/src/components/automations/automations-table-layout.ts
+++ b/src/renderer/src/components/automations/automations-table-layout.ts
@@ -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'
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 96c4140aa26..cf5c4ce277b 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 3195249936a..f3b1812df56 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 8c34d04a7c0..efcecf8a3d4 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -14279,6 +14279,13 @@
"pendingChip": "{{value0}} 件保留中",
"needsActionChip": "{{value0}} 件の対応が必要",
"unresolvedChip": "{{value0}} 件未解決"
+ },
+ "artifacts": {
+ "ArtifactsPage": {
+ "deleteTitle": "成果物を削除しますか?",
+ "deleteDescription": "「{{name}}」は公開リンクで利用できなくなります。",
+ "delete": "削除"
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 3cf47a8e4ae..8a3af5929d2 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -14308,6 +14308,13 @@
"pendingChip": "{{value0}} 보류 중",
"needsActionChip": "{{value0}}개 조치 필요",
"unresolvedChip": "{{value0}}개 미해결"
+ },
+ "artifacts": {
+ "ArtifactsPage": {
+ "deleteTitle": "아티팩트를 삭제할까요?",
+ "deleteDescription": "“{{name}}”은(는) 공개 링크에서 더 이상 사용할 수 없습니다.",
+ "delete": "삭제"
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 36b8ae3bca9..c24948c8c4a 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -14299,6 +14299,13 @@
"pendingChip": "{{value0}} 待处理",
"needsActionChip": "{{value0}} 个需要操作",
"unresolvedChip": "{{value0}} 个未解决"
+ },
+ "artifacts": {
+ "ArtifactsPage": {
+ "deleteTitle": "删除工件?",
+ "deleteDescription": "“{{name}}” 将无法再通过其公共链接访问。",
+ "delete": "删除"
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/components/automations/automation-list-row-interaction.test.ts b/src/renderer/src/lib/list-row-interaction.test.ts
similarity index 90%
rename from src/renderer/src/components/automations/automation-list-row-interaction.test.ts
rename to src/renderer/src/lib/list-row-interaction.test.ts
index 5e0703c174f..74958ef9f65 100644
--- a/src/renderer/src/components/automations/automation-list-row-interaction.test.ts
+++ b/src/renderer/src/lib/list-row-interaction.test.ts
@@ -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', () => {
diff --git a/src/renderer/src/components/automations/automation-list-row-interaction.ts b/src/renderer/src/lib/list-row-interaction.ts
similarity index 91%
rename from src/renderer/src/components/automations/automation-list-row-interaction.ts
rename to src/renderer/src/lib/list-row-interaction.ts
index c09504bc543..65c7972254c 100644
--- a/src/renderer/src/components/automations/automation-list-row-interaction.ts
+++ b/src/renderer/src/lib/list-row-interaction.ts
@@ -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
diff --git a/src/renderer/src/lib/list-table-layout.ts b/src/renderer/src/lib/list-table-layout.ts
new file mode 100644
index 00000000000..0e3b4d725f1
--- /dev/null
+++ b/src/renderer/src/lib/list-table-layout.ts
@@ -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'