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')} - - + + - - - {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 ? ( +
+ +
+ ) : 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 ? ( -
- -
- ) : 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} + + + + + + + {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 ? ( + + ) : 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 ? ( - - - - - - {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}

- -
- ) : 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 ? ( - - ) : ( -
-

- {translate( - 'auto.components.artifacts.ArtifactsPage.unconfiguredCopy', - 'Orca account sign-in is not configured on this machine yet.' - )} -

- -
- )} -
- ) : 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 ? ( - - ) : null} - {nextCursor ? ( - - ) : 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}

+ +
+ ) +} + +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 ? ( + + ) : ( +
+

+ {translate( + 'auto.components.artifacts.ArtifactsPage.unconfiguredCopy', + 'Orca account sign-in is not configured on this machine yet.' + )} +

+ +
+ )} +
+ ) +} + +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 ? ( + + ) : null} + {hasMore ? ( + + ) : 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'