diff --git a/ui/src/components/ui/__tests__/badge.test.tsx b/ui/src/components/ui/__tests__/badge.test.tsx new file mode 100644 index 0000000000..dc113b855e --- /dev/null +++ b/ui/src/components/ui/__tests__/badge.test.tsx @@ -0,0 +1,22 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Badge } from '../badge'; + +describe('Badge', () => { + it('has the default size', () => { + render(Default); + expect(screen.getByText('Default')).toHaveClass('h-5', 'px-1.5'); + }); + + it('has a small size for dense lists', () => { + render(Small); + const badge = screen.getByText('Small'); + expect(badge).toHaveClass('h-4', 'px-1', 'text-[10px]', 'leading-4'); + expect(badge).not.toHaveClass('h-5'); + expect(badge).not.toHaveClass('px-1.5'); + expect(badge).not.toHaveClass('text-[11px]'); + }); +}); diff --git a/ui/src/components/ui/__tests__/copy-button.test.tsx b/ui/src/components/ui/__tests__/copy-button.test.tsx new file mode 100644 index 0000000000..2278045228 --- /dev/null +++ b/ui/src/components/ui/__tests__/copy-button.test.tsx @@ -0,0 +1,83 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CopyButton } from '@/components/ui/copy-button'; + +const copyText = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/clipboard', () => ({ copyText })); + +async function click(name: string) { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name })); + }); +} + +describe('CopyButton', () => { + beforeEach(() => { + vi.useFakeTimers(); + copyText.mockReset(); + copyText.mockResolvedValue(true); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('copies, shows Check for 2 s, then returns', async () => { + render(); + const idle = screen.getByRole('button', { name: 'Copy id' }); + const idleIcon = idle.innerHTML; + const idleClass = idle.className; + + await click('Copy id'); + expect(copyText).toHaveBeenCalledWith('abc'); + + const copied = screen.getByRole('button', { name: 'Copied' }); + expect(copied.innerHTML).not.toBe(idleIcon); + expect(copied.querySelector('svg')).toHaveClass('text-success'); + expect(copied.className).toBe(idleClass); + + await act(async () => { + vi.advanceTimersByTime(1999); + }); + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + const back = screen.getByRole('button', { name: 'Copy id' }); + expect(back.innerHTML).toBe(idleIcon); + }); + + it('uses the copied label that the caller gives', async () => { + render(); + await click('Copy'); + expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); + }); + + it('reads an async text function when clicked', async () => { + const text = vi.fn(async () => 'later'); + render(); + expect(text).not.toHaveBeenCalled(); + await click('Copy'); + expect(copyText).toHaveBeenCalledWith('later'); + }); + + it('stays idle when the copy fails', async () => { + copyText.mockResolvedValue(false); + render(); + await click('Copy'); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + }); + + it('stays idle when the text function rejects', async () => { + const text = vi.fn(async (): Promise => { + throw new Error('no link'); + }); + render(); + await click('Copy'); + expect(copyText).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/ui/__tests__/count-labels.test.tsx b/ui/src/components/ui/__tests__/count-labels.test.tsx new file mode 100644 index 0000000000..2d727c594a --- /dev/null +++ b/ui/src/components/ui/__tests__/count-labels.test.tsx @@ -0,0 +1,25 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { CompactCount } from '../compact-count'; +import { RetryCount } from '../retry-count'; + +// A name on a plain span is not exposed reliably, so the counts are images +// with their whole meaning as the name. +describe('count labels', () => { + it('names the retry count for assistive technology', () => { + render(); + expect(screen.getByRole('img', { name: 'Retried 3×' })).toHaveTextContent( + '3' + ); + }); + + it('names the whole count of a compact count', () => { + render(); + expect(screen.getByRole('img', { name: '1,234,567' })).toHaveTextContent( + '1.2M' + ); + }); +}); diff --git a/ui/src/components/ui/__tests__/icon-button.test.tsx b/ui/src/components/ui/__tests__/icon-button.test.tsx new file mode 100644 index 0000000000..5a64bb367a --- /dev/null +++ b/ui/src/components/ui/__tests__/icon-button.test.tsx @@ -0,0 +1,154 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Play } from 'lucide-react'; +import { describe, expect, it, vi } from 'vitest'; +import { IconButton } from '@/components/ui/icon-button'; +import { Spinner } from '@/components/ui/spinner'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +function sizeClasses(el: HTMLElement): string[] { + return el.className.split(/\s+/).filter((c) => /^size-|^h-|^w-/.test(c)); +} + +describe('IconButton', () => { + it('keeps the same size classes when idle, busy and disabled', () => { + render( +
+ + + +
+ ); + const idle = sizeClasses(screen.getByRole('button', { name: 'Idle' })); + expect(idle).toContain('size-6'); + expect(sizeClasses(screen.getByRole('button', { name: 'Busy' }))).toEqual( + idle + ); + expect(sizeClasses(screen.getByRole('button', { name: 'Off' }))).toEqual( + idle + ); + }); + + it('uses 28 px for md and puts a spinner in the icon slot', () => { + render(); + const button = screen.getByRole('button', { name: 'Run' }); + expect(button).toHaveClass('size-7'); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button.querySelectorAll('svg')).toHaveLength(1); + expect(button.querySelector('svg')).toHaveClass('animate-spin'); + }); + + it('ignores clicks while busy', async () => { + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Run' })); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('sets aria-pressed and a pressed background', () => { + render( +
+ + + +
+ ); + const on = screen.getByRole('button', { name: 'On' }); + expect(on).toHaveAttribute('aria-pressed', 'true'); + expect(on).toHaveClass('bg-muted'); + expect(screen.getByRole('button', { name: 'Off' })).toHaveAttribute( + 'aria-pressed', + 'false' + ); + expect(screen.getByRole('button', { name: 'Plain' })).not.toHaveAttribute( + 'aria-pressed' + ); + }); + + it('shows the label, shortcut and description in the tooltip', async () => { + render( + + ); + await userEvent.hover(screen.getByRole('button', { name: 'Run step' })); + const tip = await screen.findByRole('tooltip'); + expect(tip).toHaveTextContent('Run step'); + expect(tip).toHaveTextContent('Ctrl+R'); + expect(tip).toHaveTextContent('Runs this step alone'); + }); + + it('shows the disabled reason in the tooltip', async () => { + const onClick = vi.fn(); + render( + + ); + const button = screen.getByRole('button', { name: 'Run step' }); + expect(button).toBeDisabled(); + // The wrapper takes the pointer event that the disabled button cannot. + await userEvent.hover(button.parentElement as HTMLElement); + const tip = await screen.findByRole('tooltip'); + expect(tip).toHaveTextContent('Save the workflow first'); + expect(onClick).not.toHaveBeenCalled(); + }); +}); + +// A second Radix tooltip that opens closes the first one and its content: +// the button of a tooltip keeps working, and the title says its name. +describe('IconButton in a tooltip', () => { + it('stays clickable and names itself with a title', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + + + + host + + + + + + + ); + await user.hover(screen.getByTestId('host')); + const tip = await screen.findByRole('tooltip'); + const button = within(tip).getByRole('button', { name: 'Copy it' }); + expect(button).toHaveAttribute('title', 'Copy it'); + + await user.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('tooltip')).toHaveLength(1); + }); +}); + +describe('Spinner', () => { + it('is hidden from assistive technology without a label', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + expect(svg).toHaveClass('size-3.5', 'animate-spin'); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('is a named status with a label', () => { + render(); + expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/ui/__tests__/kbd.test.tsx b/ui/src/components/ui/__tests__/kbd.test.tsx new file mode 100644 index 0000000000..148286f99e --- /dev/null +++ b/ui/src/components/ui/__tests__/kbd.test.tsx @@ -0,0 +1,15 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Kbd } from '@/components/ui/kbd'; + +describe('Kbd', () => { + it('renders a kbd element with the key text and the shared box', () => { + render(Ctrl/Cmd+C); + const key = screen.getByText('Ctrl/Cmd+C'); + expect(key.tagName).toBe('KBD'); + expect(key).toHaveClass('rounded', 'border', 'bg-muted', 'ml-4'); + }); +}); diff --git a/ui/src/components/ui/__tests__/repeat-summary.test.ts b/ui/src/components/ui/__tests__/repeat-summary.test.ts new file mode 100644 index 0000000000..d2287bb680 --- /dev/null +++ b/ui/src/components/ui/__tests__/repeat-summary.test.ts @@ -0,0 +1,154 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import { + readRepeatPolicy, + repeatIteration, + repeatSummary, + type RepeatPolicyView, +} from '../repeat-summary'; + +// The English catalog: the key is the text. +const ts = (source: string, values?: Record) => + source.replace(/\{(\w+)\}/g, (_, key: string) => String(values?.[key])); + +function summary(raw: unknown): string { + const policy = readRepeatPolicy(raw); + if (!policy) throw new Error('no policy'); + return repeatSummary(policy, ts); +} + +describe('readRepeatPolicy', () => { + it('reads the YAML form', () => { + expect( + readRepeatPolicy({ + repeat: 'until', + interval_sec: 30, + limit: 5, + condition: '${STATE}', + expected: 'ready', + exit_code: [0, 2], + }) + ).toEqual({ + mode: 'until', + intervalSec: 30, + limit: 5, + condition: '${STATE} = ready', + exitCodes: [0, 2], + }); + }); + + it('reads the API form', () => { + expect( + readRepeatPolicy({ + repeat: 'while', + interval: 10, + limit: 3, + condition: { condition: 'x', expected: 'y' }, + exitCode: [1], + backoff: 2, + }) + ).toEqual({ + mode: 'while', + intervalSec: 10, + limit: 3, + condition: 'x = y', + exitCodes: [1], + backoff: true, + }); + }); + + it('reads the boolean and unknown forms as while', () => { + expect(readRepeatPolicy({ repeat: true })?.mode).toBe('while'); + expect(readRepeatPolicy({ repeat: 'sometimes' })?.mode).toBe('while'); + }); + + it('infers the mode as the server does when repeat is absent', () => { + expect(readRepeatPolicy({ condition: 'a', expected: 'b' })?.mode).toBe( + 'until' + ); + expect(readRepeatPolicy({ condition: 'a' })?.mode).toBe('while'); + expect(readRepeatPolicy({ exit_code: [1] })?.mode).toBe('while'); + }); + + it('gives null for a step that does not repeat', () => { + expect(readRepeatPolicy(undefined)).toBeNull(); + expect(readRepeatPolicy('while')).toBeNull(); + expect(readRepeatPolicy({})).toBeNull(); + expect(readRepeatPolicy({ interval_sec: 5 })).toBeNull(); + }); + + it('keeps only fixed whole numbers for the interval and the limit', () => { + expect( + readRepeatPolicy({ repeat: 'while', interval_sec: '15', limit: '${N}' }) + ).toEqual({ mode: 'while', intervalSec: 15 }); + expect( + readRepeatPolicy({ repeat: 'while', interval_sec: 0, limit: -1 }) + ).toEqual({ mode: 'while' }); + }); +}); + +describe('repeatSummary', () => { + it('writes an unconditional while with an interval and no limit', () => { + expect(summary({ repeat: 'while', interval_sec: 10 })).toBe( + 'repeat while exit 0 · every 10s · no limit' + ); + }); + + it('writes until with a condition, an interval and a limit', () => { + expect( + summary({ + repeat: 'until', + interval_sec: 30, + limit: 5, + condition: 'ready', + }) + ).toBe('repeat until "ready" · every 30s · max 5'); + }); + + it('writes the exit codes', () => { + expect(summary({ repeat: 'while', exit_code: [1, 2] })).toBe( + 'repeat while exit 1, 2 · no limit' + ); + }); + + it('writes no interval when the policy has none', () => { + expect(summary({ repeat: 'until', limit: 2 })).toBe( + 'repeat until exit 0 · max 2' + ); + }); + + it('reads the boolean form as while', () => { + expect(summary({ repeat: true, interval_sec: 120 })).toBe( + 'repeat while exit 0 · every 2m · no limit' + ); + }); + + it('cuts a long condition to 24 characters', () => { + const text = summary({ + repeat: 'while', + condition: 'abcdefghijklmnopqrstuvwxyz0123456789', + }); + expect(text).toContain('"abcdefghijklmnopqrstuvw…"'); + }); +}); + +describe('repeatIteration', () => { + const limited: RepeatPolicyView = { mode: 'while', limit: 5 }; + const open: RepeatPolicyView = { mode: 'while' }; + + it('counts the running iteration after the finished ones', () => { + expect(repeatIteration(limited, 2, true, ts)).toBe('iteration 3 of 5'); + expect(repeatIteration(open, 2, true, ts)).toBe('iteration 3'); + }); + + it('counts the finished ones for a step that stopped', () => { + expect(repeatIteration(limited, 3, false, ts)).toBe('iteration 3 of 5'); + }); + + it('gives nothing before the first run', () => { + expect(repeatIteration(limited, 0, false, ts)).toBeNull(); + expect(repeatIteration(limited, 0, true, ts)).toBe('iteration 1 of 5'); + }); +}); diff --git a/ui/src/components/ui/__tests__/status-chip.test.tsx b/ui/src/components/ui/__tests__/status-chip.test.tsx index 15237f6429..0ae40bd088 100644 --- a/ui/src/components/ui/__tests__/status-chip.test.tsx +++ b/ui/src/components/ui/__tests__/status-chip.test.tsx @@ -27,4 +27,17 @@ describe('StatusChip', () => { expect(screen.getByText('exit 3')).toBeVisible(); }); + + it('reads Healthy in the success token for a healthy run', () => { + render( + + {StatusLabel.running} + + ); + + const chip = screen.getByText('Healthy').closest('[data-status]'); + expect(chip).toHaveAttribute('data-status', String(Status.Running)); + expect(chip).toHaveClass('status-success'); + expect(screen.queryByText('Running')).toBeNull(); + }); }); diff --git a/ui/src/components/ui/__tests__/status-icon.test.tsx b/ui/src/components/ui/__tests__/status-icon.test.tsx new file mode 100644 index 0000000000..41b8fe585d --- /dev/null +++ b/ui/src/components/ui/__tests__/status-icon.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Status } from '@/api/v1/schema'; +import StatusIcon from '../status-icon'; + +describe('StatusIcon', () => { + it.each([ + [Status.NotStarted, 'Not started'], + [Status.Running, 'Running'], + [Status.Failed, 'Failed'], + [Status.Aborted, 'Aborted'], + [Status.Success, 'Succeeded'], + [Status.Queued, 'Queued'], + [Status.PartialSuccess, 'Partially succeeded'], + [Status.Waiting, 'Waiting'], + [Status.Rejected, 'Rejected'], + ])('names status %i with its text', (status, text) => { + render(); + + const icon = screen.getByRole('img', { name: text }); + expect(icon).toHaveAttribute('title', text); + expect(icon).not.toHaveTextContent(/\S/); + }); + + it('spins only while the run is running', () => { + const { container, rerender } = render( + + ); + expect(container.querySelector('svg')).toHaveClass('animate-spin'); + + rerender(); + expect(container.querySelector('svg')).not.toHaveClass('animate-spin'); + }); + + it('renders nothing without a status', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('is a calm heart named Healthy for a run that never ends', () => { + const { container } = render( + + ); + const icon = screen.getByRole('img', { name: 'Healthy' }); + expect(icon).toHaveAttribute('title', 'Healthy'); + expect(icon).toHaveAttribute('data-status', String(Status.Running)); + expect(icon.style.color).toBe('var(--status-success)'); + const svg = container.querySelector('svg'); + expect(svg).toHaveClass('motion-safe:animate-pulse'); + expect(svg).not.toHaveClass('animate-spin'); + }); + + it('gives no native title when a tooltip of the parent speaks', () => { + render(); + expect(screen.getByRole('img', { name: 'Failed' })).not.toHaveAttribute( + 'title' + ); + }); +}); diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index cb48e9aff7..2080794c57 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -27,9 +27,15 @@ const badgeVariants = cva( 'bg-destructive/10 text-[color-mix(in_srgb,var(--destructive)_85%,#000)] border-transparent dark:bg-destructive/15 dark:text-[color-mix(in_srgb,var(--destructive)_90%,#fff)]', cancelled: 'bg-muted text-muted-foreground border-transparent', }, + size: { + default: '', + // Smaller chip for dense lists. It follows the base classes. + sm: 'h-4 px-1 text-[10px] leading-4', + }, }, defaultVariants: { variant: 'default', + size: 'default', }, } ); @@ -37,6 +43,7 @@ const badgeVariants = cva( function Badge({ className, variant, + size, asChild = false, ...props }: React.ComponentProps<'span'> & @@ -46,7 +53,7 @@ function Badge({ return ( ); diff --git a/ui/src/components/ui/compact-count.tsx b/ui/src/components/ui/compact-count.tsx new file mode 100644 index 0000000000..1abd6a176b --- /dev/null +++ b/ui/src/components/ui/compact-count.tsx @@ -0,0 +1,35 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { ReactElement } from 'react'; +import { useI18n } from '@/i18n/I18nProvider'; +import { formatCompactCount, formatExactCount } from '@/lib/formatCompactCount'; + +type Props = { + /** The count of executions. */ + value: number; + className?: string; +}; + +/** + * A count of executions in its compact form (`1.2k`), with the whole count, + * in the separators of the language, as its title and accessible name. Every + * place that prints a count of executions without the repeat badge uses it. + */ +export function CompactCount({ value, className }: Props): ReactElement { + const { locale } = useI18n(); + const exact = formatExactCount(value, locale); + return ( + + {formatCompactCount(value)} + + ); +} + +export default CompactCount; diff --git a/ui/src/components/ui/copy-button.tsx b/ui/src/components/ui/copy-button.tsx new file mode 100644 index 0000000000..dab9a772f9 --- /dev/null +++ b/ui/src/components/ui/copy-button.tsx @@ -0,0 +1,61 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { Check, Copy, type LucideIcon } from 'lucide-react'; + +import { IconButton, type IconButtonProps } from '@/components/ui/icon-button'; +import { useCopyFeedback } from '@/hooks/useCopyFeedback'; +import { useI18n } from '@/i18n/I18nProvider'; + +export interface CopyButtonProps + extends Omit< + IconButtonProps, + 'icon' | 'label' | 'onClick' | 'pressed' | 'iconClassName' + > { + /** The text, or a function that returns it when the user clicks. */ + text: string | (() => string | Promise); + label: string; + /** Name shown for 2000 ms after the copy. Defaults to "Copied". */ + copiedLabel?: string; + /** Defaults to Copy. */ + icon?: LucideIcon; + /** Called once each time the text reached the clipboard. */ + onCopied?: () => void; +} + +/** + * An icon-only copy action. On success the icon becomes a Check in the + * success colour, and the name becomes the copied label, for 2000 ms. + */ +export function CopyButton({ + text, + label, + copiedLabel, + icon = Copy, + onCopied, + ...props +}: CopyButtonProps) { + const { ts } = useI18n(); + const { copied, copy } = useCopyFeedback(2000, onCopied); + + const onClick = async () => { + let value: string; + try { + value = typeof text === 'function' ? await text() : text; + } catch { + // A text that cannot be read is a failed copy: the button stays idle. + return; + } + await copy(value); + }; + + return ( + void onClick()} + /> + ); +} diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx index 98812efc74..2a9cdcc931 100644 --- a/ui/src/components/ui/dialog.tsx +++ b/ui/src/components/ui/dialog.tsx @@ -15,6 +15,33 @@ const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; +/** + * The layer of a dialog. 'default' is z-50. 'raised' is z-[80], the Z.dialog + * layer of the workspace, above the node view (z-[60]) and the menus. + */ +type DialogLayer = 'default' | 'raised'; + +const RAISED_LAYER = 'z-[80]'; + +const DialogLayerContext = React.createContext('default'); + +/** Every dialog below this provider takes the layer, unless it sets its own. */ +const DialogLayerProvider = DialogLayerContext.Provider; + +/** + * The z-index class for a popup (select, menu, tooltip) that renders in a + * portal. Inside a raised dialog or workspace it must sit above the dialog + * overlay; elsewhere it keeps its own layer. + */ +function useRaisedLayerClass(): string | undefined { + return useIsRaisedLayer() ? RAISED_LAYER : undefined; +} + +/** True below a raised layer provider, for drawers that sit under dialogs. */ +function useIsRaisedLayer(): boolean { + return React.useContext(DialogLayerContext) === 'raised'; +} + const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -35,34 +62,52 @@ const DialogContent = React.forwardRef< React.ComponentPropsWithoutRef & { hideCloseButton?: boolean; fullscreen?: boolean; + /** Defaults to the layer of the nearest DialogLayerProvider. */ + layer?: DialogLayer; } >( ( - { className, children, hideCloseButton, fullscreen = false, ...props }, + { + className, + children, + hideCloseButton, + fullscreen = false, + layer, + ...props + }, ref - ) => ( - - - - {children} - {!hideCloseButton && ( - - - - - )} - - - ) + ) => { + const inherited = React.useContext(DialogLayerContext); + const raised = (layer ?? inherited) === 'raised'; + return ( + + + + + {children} + + {!hideCloseButton && ( + + + + + + + )} + + + ); + } ); DialogContent.displayName = DialogPrimitive.Content.displayName; @@ -118,6 +163,7 @@ const DialogDescription = React.forwardRef< )); DialogDescription.displayName = DialogPrimitive.Description.displayName; +export type { DialogLayer }; export { Dialog, DialogClose, @@ -125,8 +171,11 @@ export { DialogDescription, DialogFooter, DialogHeader, + DialogLayerProvider, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, + useIsRaisedLayer, + useRaisedLayerClass, }; diff --git a/ui/src/components/ui/icon-button.tsx b/ui/src/components/ui/icon-button.tsx new file mode 100644 index 0000000000..e2e016dbdd --- /dev/null +++ b/ui/src/components/ui/icon-button.tsx @@ -0,0 +1,129 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { LucideIcon } from 'lucide-react'; +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Kbd } from '@/components/ui/kbd'; +import { Spinner } from '@/components/ui/spinner'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + useInsideTooltip, +} from '@/components/ui/tooltip'; +import { cn, FOCUS_RING } from '@/lib/utils'; + +export interface IconButtonProps + extends Omit, 'children' | 'aria-label'> { + /** The name of the action: its accessible name and the tooltip title. */ + label: string; + icon: LucideIcon; + /** sm is 24 px (node toolbars and rows); md is 28 px (elsewhere). */ + size?: 'sm' | 'md'; + tone?: 'default' | 'destructive'; + /** The button is a toggle: set aria-pressed and a pressed background. */ + pressed?: boolean; + /** Show a spinner in the icon slot and ignore clicks. The box is unchanged. */ + busy?: boolean; + /** Disables the button and shows this text in the tooltip. */ + disabledReason?: string; + /** The key text that the action has, for example from canvasShortcut(). */ + shortcut?: string; + /** A second tooltip line. */ + description?: string; + /** Extra classes for the icon, for example a status colour. */ + iconClassName?: string; + tooltipSide?: React.ComponentProps['side']; +} + +const SIZE_CLASS = { + sm: 'size-6 [&_svg]:size-3.5', + md: 'size-7 [&_svg]:size-4', +} as const; + +/** + * An icon-only action. The name is in the tooltip and in aria-label, also + * while the button is disabled or busy. + */ +export function IconButton({ + label, + icon: Icon, + size = 'md', + tone = 'default', + pressed, + busy = false, + disabledReason, + shortcut, + description, + iconClassName, + tooltipSide = 'bottom', + disabled, + className, + onClick, + type = 'button', + ...buttonProps +}: IconButtonProps) { + const insideTooltip = useInsideTooltip(); + const isDisabled = Boolean(disabled || disabledReason); + const button = ( + + ); + + // A tooltip in a tooltip would close the outer one: the title says the name. + if (insideTooltip) return button; + + return ( + + + {/* A disabled button takes no pointer or focus event: its wrapper does. */} + {isDisabled ? ( + + {button} + + ) : ( + button + )} + + +
+ + {label} + {shortcut && {shortcut}} + + {description && ( + {description} + )} + {disabledReason && ( + {disabledReason} + )} +
+
+
+ ); +} diff --git a/ui/src/components/ui/kbd.tsx b/ui/src/components/ui/kbd.tsx new file mode 100644 index 0000000000..129bbeb1cc --- /dev/null +++ b/ui/src/components/ui/kbd.tsx @@ -0,0 +1,24 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { ComponentProps, ReactElement } from 'react'; +import { cn } from '@/lib/utils'; + +/** + * The text of a key, in a small box. Menus and the key help use this one + * look for every key hint. + */ +export function Kbd({ + className, + ...props +}: ComponentProps<'kbd'>): ReactElement { + return ( + + ); +} diff --git a/ui/src/components/ui/matrix-text.tsx b/ui/src/components/ui/matrix-text.tsx index e614ef7294..1382a0f9a4 100644 --- a/ui/src/components/ui/matrix-text.tsx +++ b/ui/src/components/ui/matrix-text.tsx @@ -1,5 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React from 'react'; +import { useClock } from '@/hooks/useClock'; +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { cn } from '@/lib/utils'; type MatrixTextProps = { @@ -9,37 +11,17 @@ type MatrixTextProps = { }; function MatrixText({ text, className, glowSpeed = 175 }: MatrixTextProps) { - const [glowPosition, setGlowPosition] = useState(0); - const intervalRef = useRef(null); - const prefersReducedMotion = useRef(false); + const reducedMotion = usePrefersReducedMotion(); const textChars = text.split(''); - - useEffect(() => { - const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); - prefersReducedMotion.current = mediaQuery.matches; - - const handler = (event: MediaQueryListEvent) => { - prefersReducedMotion.current = event.matches; - }; - mediaQuery.addEventListener('change', handler); - return () => mediaQuery.removeEventListener('change', handler); - }, []); - - useEffect(() => { - if (prefersReducedMotion.current || textChars.length === 0) { - return; - } - - intervalRef.current = setInterval(() => { - setGlowPosition((prev) => (prev + 1) % textChars.length); - }, glowSpeed); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - }; - }, [glowSpeed, textChars.length]); + // The glow reads the shared clock: all chips glow in step, on one timer, + // and none runs while the tab is hidden. + const glowPosition = useClock( + reducedMotion || textChars.length === 0 ? null : glowSpeed, + (now) => + reducedMotion || textChars.length === 0 + ? 0 + : Math.floor(now / glowSpeed) % textChars.length + ); return ( void | Promise; className?: string; disabled?: boolean; + /** The caller is loading the data: show the spinner and ignore clicks. */ + isLoading?: boolean; + /** A tooltip that explains what the button reloads; the name stays Refresh. */ + description?: string; } export const RefreshButton: React.FC = ({ onRefresh, className, disabled = false, + isLoading = false, + description, }) => { const { t } = useI18n(); const [isRefreshing, setIsRefreshing] = useState(false); + const busy = isRefreshing || isLoading; const handleRefresh = async () => { setIsRefreshing(true); @@ -32,11 +39,17 @@ export const RefreshButton: React.FC = ({ ); }; diff --git a/ui/src/components/ui/relative-time.tsx b/ui/src/components/ui/relative-time.tsx index 824b422208..613917fc8a 100644 --- a/ui/src/components/ui/relative-time.tsx +++ b/ui/src/components/ui/relative-time.tsx @@ -2,8 +2,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later import React from 'react'; +import { formatAbsoluteTime } from '@/lib/dagRunTiming'; import dayjs from '@/lib/dayjs'; +import { useClock } from '@/hooks/useClock'; import { useI18n } from '@/i18n/I18nProvider'; +import { cn } from '@/lib/utils'; type Props = { /** Timestamp string parsable by dayjs; '-' and empty are treated as absent. */ @@ -12,18 +15,53 @@ type Props = { fallback?: string; /** Single-unit form ("5s", "3m", "2h", "4d") instead of "3 minutes ago". */ compact?: boolean; + /** + * With `compact`, a lapse of 5 s or more reads "3m ago" instead of "3m", and + * rounds to the nearest unit as the long form does, so that both agree. + */ + ago?: boolean; + /** + * With `compact`, keeps the width of the widest text of the unit that shows + * now ("99m ago"), so that a text that ticks moves nothing next to it. + */ + stable?: boolean; /** Tooltip text; defaults to the parsed timestamp formatted as an absolute time. */ absolute?: string | null; className?: string; }; -function formatCompact(diffSeconds: number, now: string): string { +interface CompactUnits { + s: string; + m: string; + h: string; + d: string; +} + +// ja and zh-CN write the unit as a word ("5時間前"), not as a Latin letter. +const EN_UNITS: CompactUnits = { s: 's', m: 'm', h: 'h', d: 'd' }; +const COMPACT_UNITS: Record = { + 'zh-CN': { s: '秒', m: '分钟', h: '小时', d: '天' }, + ja: { s: '秒', m: '分', h: '時間', d: '日' }, +}; + +function formatCompact( + diffSeconds: number, + now: string, + round: (value: number) => number, + units: CompactUnits +): string { const seconds = Math.abs(diffSeconds); if (seconds < 5) return now; - if (seconds < 60) return `${Math.floor(seconds)}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; - return `${Math.floor(seconds / 86400)}d`; + if (seconds < 60) return `${Math.floor(seconds)}${units.s}`; + // Rounding up must not give "60m" or "24h": the next unit takes over. + if (round(seconds / 60) < 60) return `${round(seconds / 60)}${units.m}`; + if (round(seconds / 3600) < 24) return `${round(seconds / 3600)}${units.h}`; + return `${round(seconds / 86400)}${units.d}`; +} + +// dayjs zh-cn writes "5 小时前"; Chinese puts no space between number and unit. +function longForm(text: string): string { + return text.replace(/(\d)\s+(?=[\u4e00-\u9fff])/g, '$1'); } /** @@ -34,35 +72,57 @@ export function RelativeTime({ timestamp, fallback = '-', compact = false, + ago = false, + stable = false, absolute, className, }: Props) { - const { locale, t } = useI18n(); + const { locale, t, ts } = useI18n(); const parsed = timestamp && timestamp !== '-' ? dayjs(timestamp) : null; const isValid = !!parsed && parsed.isValid(); - const [, setTick] = React.useState(0); - - React.useEffect(() => { - if (!isValid) return; - const interval = setInterval( - () => setTick((tick) => tick + 1), - compact ? 1000 : 15000 - ); - return () => clearInterval(interval); - }, [isValid, compact]); + const now = t('time.now'); + const units = COMPACT_UNITS[locale] ?? EN_UNITS; + // The text that reads the clock: the component renders only when it changes. + const text = useClock(isValid ? 1000 : null, (nowMs) => { + if (!parsed || !isValid) return ''; + return compact + ? formatCompact( + dayjs(nowMs).diff(parsed, 'second'), + now, + ago ? Math.round : Math.floor, + units + ) + : longForm( + parsed + .locale(locale === 'zh-CN' ? 'zh-cn' : locale) + .from(dayjs(nowMs)) + ); + }); if (!parsed || !isValid) { return {fallback}; } - const label = compact - ? formatCompact(dayjs().diff(parsed, 'second'), t('time.now')) - : parsed.locale(locale === 'zh-CN' ? 'zh-cn' : locale).fromNow(); + const lapse = compact ? text : undefined; + const label = lapse + ? ago && lapse !== now + ? ts('{time} ago', { time: lapse }) + : lapse + : text; + + // The widest text of the same unit has two digits: "99m", "99m ago". + const widest = + stable && lapse && lapse !== now + ? ago + ? ts('{time} ago', { time: lapse.replace(/\d+/, '99') }) + : lapse.replace(/\d+/, '99') + : null; return ( {label} diff --git a/ui/src/components/ui/repeat-summary.ts b/ui/src/components/ui/repeat-summary.ts new file mode 100644 index 0000000000..aed94e6b79 --- /dev/null +++ b/ui/src/components/ui/repeat-summary.ts @@ -0,0 +1,188 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { formatDurationMs } from '@/lib/formatDuration'; + +/** The translator of static messages, as useI18n gives it. */ +export type Translate = ( + source: string, + values?: Record +) => string; + +/** The repeat policy of a step, in one shape for the YAML and for the API. */ +export interface RepeatPolicyView { + mode: 'while' | 'until'; + /** Seconds between two runs, when the policy sets a fixed number. */ + intervalSec?: number; + /** The maximum number of runs, when the policy sets a fixed number. */ + limit?: number; + /** The condition, with ` = expected` appended when the policy has one. */ + condition?: string; + exitCodes?: number[]; + /** True when the interval grows after each run. */ + backoff?: boolean; +} + +/** The longest condition that a summary shows; the title keeps all of it. */ +export const REPEAT_CONDITION_MAX = 24; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** A positive whole number, written as a number or as digits; else undefined. */ +function count(value: unknown): number | undefined { + const n = + typeof value === 'string' && /^\s*\d+\s*$/.test(value) + ? Number(value) + : value; + return typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : undefined; +} + +function text(value: unknown): string { + return typeof value === 'string' || typeof value === 'number' + ? String(value).trim() + : ''; +} + +function conditionOf( + condition: unknown, + expected: unknown +): string | undefined { + // The API gives {condition, expected}; the YAML gives two strings. + const source = isRecord(condition) ? condition.condition : condition; + const wanted = isRecord(condition) ? condition.expected : expected; + const base = text(source); + if (!base) return undefined; + const want = text(wanted); + return want ? `${base} = ${want}` : base; +} + +function exitCodesOf(value: unknown): number[] | undefined { + if (!Array.isArray(value)) return undefined; + const codes = value.filter( + (code): code is number => typeof code === 'number' && Number.isInteger(code) + ); + return codes.length > 0 ? codes : undefined; +} + +/** + * Reads the repeat policy of a step: the `repeat_policy` block of the YAML + * (`interval_sec`, `exit_code`) or the `repeatPolicy` of the API (`interval`, + * `exitCode`). `repeat: until` is until; every other value of `repeat`, such + * as the boolean of old files, is while. Without `repeat`, the server infers + * the mode, and so does this function. Returns null when the step does not + * repeat. + */ +export function readRepeatPolicy(raw: unknown): RepeatPolicyView | null { + if (!isRecord(raw)) return null; + const condition = conditionOf(raw.condition, raw.expected); + const exitCodes = exitCodesOf(raw.exit_code ?? raw.exitCode); + let mode: RepeatPolicyView['mode'] | null = null; + const repeat = raw.repeat; + if (repeat === 'until') { + mode = 'until'; + } else if (repeat === 'while' || repeat === true) { + mode = 'while'; + } else if (repeat !== undefined && repeat !== null && repeat !== false) { + // An unknown form reads as while. + mode = 'while'; + } else if ( + condition !== undefined && + text(isRecord(raw.condition) ? raw.condition.expected : raw.expected) + ) { + mode = 'until'; + } else if (condition !== undefined || exitCodes !== undefined) { + mode = 'while'; + } + if (mode === null) return null; + const view: RepeatPolicyView = { mode }; + const intervalSec = count( + raw.interval_sec ?? raw.intervalSec ?? raw.interval + ); + if (intervalSec !== undefined) view.intervalSec = intervalSec; + const limit = count(raw.limit); + if (limit !== undefined) view.limit = limit; + if (condition !== undefined) view.condition = condition; + if (exitCodes !== undefined) view.exitCodes = exitCodes; + if (raw.backoff !== undefined && raw.backoff !== false && raw.backoff !== 0) { + view.backoff = true; + } + return view; +} + +/** The condition cut to REPEAT_CONDITION_MAX characters, with an ellipsis. */ +export function shortCondition(condition: string): string { + return condition.length > REPEAT_CONDITION_MAX + ? `${condition.slice(0, REPEAT_CONDITION_MAX - 1)}…` + : condition; +} + +/** "10s", "2m": the interval as the other durations of the UI read. */ +export function repeatInterval(seconds: number): string { + return formatDurationMs(seconds * 1000); +} + +/** + * The text of the repeat rule: what the step waits for, as in + * `repeat while exit 0` or `repeat until "ready = yes"`. A policy with + * neither condition nor exit codes repeats while, or until, the step exits 0. + */ +export function repeatRule(policy: RepeatPolicyView, ts: Translate): string { + const until = policy.mode === 'until'; + if (policy.condition !== undefined) { + return ts( + until ? 'repeat until "{condition}"' : 'repeat while "{condition}"', + { + condition: shortCondition(policy.condition), + } + ); + } + return ts(until ? 'repeat until exit {codes}' : 'repeat while exit {codes}', { + codes: (policy.exitCodes ?? [0]).join(', '), + }); +} + +/** + * The one line that describes a repeat policy, for the card tooltip, the run + * list and the step facts: `repeat while exit 0 · every 10s · no limit` or + * `repeat until "ready = yes" · every 30s · max 5`. + */ +export function repeatSummary(policy: RepeatPolicyView, ts: Translate): string { + const parts = [repeatRule(policy, ts)]; + if (policy.intervalSec !== undefined) { + parts.push( + ts('every {interval}', { interval: repeatInterval(policy.intervalSec) }) + ); + } + parts.push( + policy.limit !== undefined + ? ts('max {limit}', { limit: policy.limit }) + : ts('no limit') + ); + return parts.join(' · '); +} + +/** + * The iteration of a step in a run: `iteration 3 of 5` with a limit, + * `iteration 3` without. `done` is the number of finished runs. A step that + * runs is in the next iteration. Returns null before the first run. `format` + * writes the number: the badge gives the whole count with the separators of + * the language. + */ +export function repeatIteration( + policy: RepeatPolicyView, + done: number, + running: boolean, + ts: Translate, + format: (n: number) => string = String +): string | null { + const iteration = running ? done + 1 : done; + if (iteration <= 0) return null; + return policy.limit !== undefined + ? ts('iteration {n} of {limit}', { + n: format(iteration), + limit: policy.limit, + }) + : ts('iteration {n}', { n: format(iteration) }); +} diff --git a/ui/src/components/ui/retry-count.tsx b/ui/src/components/ui/retry-count.tsx new file mode 100644 index 0000000000..a31a452677 --- /dev/null +++ b/ui/src/components/ui/retry-count.tsx @@ -0,0 +1,32 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { RotateCcw } from 'lucide-react'; +import type { ReactElement } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { useI18n } from '@/i18n/I18nProvider'; + +/** + * How many times a step was retried: a small warning badge with the count. + * Every surface that shows a retry count renders this one badge. + */ +export function RetryCount({ count }: { count: number }): ReactElement { + const { ts } = useI18n(); + const label = ts('Retried {count}×', { count }); + return ( + + + ); +} + +export default RetryCount; diff --git a/ui/src/components/ui/spinner.tsx b/ui/src/components/ui/spinner.tsx new file mode 100644 index 0000000000..829965610d --- /dev/null +++ b/ui/src/components/ui/spinner.tsx @@ -0,0 +1,31 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { Loader2 } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +export interface SpinnerProps { + className?: string; + /** Accessible name. Without it the spinner is decorative and hidden. */ + label?: string; +} + +/** + * The one busy indicator. Put it in the slot of the icon that it replaces, so + * the control keeps its size. + */ +export function Spinner({ className, label }: SpinnerProps) { + const icon = ( +