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