pre app check

This commit is contained in:
Pavel committed 2026-09-24 21:05:39 +04:00
1 parent 8a9f3a75d6
commit d5f1e47593
217 files changed
+13133 -3644

No files matched your search

+502
View File
@@ -0,0 +1,502 @@
# DocsGPT frontend design system
The UI is Tailwind v4 plus the shadcn-style components in
`src/components/ui/`. `@shadcn/lint` enforces the rules below through
`npm run lint`; its messages point here. Everything in this file is either a
token in `src/index.css` or a variant in a `ui/` component, so an agent or a
contributor can always find the concrete thing to use.
## Rules in one paragraph
Pages compose `ui/` components and choose their look through props
(`variant`, `size`, `shape`). `className` on a component is for placement
only: margin, width, flex and grid, positioning. Colours come from the theme
tokens, never from the raw Tailwind palette. Spacing, type and radii come
from the Tailwind scale, never from arbitrary `[...]` values. Runtime values
go in CSS custom properties or, when unavoidable, an inline style with a
disable comment.
## Colour tokens
Defined in `src/index.css` (`:root` for light, `.dark` for dark) and exposed
through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and
`stroke-` all accept them, including opacity modifiers such as
`bg-success/10`. One token class replaces a light + dark pair.
| Token | Use for | Replaces |
| --------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------- |
| `background`, `foreground` | page surface and primary text | `bg-white`, `text-gray-700/800/900`, `dark:text-gray-200/300` |
| `card`, `card-foreground` | raised surfaces (cards, dialogs, dropdowns) | `bg-white dark:bg-[#2b2c31]` |
| `popover`, `popover-foreground` | floating menus and popovers | same as card |
| `muted`, `muted-foreground` | quiet fills and secondary text, icons, placeholders, timestamps | `bg-gray-100/200`, `text-gray-400/500/600`, `dark:text-gray-400` |
| `accent`, `accent-foreground` | hover and selected fills | `hover:bg-gray-100`, `dark:hover:bg-gray-700` |
| `border`, `input`, `ring` | dividers, field borders, focus rings | `border-gray-200/300`, `dark:border-gray-600/700` |
| `primary`, `primary-foreground` | brand purple: primary actions, links, selected states | `bg-purple-*`, `text-purple-*`, `#7D54D1`, `#A076F6` |
| `secondary`, `secondary-foreground` | pressed and active toggles (a brand tint), secondary buttons, the question bubble | `bg-accent` on an active icon button |
| `destructive`, `destructive-foreground` | errors, failed states, dangerous actions | `text-red-*`, `bg-red-50/100`, `#B42318`, `#E60000` |
| `success`, `success-foreground` | completed, active, healthy | `text-green-*`, `text-emerald-*`, `bg-green-50/100` |
| `warning`, `warning-foreground` | paused, pending review, degraded | `text-amber-*`, `text-yellow-*`, `text-orange-*`, `bg-amber-50/100` |
| `info`, `info-foreground` | running, informational | `text-blue-*`, `bg-blue-50/100` |
| `sidebar-*` | the navigation rail | |
| `chart-1` to `chart-5` | data series only, never UI chrome (see below) | |
| `answer-bubble` | assistant message background | |
Status colours are tuned to stay vivid rather than turning brown or olive,
so their contrast is low. Against white in light mode, `destructive` and
`success` are about 3.8:1, `warning` is 2.9:1 and `info` is 5.2:1. On the
dark card, `destructive` is 2.9:1 and the others are 5.5:1 or more. Do not
use them for long body text; they are for badges, icons, short labels and
fills.
Chart colours are not a separate palette. `chart-1` to `chart-5` alias
`primary`, `info`, `success`, `warning` and `destructive`, in that order,
so the charts use the same colours as the rest of the app in both themes.
A single series uses `chart-1`, the brand colour. When a series has a
meaning, use the token for that meaning, not the next one in order:
failed tool calls use `destructive`, succeeded ones `success`, pending
ones `warning`. Series with no meaning (models, agents, sources) take
`chart-1` to `chart-5` in order. When there are more than five, the four
largest keep `chart-1` to `chart-4` and the rest are summed into one
"Other" series in `chart-5`, so no colour repeats (`settings/foldSeries.ts`).
`secondary` is never a chart colour.
Patterns:
- Status pill: `<Badge variant="success">`. Status box: `<Alert variant="warning">`.
Reach for `bg-success/10 text-success` directly only on dots and borders.
- Solid status fill: `bg-warning text-warning-foreground`.
- Guardrail outcomes: block `destructive`, flag `warning`, redact `info`,
not evaluated `neutral`.
- Status border: `border-destructive/50`.
- De-emphasised text: `text-muted-foreground`; go lighter with an opacity
modifier (`text-muted-foreground/70`) rather than a lighter grey.
- Text on the brand colour is `text-primary-foreground`, not `text-white`.
- `secondary` is a brand tint, not a grey: primary at 10% (light) or 15%
(dark) over whatever sits behind it, with `primary` text in light and a
lighter purple (#b89cf8) in dark. So a pressed toggle (CopyButton's copied
state, text-to-speech while speaking) shows on card, background and muted
alike, and never reads as the neutral ghost hover. Use it as
`variant={active ? 'secondary' : 'ghost-muted'}`.
- The user's question bubble is `bg-secondary text-foreground`: the brand
tint as the fill, with body text in `foreground` (15.7:1 light, 12.4:1
dark) rather than `secondary-foreground`, which is only 4.5:1 in light.
Controls on it (the collapse chevron) are plain `ghost` with a lucide icon
in `currentColor`.
- `white`, `black`, `transparent`, `current` and `inherit` are allowed; use
them only for overlays and imagery, not for text or surfaces.
## Components and their variants
### Button (`ui/button.tsx`)
| Prop | Values |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `variant` | `default`, `secondary`, `outline`, `outline-primary`, `ghost`, `ghost-muted`, `ghost-destructive`, `link`, `destructive`, `destructive-outline`, `combobox`, `sidebar-item`, `tab`, `section-toggle` |
| `size` | `xs`, `sm`, `default`, `lg`, `field`, `icon-xs`, `icon-sm`, `icon`, `icon-lg`, `inline` |
| `shape` | `default` (rounded-md), `pill` (rounded-full, wider padding) |
- Round brand buttons (`rounded-3xl px-5`, `rounded-full px-6`): `shape="pill"`,
plus `size="lg"` if they were `px-6`.
- Muted icon buttons (`text-muted-foreground hover:text-foreground`):
`variant="ghost-muted"`.
- Icon actions that remove something (a trash can on a member or shared
resource row) are `variant="ghost-destructive"`: muted at rest, red on
hover, so the warning shows even where the click deletes without asking.
- Bordered purple buttons (`border-primary text-primary hover:bg-primary`):
`variant="outline-primary"`.
- Tiny inline actions (`h-auto px-2 py-1 text-xs`): `size="xs"`.
- A link inside running text (an artifact link in an answer) is
`variant="link" size="inline"`: no height or padding, so it wraps with the
sentence. Standalone links (toggles, crumbs) keep a normal size.
- The composer controls under the chat field (Attach, Voice, Tools,
Sources) are `size="sm" shape="pill"`. Their icons are `size-3.5 sm:size-4`
with no margin; the size's `gap-1.5` spaces them, and the label span keeps
`text-xs sm:text-sm` so the row still fits on a phone.
- Popover comboboxes (`role="combobox"` + `Command`) use `variant="combobox"`,
which matches `SelectTrigger`: card fill, normal weight, and muted text
while `data-placeholder` is set (`data-placeholder={value ? undefined : ''}`).
Pass only layout (`w-full justify-between`) and keep the chevron as the
last child.
- A button or picker that sits in a row of fields is `size="field"`: 42px,
the height of `Input` (default) and `SelectTrigger size="lg"`, so a form
column has one height. With `shape="pill"` its text starts 21px in, like
the Input and Select pills beside it. The agent form's pickers are
`combobox field pill`, its Add button `outline-primary field pill`.
- Rows in the navigation sidebar (`hover:bg-sidebar-accent … pl-3 gap-2.5
rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal
weight, `bg-sidebar-accent` on hover and while `aria-current="page"`. Use it
with `asChild` around a `<Link>` for navigation rows.
- Inline disclosure toggles ("Advanced settings", "Show advanced options")
are `variant="link" size="sm"` with only `-ml-3 w-fit justify-start`;
a chevron, when the toggle has one, is a lucide `ChevronRight` (so it
takes the link colour) as the first child.
- Underline tabs (FilePicker's My Files / Shared with Me, the agent page
sub-nav) are `variant="tab"`: muted text on a transparent 2px bottom border,
square corners, no hover fill. Mark the current tab with `data-active`, which
gives it `foreground` text and a `primary` underline. Padding-free tabs (a
sub-nav with `gap-6`) add `size="inline"`, which keeps 4px above the line;
the row draws the 1px baseline, and `-mb-px` lays the underline over it.
Tabs that switch a panel in place use `ui/tabs` instead.
- A section panel's disclosure header (NewAgent's Advanced and Guardrails
panels) is `variant="section-toggle" size="sm"` with `-ml-3 w-fit
justify-start` and `aria-expanded`: a lucide `ChevronRight` first
(`rotate-90` while open) in primary, then the foreground title, with a
primary underline on hover. The button draws no focus ring; the panel does,
so keyboard focus outlines the whole white block:
`has-[[data-variant=section-toggle]:focus-visible]:ring-3 …:ring-ring/50
…:ring-inset` on the panel `<div>` (inset, because a scrolling column clips
an outset ring). Status badges go beside the button, not inside it, or the
hover underline runs under them.
- Drop `text-white` on default and destructive buttons; the foreground token
already provides it. Drop `disabled:cursor-not-allowed`; the base disables
pointer events.
### Card (`ui/card.tsx`)
| Prop | Values |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `variant` | `outline` (border + card surface, the default), `filled` (muted fill, no border, for tiles on a card-coloured page), `subtle` (border on the page background) |
| `padding` | `none`, `sm` (p-3, stat tiles and panels), `default` (p-4), `lg` (p-6) |
| `interactive` | whole card is the target: hover, focus ring and `selected` highlight; pair with `asChild` around a `<button>` or `<Link>` |
Parts: `CardHeader` (title left, `CardAction` top-right), `CardTitle`,
`CardDescription`, `CardContent`, `CardFooter` (meta row, sticks to the
bottom). One radius for every card (`rounded-2xl`); pass only layout and
`gap-*` on `Card`. Replaces every hand-rolled
`rounded-(md|lg|xl|2xl|3xl|4xl) border bg-(card|muted) p-*` box: source and
tool tiles, team and agent cards, analytics stat tiles and chart panels,
source-type and agent-type picker tiles.
### OptionCard (`ui/option-card.tsx`)
The picker tile: a whole-card `<button>` with an icon in a tinted square, a
title and an optional description; `selected` fills the icon square and
outlines the card. Use it wherever the user picks one of several ways to
proceed (agent type, source type). Title-only tiles keep the same layout.
Pass `selected` (true or false) only in a
single-select picker inside a `role="radiogroup"`; the tile is then a radio.
Tiles that advance a step or navigate (Upload's source types, the agent-type
modal) leave it out and stay plain buttons. Only layout classes on it.
### Badge (`ui/badge.tsx`)
`variant`: `default` (brand), `neutral`, `success`, `warning`, `destructive`,
`info`, `outline`. Replaces every hand-rolled
`rounded-full bg-<colour>-100 px-2 py-0.5 text-xs text-<colour>-700 dark:...`
pill, including schedule and run status pills. HTTP method pills take their variant from
`getMethodBadgeVariant` (`utils/httpMethodColors.ts`): GET `success`, POST
`info`, PUT `warning`, DELETE `destructive`, PATCH `default`, anything else
`neutral`.
### Input (`ui/input.tsx`)
| Prop | Values |
| ------- | ---------------------------------------------------------------- |
| `size` | `sm` (h-8), `default` (h-10.5, the historical 42px), `lg` (h-12) |
| `shape` | `default`, `pill` |
| `variant` | `default`, `bare` (no border, padding, radius, shadow or ring), `filled` (card fill) |
Text alignment classes (`text-right`) and `font-mono` (code fields) are
allowed on Input and Textarea. A floating
`label` sits on the field's border, so its background must match the surface
behind the field: `labelSurface` is `card` (default, forms in cards and
modals), `background` (fields straight on a page) or `muted` (fields on a
muted panel). Never pass a background class for it. The chat and hero
fields (`rounded-3xl px-5 py-3`) are `size="lg" shape="pill"`. A
default-size pill (42px, the form-row height) pads `px-5` like the large
one, so its text lines up with the Select pills. Compact table
filters (`h-auto px-2 py-1 text-sm`) are `size="sm"`. An inset icon (a
search glass) is `leftIcon`, with or without a `label`; it pads the field
`pl-10`, so never hand-place an icon over an Input. A field inside a host
that already draws the frame (the renaming sidebar row, a search strip in a
bordered panel) is `variant="bare"`; the host shows focus, and any inset
padding goes on the host, not the field. A field on a muted panel (the
ImportSpec Base URL box) is `variant="filled"`, so it keeps the card fill
instead of showing the panel through; never pass `bg-card` for it.
### SelectTrigger (`ui/select.tsx`)
`size`: `sm`, `default`, `lg`; `variant`: `default`, `ghost`; `shape`:
`default`, `pill`. The large rounded selects (`rounded-3xl px-5 py-3`) are
`size="lg" shape="pill"`.
### Textarea (`ui/textarea.tsx`)
`size`: `sm`, `default`, `lg` (rounded-2xl, for prompt editors); `resize`:
`none`, `vertical` (default), `both`. Same border, ring and invalid styling
as Input. `variant`: `default` (transparent), `filled` (card fill), with
the same rule as Input: a textarea on a muted panel is `variant="filled"`,
never `bg-card`. Replaces the 21 raw `<textarea>` elements.
### Tooltip (`ui/tooltip.tsx`)
`Tooltip` + `TooltipTrigger asChild` + `TooltipContent side`. Each tooltip
carries its own provider. Use it on every icon-only button and wherever a
`title=` attribute was standing in for a hint; keep `aria-label` on the
button as well.
### Spinner and Skeleton (`ui/spinner.tsx`, `ui/skeleton.tsx`)
`Spinner size="sm | default | lg"` draws in `currentColor`, so colour it
with a `text-*` token on it or its parent. `components/Spinner.tsx` is a
compatibility wrapper (`small | medium | large`) for existing imports.
`Skeleton` is a pulsing muted block sized with layout classes and rounded
as needed; `components/SkeletonLoader.tsx` should compose it.
### Progress (`ui/progress.tsx`)
`value` 0 to 100, `variant`: `default`, `success`, `warning`,
`destructive`, `info`; `size`: `sm`, `default`, `lg`. Replaces the
width-percent divs in quota, indexing and guardrail views.
### Avatar (`ui/avatar.tsx`)
`size`: `none` (image decides, the default), `sm`, `default`, `lg`, `xl`;
`shape`: `none`, `circle`, `square`; `variant`: `default`, `primary` (brand
initials), `muted` (grey initials). Initials boxes pass the letters as
children.
### Dropzone (`ui/dropzone.tsx`)
`size`: `default` (tall target), `compact` (one row, for forms and modals).
Props: `onDrop`, `accept`, `multiple`, `maxFiles`, `maxSize`, `disabled`,
`title`, `description`, `icon`, `error`. Border and fill follow the drag
state through `data-drag-active` and `data-drag-reject`. Replaces every
hand-rolled `border-2 border-dashed rounded-3xl` target around
`useDropzone`; `components/FileUpload.tsx` keeps its preview and validation
logic and should render this underneath.
### Toast (`ui/toast.tsx`)
Feedback on anything the user did (uploads, runs, approvals, team events)
is a toast in the bottom-right stack, never an inline box. The app has one
`ToastViewport`, mounted in `App.tsx`; it is the live region
(`role="status"`, `aria-live="polite"`) and the fixed stack, so `Toast`
cards carry no role and no toast renders its own rail or positioning. Top
to bottom it holds `TeamNotificationToast`, `ToolApprovalToast`,
`UploadToast` and `ActionToast`, and it moves to the bottom-left while the
workflow Preview drawer is open. A new toast component returns only its
`Toast` cards and is added to that viewport. A page that reports the result
of an action (the admin Users actions) dispatches
`showActionToast({ variant: 'success' | 'destructive', message })` from
`notifications/actionToastSlice.ts`; `ActionToast` shows it and dismisses
it after 4.5s, and a new result replaces the previous one.
Compose `Toast` > `ToastHeader variant` (`default`, `success`, `warning`,
`destructive`, `info`) with `ToastTitle` and `ToastActions` (collapse and
close as `Button variant="ghost-muted" size="icon-sm"`), then
`ToastContent` (add `scrollable` for long lists) with `ToastItem label meta`
rows (optional `icon` before the label), a `ToastStatus status` circle per
row (`pending`, `success`, `warning`, `destructive`, `info`),
`ToastMessage variant` for an explanation and `ToastFooter` for action
buttons. `ToastTitle` truncates to one line; `wrap` lets a long title (a
localised string, a team name) wrap instead. `ToastMessage size` is `xs`
(default, the note under a row) or `sm` (`text-sm leading-4.5`, the whole
body of a notice). A message that is the only content under the header
gets its top padding on its own, and a message right after a `ToastItem`
shares that row's divider. Toasts own their width, radius and shadow; pass
no width or colour to them.
### Alert (`ui/alert.tsx`)
Inline notice inside a form or modal only, for something the user must
read before acting (an expiring token above the field, a warning in a
settings panel). Not for feedback on an action; that is a Toast.
`variant`: `default`, `success`, `warning`, `info`, `destructive`. Every
coloured variant is the same shape: `border-<role>/50 bg-<role>/10
text-<role>`, icon in the role colour; `default` sits on `bg-background`.
Icon first, then `AlertTitle` and `AlertDescription`. Every variant is
`role="alert"` except `success`, which is `role="status"` so a confirmation
is announced politely; pass `role` only to override that. Replaces the hand-rolled
`rounded-lg border bg-amber-50 text-amber-800` boxes.
### Breadcrumb (`ui/breadcrumb.tsx`)
`BreadcrumbPage`, the current crumb, is always one line and truncates with
an ellipsis. Pages pass only its width (`w-[16ch]`, `max-w-[32ch]`) and a
`title` with the full text; never `truncate` or typography. Give every
current crumb a width cap so a long name cannot push the row past the
screen. A crumb that runs a handler instead of navigating is
`BreadcrumbLink asChild` around a `<button type="button">`; the link carries
the focus ring. The current crumb is never a disabled button.
### Accordion (`ui/accordion.tsx`)
`AccordionTrigger` carries its own inset and type (`px-4 py-3 text-sm
font-medium`) and an inset focus ring, since `AccordionItem` clips anything
outside it. Pages pass nothing to the trigger; put the frame (a bordered,
rounded box) on a wrapper around `Accordion`.
### Modal, not Dialog
`ui/dialog.tsx` is the Radix primitive and is private to `ui/`; ESLint
rejects imports of it elsewhere. App code uses `Modal` (sizes `sm` to
`full`, `mobileVariant="sheet"`) or `CommandDialog`.
A search palette is `CommandDialog` on desktop. Pass cmdk options to its
inner `Command` through `commandProps` (`shouldFilter={false}` when results
come from a server search, a controlled `value`). On phones the same palette
goes in `Modal mobileVariant="sheet"` as `<Command variant="palette">`, which
gives it the dialog's 48px input row and row spacing, so both widths look
the same (`modals/SearchConversationsModal.tsx`).
In a picker list, mark the item that is currently chosen with
`CommandItem checked` (a `secondary` brand tint through `data-checked`), not
with `bg-accent`: cmdk's own `data-selected` highlight is `bg-accent` and
follows the pointer and arrow keys, so an accent fill would look like hover.
While a checked item is also highlighted it keeps its tint and text and gains
a 1px inset `primary` ring, so the chosen row never turns plain grey.
### Table, Label, dialog text
`TableCell` and `TableHeader` accept typography and alignment classes and
`text-muted-foreground`. `Label`, `DialogTitle`, `DialogDescription`,
`SheetTitle` and `SheetDescription` accept typography plus
`text-foreground` and `text-muted-foreground`. `DialogContent`,
`PopoverContent`, `SheetContent`, `DropdownMenuContent` and `Modal` accept
`p-0` for edge-to-edge content (MoveToFolderModal's banded folder picker);
Modal merges it after its own `p-8`, so no `!` is needed. Everything else on these components is layout only.
## Spacing, type and radii
Use the Tailwind scale. Tailwind v4 accepts fractional steps, so
`h-10.5` is 42px and `ring-3` is a 3px ring; neither needs brackets. Layout
values (`h-[calc(100dvh-64px)]`, `max-w-[520px]`) and motion values
(`transition-[color,box-shadow]`, custom easings) are allowed. Font sizes,
padding, colours and radii in brackets are not; pick the nearest scale step
or add a token.
## Focus, elevation and stacking
- **Focus ring**: `focus-visible:ring-3 focus-visible:ring-ring/50` plus
`focus-visible:border-ring` on fields. Never `focus:` (mouse users see it)
and never `ring-2` or `ring-[3px]`; the `ui/` components already carry it,
so plain elements should become components rather than copy the classes.
- **Elevation**: cards have no shadow; fields and outline buttons
`shadow-xs`; popovers, menus and select lists `shadow-md`; sheets
`shadow-lg`; modals `shadow-modal`; toasts `shadow-toast`. Nothing else
uses a shadow class.
- **Stacking**: `z-10` sticky headers and table heads inside a page;
`z-20` in-page floating chrome (banners, scroll-to-bottom, drag
handles); `z-50` everything portalled (overlay, modal, sheet, popover,
menu, tooltip); `z-200` only for select lists that must open above a
modal. Do not invent values in between.
## Inline styles
Allowed without comment in `src/components/MermaidRenderer.tsx` and
`src/agents/workflow/nodes/**` (third-party renderers and canvas
positions). Elsewhere, prefer a CSS custom property
(`style={{ '--progress': pct }}` with `w-(--progress)`), and when a value
truly is computed at runtime, keep the inline style and add
`// eslint-disable-next-line shadcn/no-inline-styles -- <reason>`.
## Approved exceptions
Places where a rule is knowingly disabled. Each one carries the same reason
as a comment at the call site; add a row here when you add a disable so the
list stays reviewable.
| Where | Rule | Why |
| ----------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `components/Notification.tsx` | `shadcn/no-restyle` | The promo banner's close control is a bare 16px image on the purple gradient: no `size` fits it, and `ghost`'s accent hover fill would paint a grey square on the gradient. |
| `components/ui/calendar.tsx` | `shadcn/require-static-classes` | The day button merges `defaultClassNames.day`, which react-day-picker returns from `getDefaultClassNames()` at runtime; no static form exists. |
| `navigation/SidebarLevel.tsx` | `shadcn/no-arbitrary-values` | The incoming sidebar panel casts a strong shadow off its left edge while it slides (`shadow-[-12px_0_24px_-6px_rgba(0,0,0,0.45)]`); no scale shadow is horizontal, and the container clips it once the panel comes to rest. |
| `components/ui/modal.tsx` | `shadcn/no-arbitrary-values` | The mobile sheet's `pb-[max(env(safe-area-inset-bottom),1rem)]` keeps its footer clear of the iPhone home indicator (about 34px, since `index.html` sets `viewport-fit=cover`) and equals `pb-4` everywhere else; no scale step can express `env()`. |
| `components/MessageInput.tsx` | `shadcn/no-restyle` | The empty composer's send button is a grey circle (`bg-muted`, `dark:bg-accent`), not a faded brand one; no variant is neutral while disabled, and `secondary` is the brand-tinted pressed state. |
| `Hero.tsx` | `shadcn/no-restyle` | The landing page's model picker keeps its hero look: a borderless muted pill at 16px (`rounded-4xl px-6 py-4 text-base`) whose menu hangs from it as one shape. Three disables: `SelectTrigger`, `SelectContent`, `SelectItem`. |
| `agents/AgentsList.tsx` | `shadcn/no-restyle` | Inside a folder, the breadcrumb trail replaces the section `<h2>`, so its `BreadcrumbList` keeps heading typography (`text-foreground text-lg font-semibold gap-2`). It's the only breadcrumb that does. |
| `settings/Prompts.tsx` | `shadcn/no-restyle` | The row icon buttons (edit, duplicate, share, delete) sit in a cmdk row that is already `bg-accent` while highlighted, so ghost's accent hover would be invisible; they keep a `foreground/15` (delete: `destructive/15`) hover tint. Four disables. |
| `Navigation.tsx` | `shadcn/no-restyle` | The agent pin is a bare 16px image on a sidebar row that already fills on hover; an accent fill would be invisible on it, so hover dims the icon (`hover:opacity-75`) instead. |
| `conversation/MarkdownAnswer.tsx`, `components/ArtifactSidebar.tsx` | `shadcn/no-inline-styles` | SyntaxHighlighter's `style` prop is its Prism theme object (`oneLight` / `vscDarkPlus`), picked by theme at runtime. It is not CSS, so no class or custom property can replace it. One disable per file. |
| `components/MultiSelectPopover.tsx` | `shadcn/no-restyle`, `shadcn/no-arbitrary-values` | The phone bottom sheet (`SheetContent side="bottom"`) keeps its sheet shape: a rounded top, no gap, a card fill in dark so it matches the `Command` inside it, and `pb-[env(safe-area-inset-bottom)]` to clear the iPhone home indicator (the same inset as `ui/modal.tsx`). One disable. |
| `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | The Preview minimap's node rows are status tiles: the fill, border and ring follow the step (success, primary running + pulse, destructive, muted pending; a ring on the active row) and stay pinned on hover, pending and running rows stay unfaded while disabled, and clickable rows dim to 80% on hover. No Button variant is status-tinted. The rule reports each string inside `cn(...)`, so it's a `/* eslint-disable */` … `/* eslint-enable */` pair around the `className` attribute. |
| `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom − / + buttons sit on the diagram's `bg-black/70` overlay, where ghost's accent hover paints a light square with dark text; they hover to `white/20` with white text instead (`dark:hover:bg-white/20` too, to beat ghost's dark hover). Two disables. |
| `conversation/ConversationMessages.tsx`, `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | MessageScroller padding. The primitive measures `MessageScrollerContent`'s padding-block for its scroll and spacer math, and the Viewport's top gap must scroll with the messages, so neither can move to a margin or a wrapper. The two callers use different values. Five disables. |
Note that a multi-line reason has to be a `/* ... */` block comment;
consecutive `//` lines only disable the next comment line, not the code.
## Known dead classes
The lint reports classes Tailwind cannot generate. In this codebase they are
bugs, not missing configuration:
- `xs:` has never been a breakpoint here (not in the old Tailwind v3 config
either), so `xs:px-3`, `xs:text-xs` never applied. Remove them rather than
declaring the breakpoint, which would change layouts that were never seen.
- `text-s`, `dark:text-gray`, `w-inherit` are typos for `text-sm`,
`dark:text-gray-*` and `w-[inherit]`.
- `agents-container`, `conversations-container`, `loader` and `mermaid` are
unused hooks; nothing selects them. Before deleting a class like these,
grep for it in `querySelector` and `classList` calls too. `star` was
defined at runtime by a `<style>` element in `components/Notification.tsx`;
it is now the `notification-star` utility in `src/index.css`. `msc-spacer`
was a lookup hook for `ConversationMessages`' `querySelector`; the lookup
now uses the primitive's `[data-message-scroller-spacer]` attribute.
## Live style guide
`npm run dev`, then open `/design`. The page (`src/design/DesignSystem.tsx`)
renders every `ui/` component with the variants above, shows the resolved
value of each colour token and has a theme toggle. It is registered only
when Vite runs in development mode, so it never ships. When you add a
variant or token, add it there too.
## Cleanup playbook
The design lint is at `warn` while the app is migrated. Work one file at a
time, smallest first, and keep every step green.
1. **See the state.** `npm run lint:design` prints warnings by rule and the
worst files. `npm run lint:design -- --file src/settings/Teams.tsx` lists
every warning in one file with the fix the message proposes;
`-- --rule no-raw-colors` lists files for one rule.
2. **Pick a file, read it whole**, then fix all of its design warnings in
one pass. Map raw colours to tokens (table above), replace hand-rolled
boxes, pills, tiles, spinners and dropzones with the `ui/` components,
and move Button/Input/Select overrides onto `variant`, `size`, `shape`.
Delete the `dark:` twin of every class you tokenise. Remove classes the
lint calls unknown (`xs:*`, `text-s`, `dark:text-gray`, unused hooks).
3. **Do not restyle `ui/` components from a page.** If a treatment is used
in more than one place and no variant fits, add the variant in the
component file, add it to `src/design/DesignSystem.tsx`, and document it
here. A one-off gets `// eslint-disable-next-line shadcn/<rule> -- reason`.
4. **Keep behaviour identical.** Same DOM order, same handlers, same
translations (`t(...)` keys stay). Only classes and component choice
change. Prettier with the Tailwind plugin reorders classes; run
`npm run lint-fix` rather than fighting it.
5. **Verify per file**: `npx eslint <file>` (zero `shadcn/*` warnings),
`npx tsc --noEmit -p tsconfig.json`, `npm test`, and look at the screen
in the running app (`npm run dev`, then the page that renders the file)
in both themes.
6. **Promote rules** in `eslint.config.js` from `warn` to `error` when
`npm run lint:design -- --rule <rule>` reports zero, in this order:
`no-unknown-classes`, `require-static-classes`, `no-inline-styles`,
`no-arbitrary-values`, `no-raw-colors`, `no-restyle`.
Known traps:
- `npm run lint` also reports 187 pre-existing Prettier errors in nine
files unrelated to design; `npm run lint-fix` clears them.
- Port 5173 may be held by another checkout; check the Vite banner shows
this repo's path before trusting what you see.
- `components/Spinner.tsx` and `components/StatusBadge.tsx` are transitional
wrappers; migrate their callers to `ui/spinner` and `ui/badge`, then
delete them. `components/SkeletonLoader.tsx` and
`components/FileUpload.tsx` keep their logic but should render `Skeleton`
and `Dropzone`.
## Working with the lint
```bash
cd frontend && npm run lint
```
All `shadcn/*` rules are `warn` while the backlog is cleaned up. Promote a
rule to `error` in `eslint.config.js` once `npm run lint` reports zero
warnings for it; the intended order is `no-unknown-classes`,
`require-static-classes`, `no-inline-styles`, `no-arbitrary-values`,
`no-raw-colors`, `no-restyle`. Add a variant or token only when a treatment
is used in more than one place and none of the existing ones fits; a single
special case gets a disable comment with a reason.
+195 -8
View File
@@ -1,10 +1,11 @@
import js from '@eslint/js'
import tsParser from '@typescript-eslint/parser'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import react from 'eslint-plugin-react'
import unusedImports from 'eslint-plugin-unused-imports'
import prettier from 'eslint-plugin-prettier'
import globals from 'globals'
import js from '@eslint/js';
import { plugin as shadcn } from '@shadcn/lint';
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import react from 'eslint-plugin-react';
import unusedImports from 'eslint-plugin-unused-imports';
import prettier from 'eslint-plugin-prettier';
import globals from 'globals';
export default [
{
@@ -49,6 +50,7 @@ export default [
react,
'unused-imports': unusedImports,
prettier,
shadcn,
},
rules: {
...js.configs.recommended.rules,
@@ -68,11 +70,196 @@ export default [
endOfLine: 'auto',
},
],
// The Radix Dialog wrapper is an internal primitive; app code uses
// <Modal> (or <CommandDialog>). Only src/components/ui may import it.
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/components/ui/dialog'],
message:
'Use <Modal> from @/components/ui/modal; the Dialog primitive is internal to ui/.',
},
],
},
],
// Design-system rules (@shadcn/lint). Tokens, variants and the
// approved exceptions are documented in DESIGN.md. Every rule starts
// at `warn`; promote a rule to `error` once its count reaches zero.
'shadcn/no-restyle': [
'warn',
{
allow: ['layout'],
contracts: [
{
pattern: '^Button$',
allow: ['layout'],
message: {
shape:
'"{{className}}" is not allowed on <Button>: use shape="pill" for round buttons. Other shapes need a new variant in {{file}}.',
spacing:
'"{{className}}" is not allowed on <Button>: use a size ({{sizes}}). shape="pill" already widens the padding; put space around the button on the parent (gap) or as margin here.',
color:
'"{{className}}" is not allowed on <Button>: use a variant ({{variants}}). Primary buttons already have white text; muted icon buttons are variant="ghost-muted"; bordered brand buttons are variant="outline-primary".',
typography:
'"{{className}}" is not allowed on <Button>: size="xs" gives text-xs; the default size is text-sm. Add a size in {{file}} only if the design explicitly calls for one.',
default:
'"{{className}}" is not allowed on <Button>: {{category}} belongs to the component. Use a variant ({{variants}}), a size ({{sizes}}) or a shape (default, pill). See DESIGN.md.',
},
},
{
pattern: '^Input$',
// font-mono: code fields (JSON, keys) keep a monospace face.
allow: [
'layout',
'text-left',
'text-center',
'text-right',
'font-mono',
],
message: {
default:
'"{{className}}" is not allowed on <Input>: use size (default, sm, lg) and shape (default, pill); alignment classes are allowed. Add a variant in {{file}} only if the design explicitly calls for one.',
},
},
{
pattern: '^SelectTrigger$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <SelectTrigger>: use size (sm, default, lg), variant (default, ghost) and shape (default, pill) from {{file}}.',
},
},
{
pattern: '^Avatar$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Avatar>: use size (sm, default, lg, xl), shape (circle, square) and variant (primary, muted) from {{file}}.',
},
},
{
pattern: '^Card$',
allow: ['layout', 'gap-*'],
message: {
default:
'"{{className}}" is not allowed on <Card>: use variant (outline, filled, subtle), padding (none, sm, default, lg) and interactive/selected from {{file}}.',
},
},
{
pattern: '^Textarea$',
// font-mono: code editors (JSON schema, workflow code) keep a
// monospace face.
allow: [
'layout',
'text-left',
'text-center',
'text-right',
'font-mono',
],
message: {
default:
'"{{className}}" is not allowed on <Textarea>: use size (sm, default, lg) and resize (none, vertical, both) from {{file}}; min-height and width are layout and allowed.',
},
},
{
pattern: '^Skeleton$',
allow: ['layout', 'rounded-*'],
},
{
pattern: '^Spinner$',
allow: ['layout', 'color'],
},
{
pattern: '^Progress$',
allow: ['layout'],
},
{
pattern: '^(Toast|ToastViewport)$',
allow: ['layout'],
},
{
pattern: '^(ToastContent|ToastItem|ToastFooter)$',
allow: ['layout', 'spacing'],
},
{
pattern: '^OptionCard$',
allow: ['layout'],
},
{
pattern: '^(CardHeader|CardContent|CardFooter|CardAction)$',
allow: ['layout', 'spacing', 'gap-*', 'bg-muted/*'],
},
{
pattern: '^(CardTitle|CardDescription)$',
allow: ['layout', 'typography', 'line-clamp-*'],
},
{
pattern: '^(TableCell|TableHeader)$',
allow: ['layout', 'typography', 'text-muted-foreground'],
},
{
pattern:
'^(Label|DialogTitle|DialogDescription|SheetTitle|SheetDescription)$',
allow: [
'layout',
'typography',
'text-muted-foreground',
'text-foreground',
],
},
{
// Modal wraps DialogContent and merges className after its p-8.
pattern: '^((Dialog|Popover|Sheet|DropdownMenu)Content|Modal)$',
allow: ['layout', 'p-0'],
},
],
},
],
'shadcn/no-raw-colors': [
'warn',
{
message:
'"{{className}}" uses the raw Tailwind palette. Map it to a token from {{file}}: grey text -> text-foreground or text-muted-foreground; grey borders -> border-border; grey fills -> bg-muted, bg-accent or bg-card; red -> destructive; green -> success; amber, yellow and orange -> warning; blue -> info; purple -> primary. Soft badge or alert fills use bg-<token>/10 and a single class replaces the light+dark pair. Status pills should be <Badge variant="...">.',
},
],
'shadcn/no-arbitrary-values': [
'warn',
// Motion values (`transition-[color,box-shadow]`, custom easings)
// have no token scale; everything else must come from the theme.
{ allow: ['layout', 'transition-*', 'ease-*'] },
],
'shadcn/no-inline-styles': 'warn',
'shadcn/no-unknown-classes': 'warn',
'shadcn/require-static-classes': 'warn',
},
settings: {
react: {
version: 'detect',
},
shadcn: {
note: 'See frontend/DESIGN.md for tokens, component variants and approved exceptions.',
},
},
},
]
{
// The design-system components define the styles; they are not restyled.
files: ['src/components/ui/**'],
rules: {
'shadcn/no-restyle': 'off',
'no-restricted-imports': 'off',
},
},
{
// Third-party renderers and canvas-positioned nodes take computed
// runtime values that Tailwind classes cannot express.
files: [
'src/components/MermaidRenderer.tsx',
'src/agents/workflow/nodes/**',
],
rules: {
'shadcn/no-inline-styles': 'off',
},
},
];
+417 -4
View File
@@ -48,6 +48,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@shadcn/lint": "^0.2.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/d3-force": "^3.0.10",
"@types/lodash": "^4.17.25",
@@ -847,6 +848,329 @@
"node": ">=22.12.0"
}
},
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.148.0.tgz",
"integrity": "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-android-arm64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.148.0.tgz",
"integrity": "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-darwin-arm64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.148.0.tgz",
"integrity": "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-darwin-x64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.148.0.tgz",
"integrity": "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-freebsd-x64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.148.0.tgz",
"integrity": "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.148.0.tgz",
"integrity": "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.148.0.tgz",
"integrity": "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.148.0.tgz",
"integrity": "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.148.0.tgz",
"integrity": "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.148.0.tgz",
"integrity": "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.148.0.tgz",
"integrity": "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.148.0.tgz",
"integrity": "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.148.0.tgz",
"integrity": "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.148.0.tgz",
"integrity": "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-x64-musl": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.148.0.tgz",
"integrity": "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-openharmony-arm64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.148.0.tgz",
"integrity": "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.148.0.tgz",
"integrity": "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.148.0.tgz",
"integrity": "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.148.0.tgz",
"integrity": "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
@@ -2786,6 +3110,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/@shadcn/lint": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@shadcn/lint/-/lint-0.2.0.tgz",
"integrity": "sha512-DTYr427BR8cDc0J0Y++u1Uh12NAYGmdPiqgy4wKSogi7RNEhFanIcfBMPRPUvkunt9QiJUfI7B28TW39MrPCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0",
"cn": "0.3.2"
},
"engines": {
"node": ">=20.19"
},
"optionalDependencies": {
"oxc-parser": "^0.148.0"
},
"peerDependencies": {
"@typescript-eslint/parser": ">=8.40.0",
"eslint": ">=9.30.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/parser": {
"optional": true
},
"eslint": {
"optional": true
}
}
},
"node_modules/@shadcn/react": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@shadcn/react/-/react-0.3.1.tgz",
@@ -3703,7 +4056,6 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3713,7 +4065,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.3.0"
@@ -4811,6 +5163,19 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/cn": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/cn/-/cn-0.3.2.tgz",
"integrity": "sha512-RKzY5Edavc8B2Ka6tjJecKFC4nm0p7lADY72xzICmiJSf4TkaxWXt0fHsib2P2G4FkFyXfSDi9oDtIBuCZvY2g==",
"dev": true,
"license": "MIT",
"bin": {
"cn": "bin/cn.mjs"
},
"engines": {
"node": ">=20"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4938,7 +5303,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cytoscape": {
@@ -9848,6 +10212,55 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oxc-parser": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.148.0.tgz",
"integrity": "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@oxc-project/types": "^0.148.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/sponsors/oxc-project"
},
"optionalDependencies": {
"@oxc-parser/binding-android-arm-eabi": "0.148.0",
"@oxc-parser/binding-android-arm64": "0.148.0",
"@oxc-parser/binding-darwin-arm64": "0.148.0",
"@oxc-parser/binding-darwin-x64": "0.148.0",
"@oxc-parser/binding-freebsd-x64": "0.148.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.148.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.148.0",
"@oxc-parser/binding-linux-arm64-musl": "0.148.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.148.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.148.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.148.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.148.0",
"@oxc-parser/binding-linux-x64-gnu": "0.148.0",
"@oxc-parser/binding-linux-x64-musl": "0.148.0",
"@oxc-parser/binding-openharmony-arm64": "0.148.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.148.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.148.0",
"@oxc-parser/binding-win32-x64-msvc": "0.148.0"
}
},
"node_modules/oxc-parser/node_modules/@oxc-project/types": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
"integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
"dev": true,
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -11746,7 +12159,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+2
View File
@@ -14,6 +14,7 @@
"test:watch": "vitest",
"lint": "eslint ./src --ext .jsx,.js,.ts,.tsx",
"lint-fix": "eslint ./src --ext .jsx,.js,.ts,.tsx --fix",
"lint:design": "eslint ./src -f json | node scripts/design-lint-report.mjs",
"format": "prettier ./src --write",
"prepare": "cd .. && husky install frontend/.husky"
},
@@ -64,6 +65,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@shadcn/lint": "^0.2.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/d3-force": "^3.0.10",
"@types/lodash": "^4.17.25",
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Summarise @shadcn/lint warnings from ESLint JSON output.
*
* npm run lint:design rules × counts, then the 25 worst files
* npm run lint:design -- --rule no-raw-colors files for one rule
* npm run lint:design -- --file src/settings/Tools.tsx every message in a file
*
* Reads ESLint's JSON from stdin; the npm script pipes it in.
*/
import { readFileSync } from 'node:fs';
const args = process.argv.slice(2);
const opt = (name) => {
const i = args.indexOf(name);
return i === -1 ? undefined : args[i + 1];
};
const onlyRule = opt('--rule');
const onlyFile = opt('--file');
const input = readFileSync(0, 'utf8');
const start = input.indexOf('[');
const results = JSON.parse(input.slice(start));
const rows = [];
for (const file of results) {
const rel = file.filePath.replace(`${process.cwd()}/`, '');
for (const m of file.messages) {
if (!m.ruleId?.startsWith('shadcn/')) continue;
rows.push({
file: rel,
rule: m.ruleId.slice(7),
line: m.line,
msg: m.message,
});
}
}
const count = (key) => {
const c = new Map();
for (const r of rows) c.set(r[key], (c.get(r[key]) ?? 0) + 1);
return [...c.entries()].sort((a, b) => b[1] - a[1]);
};
const pad = (s, n) => String(s).padStart(n);
if (onlyFile) {
const mine = rows.filter(
(r) => r.file === onlyFile || r.file.endsWith(onlyFile),
);
console.log(`${mine.length} design warnings in ${onlyFile}\n`);
for (const r of mine.sort((a, b) => a.line - b.line)) {
console.log(
`${pad(r.line, 5)} ${r.rule}\n ${r.msg.split(' See frontend/DESIGN.md')[0]}\n`,
);
}
} else if (onlyRule) {
const mine = rows.filter((r) => r.rule === onlyRule);
console.log(`${mine.length} warnings for shadcn/${onlyRule}\n`);
const byFile = new Map();
for (const r of mine) byFile.set(r.file, (byFile.get(r.file) ?? 0) + 1);
for (const [f, n] of [...byFile.entries()].sort((a, b) => b[1] - a[1]))
console.log(`${pad(n, 5)} ${f}`);
} else {
console.log(
`${rows.length} design warnings in ${new Set(rows.map((r) => r.file)).size} files\n`,
);
console.log('By rule:');
for (const [rule, n] of count('rule')) console.log(`${pad(n, 5)} ${rule}`);
console.log('\nWorst files:');
for (const [file, n] of count('file').slice(0, 25))
console.log(`${pad(n, 5)} ${file}`);
console.log(
'\nNext: npm run lint:design -- --file <path> or -- --rule <rule>',
);
}
+40 -5
View File
@@ -1,13 +1,17 @@
import { envVar } from '@/env';
import './locale/i18n';
import { useState } from 'react';
import { lazy, Suspense, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import Admin from './admin';
import Agents from './agents';
import SharedAgentGate from './agents/SharedAgentGate';
import { selectWorkflowPreviewOpen } from './agents/workflow/workflowPreviewSlice';
import DocsGPTMark from './assets/logo-b.svg';
import DocsGPTMarkWhite from './assets/logo-w.svg';
import ActionButtons from './components/ActionButtons';
@@ -15,6 +19,7 @@ import AdminRoute from './components/AdminRoute';
import ErrorBoundary from './components/ErrorBoundary';
import Spinner from './components/Spinner';
import { Button } from './components/ui/button';
import { ToastViewport } from './components/ui/toast';
import UploadToast from './components/UploadToast';
import Conversation from './conversation/Conversation';
import { SharedConversation } from './conversation/SharedConversation';
@@ -26,11 +31,18 @@ import Navigation from './Navigation';
import { getSectionForPath } from './navigation/sections';
import { SidebarLevelProvider } from './navigation/SidebarLevelProvider';
import PageNotFound from './PageNotFound';
// Dev-only style guide (see frontend/DESIGN.md). The DEV guard around the
// import lets the bundler drop the chunk from production builds entirely.
const DesignSystem = import.meta.env.DEV
? lazy(() => import('./design/DesignSystem'))
: null;
import Setting from './settings';
import Teams from './settings/Teams';
import Notification from './components/Notification';
import ToolApprovalToast from './notifications/ToolApprovalToast';
import TeamNotificationToast from './notifications/TeamNotificationToast';
import ActionToast from './notifications/ActionToast';
function AuthWrapper({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
@@ -64,7 +76,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
<Button
type="button"
onClick={retryOidcLogin}
className="rounded-3xl px-5"
shape="pill"
data-testid="oidc-signin"
>
{t('auth.signInWith', { provider: oidcProviderName || 'SSO' })}
@@ -90,6 +102,10 @@ function MainLayout() {
// the conversation now survives the trip, so "share" would target a chat
// that isn't on screen.
const inSection = Boolean(getSectionForPath(location.pathname));
// The workflow Preview drawer occupies the right edge; move the toast
// stack to the bottom-left while it's open so it stays visible without
// covering the drawer's attach/send controls.
const previewOpen = useSelector(selectWorkflowPreviewOpen);
return (
<SidebarLevelProvider>
@@ -109,9 +125,18 @@ function MainLayout() {
<Outlet />
</ErrorBoundary>
</div>
<UploadToast />
<ToolApprovalToast />
<TeamNotificationToast />
{/* The one toast stack (and live region) for the app. Each toast
renders only its cards, top to bottom: team notifications, tool
approvals, uploads, action results. */}
<ToastViewport
className={cn(previewOpen && 'right-auto left-4')}
onMouseDown={(e) => e.stopPropagation()}
>
<TeamNotificationToast />
<ToolApprovalToast />
<UploadToast />
<ActionToast />
</ToastViewport>
</div>
</SidebarLevelProvider>
);
@@ -178,6 +203,16 @@ export default function App() {
</Route>
<Route path="/share/:identifier" element={<SharedConversation />} />
<Route path="/shared/agent/:agentId" element={<SharedAgentGate />} />
{DesignSystem && (
<Route
path="/design"
element={
<Suspense fallback={null}>
<DesignSystem />
</Suspense>
}
/>
)}
<Route path="/*" element={<PageNotFound />} />
</Routes>
</div>
+12 -2
View File
@@ -93,17 +93,27 @@ function HeroModelSelect() {
disabled={!hasModels}
>
<SelectTrigger
/* eslint-disable-next-line shadcn/no-restyle --
The landing page's one focal control keeps its own look: a
borderless muted pill at 16px whose menu joins it (DESIGN.md,
Approved exceptions). */
className="bg-muted dark:bg-card text-foreground hover:bg-muted dark:hover:bg-card w-full justify-between rounded-4xl border-0 px-6 py-4 text-base shadow-none data-[state=open]:rounded-b-none"
size="lg"
>
<SelectValue placeholder="Select Model" />
</SelectTrigger>
<SelectContent className="bg-muted dark:bg-card rounded-t-none rounded-b-4xl border-0 shadow-md data-[side=bottom]:translate-y-0">
<SelectContent
/* eslint-disable-next-line shadcn/no-restyle --
The hero picker's menu hangs from its trigger as one muted
shape (DESIGN.md, Approved exceptions). */
className="bg-muted dark:bg-card rounded-t-none rounded-b-4xl border-0 shadow-md data-[side=bottom]:translate-y-0"
>
{hasModels ? (
availableModels?.map((model: Model) => (
<SelectItem
key={model.id}
value={model.id}
/* eslint-disable-next-line shadcn/no-restyle -- hero menu rows match its 16px trigger */
className="px-5 py-3 text-base [&_[data-slot=select-item-indicator]]:right-5"
>
{model.display_name}
@@ -169,7 +179,7 @@ export default function Hero({
<p className="text-foreground mb-2 font-semibold">
{demo.header}
</p>
<span className="line-clamp-2 text-gray-700 opacity-60 dark:text-gray-300">
<span className="text-muted-foreground line-clamp-2">
{demo.query}
</span>
</button>
+102 -95
View File
@@ -400,11 +400,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
onClick={() => setNavOpen(true)}
aria-label="Open navigation menu"
className="text-muted-foreground hover:text-foreground"
>
<PanelLeftOpen className="size-5" strokeWidth={1.75} />
</Button>
@@ -421,37 +420,34 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{queries?.length > 0 && (
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
onClick={() => newChat()}
aria-label="Start new chat"
className="text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" strokeWidth={1.75} />
</Button>
)}
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
onClick={() => {
dispatch(setSelectedAgent(null));
goToLevel(AGENTS_MANAGE_ROOT);
}}
aria-label={t('manageAgents')}
className="text-muted-foreground hover:text-foreground"
>
<LayoutGrid className="size-5" strokeWidth={1.75} />
</Button>
{conversations?.data && conversations.data.length > 0 && (
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
onClick={() => setSearchOpen(true)}
aria-label={t('modals.searchConversations.searchPlaceholder')}
title={t('modals.searchConversations.searchPlaceholder')}
className="text-muted-foreground hover:text-foreground"
>
<SearchIcon className="size-5" strokeWidth={1.75} />
</Button>
@@ -459,11 +455,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="mt-auto flex flex-col items-center gap-2">
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
onClick={() => goToLevel('/settings')}
aria-label={t('settings.label')}
className="text-muted-foreground hover:text-foreground"
>
<SettingsIcon className="size-5" strokeWidth={1.75} />
</Button>
@@ -473,9 +468,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
)}
<div
className={`${
!navOpen && '-ml-96 md:-ml-72'
} bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white`}
className={cn(
'bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white',
!navOpen && '-ml-96 md:-ml-72',
)}
>
<div
className={
@@ -493,9 +489,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
className="text-muted-foreground hover:text-foreground shrink-0"
className="shrink-0"
onClick={() => {
setNavOpen(!navOpen);
}}
@@ -562,27 +558,29 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{t('navigation.agents')}
</p>
</div>
<div className="agents-container">
<div>
<div>
{recentAgents.map((agent, idx) => (
<div
key={idx}
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 ${
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-3 ${
agent.id === selectedAgent?.id && !conversationId
? 'bg-sidebar-accent'
: ''
}`}
onClick={() => handleAgentClick(agent)}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<div className="flex w-6 justify-center">
<Avatar
src={agent.image}
alt="agent-logo"
imgClassName="h-6 w-6 rounded-full object-contain"
shape="circle"
className="overflow-hidden"
imgClassName="h-6 w-6 object-contain"
/>
</div>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{agent.name}
</p>
</div>
@@ -593,6 +591,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
type="button"
variant="ghost"
size="icon-sm"
/* eslint-disable-next-line shadcn/no-restyle -- the pin is a bare 16px image on a sidebar row that already fills on hover; an accent fill would be invisible on it, so hover dims the icon instead */
className="h-auto w-auto rounded-full p-0 hover:bg-transparent hover:opacity-75"
onClick={(e) => {
e.stopPropagation();
@@ -611,63 +610,65 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
))}
</div>
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
event.preventDefault();
dispatch(setSelectedAgent(null));
closeNavOnMobile();
goToLevel(AGENTS_MANAGE_ROOT);
}}
className={({ isActive }) =>
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 ${
isActive ? 'bg-sidebar-accent' : ''
}`
}
<Button
variant="sidebar-item"
asChild
className="mx-4 my-auto mt-2 flex"
>
<div className="flex w-6 justify-center">
<LayoutGrid
className="text-muted-foreground size-5"
strokeWidth={1.75}
aria-label="manage-agents"
/>
</div>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
event.preventDefault();
dispatch(setSelectedAgent(null));
closeNavOnMobile();
goToLevel(AGENTS_MANAGE_ROOT);
}}
>
<div className="flex w-6 justify-center">
<LayoutGrid
className="text-muted-foreground size-5"
strokeWidth={1.75}
aria-label="manage-agents"
/>
</div>
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
</Button>
</div>
</div>
) : (
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
event.preventDefault();
closeNavOnMobile();
dispatch(setSelectedAgent(null));
goToLevel(AGENTS_MANAGE_ROOT);
}}
className={({ isActive }) =>
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${
isActive ? 'bg-sidebar-accent' : ''
}`
}
<Button
variant="sidebar-item"
asChild
className="mx-4 my-auto mt-2 flex"
>
<LayoutGrid
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="manage-agents"
/>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
event.preventDefault();
closeNavOnMobile();
dispatch(setSelectedAgent(null));
goToLevel(AGENTS_MANAGE_ROOT);
}}
>
<LayoutGrid
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="manage-agents"
/>
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
</Button>
)}
{conversations?.data && conversations.data.length > 0 ? (
<div className="mt-7">
@@ -677,10 +678,11 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</p>
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
shape="pill"
onClick={() => setSearchOpen(true)}
className="text-muted-foreground hover:text-foreground hover:bg-sidebar-accent mr-1 rounded-full"
className="mr-1"
aria-label={t(
'modals.searchConversations.searchPlaceholder',
)}
@@ -693,7 +695,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
/>
</Button>
</div>
<div className="conversations-container">
<div>
{(conversations.data ?? []).map((conversation) => (
<ConversationTile
key={conversation.id}
@@ -763,25 +765,30 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
inSection && 'hidden',
)}
>
<Link
to="/settings"
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
closeNavOnMobile();
goToLevel('/settings');
}}
className="hover:bg-sidebar-accent mx-4 my-auto flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3"
<Button
variant="sidebar-item"
asChild
className="mx-4 my-auto flex"
>
<SettingsIcon
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="Settings"
/>
<p className="text-foreground text-sm dark:text-white">
{t('settings.label')}
</p>
</Link>
<Link
to="/settings"
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
closeNavOnMobile();
goToLevel('/settings');
}}
>
<SettingsIcon
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="Settings"
/>
<p className="text-foreground text-sm dark:text-white">
{t('settings.label')}
</p>
</Link>
</Button>
</div>
<div className="text-foreground flex flex-col justify-end dark:text-white">
<div className="flex items-center justify-between py-1">
@@ -836,9 +843,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="relative flex h-full items-center">
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
className="text-muted-foreground hover:text-foreground ml-4 size-9 lg:hidden"
className="ml-4 size-9 lg:hidden"
onClick={() => setNavOpen(true)}
aria-label="Toggle mobile menu"
>
+2 -2
View File
@@ -6,12 +6,12 @@ export default function PageNotFound() {
return (
<div className="bg-background grid min-h-screen">
<p className="text-foreground dark:bg-card mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl bg-gray-100 p-6 lg:p-10 xl:p-16">
<p className="text-foreground bg-muted mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl p-6 lg:p-10 xl:p-16">
<h1>{t('pageNotFound.title')}</h1>
<p>{t('pageNotFound.message')}</p>
<Link
to="/"
className="bg-primary hover:bg-primary/90 mr-4 inline-flex cursor-pointer items-center justify-center rounded-full px-4 py-2 text-white transition-colors duration-100"
className="bg-primary hover:bg-primary/90 text-primary-foreground mr-4 inline-flex cursor-pointer items-center justify-center rounded-full px-4 py-2 transition-colors duration-100"
>
{t('pageNotFound.goHome')}
</Link>
+14 -16
View File
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router-dom';
import adminService, {
type ActivityFilters,
} from '../api/services/adminService';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { MultiSelect } from '../components/ui/multi-select';
@@ -21,7 +22,6 @@ import {
import { selectToken } from '../preferences/preferenceSlice';
import {
Loading,
Pill,
categoryTone,
eventLabel,
eventTone,
@@ -75,7 +75,7 @@ function DetailRow({ label, value }: { label: string; value: unknown }) {
? JSON.stringify(value)
: String(value);
return (
<div className="flex gap-2 text-[13px] break-all">
<div className="flex gap-2 text-xs break-all">
<span className="text-muted-foreground min-w-32 shrink-0">{label}</span>
<span className="font-mono">{text}</span>
</div>
@@ -275,7 +275,7 @@ export default function Activity() {
key={range.label}
variant={range.days === rangeDays ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
shape="pill"
onClick={() => withPageReset(setRangeDays)(range.days)}
>
{range.label}
@@ -347,7 +347,7 @@ export default function Activity() {
const repeat =
idx > 0 && rows[idx - 1].actor_id === row.actor_id;
return [
<TableRow key={key} className="hover:bg-muted/40">
<TableRow key={key}>
<TableCell>
<button
type="button"
@@ -369,29 +369,29 @@ export default function Activity() {
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Pill tone={eventTone(row.event)}>
<Badge variant={eventTone(row.event)}>
{eventLabel(row.event)}
</Pill>
</Badge>
{row.outcome ? (
<Pill tone={outcomeTone(row.outcome)}>
<Badge variant={outcomeTone(row.outcome)}>
{outcomeLabel(row.outcome)}
</Pill>
</Badge>
) : null}
</div>
</TableCell>
<TableCell>
<Pill tone={categoryTone(row.category)}>
<Badge variant={categoryTone(row.category)}>
{row.category}
</Pill>
</Badge>
</TableCell>
<TableCell
className={`font-mono text-[13px] ${
className={`font-mono text-xs ${
repeat ? 'text-muted-foreground/50' : ''
}`}
>
{repeat ? '〃' : (row.actor_id ?? '—')}
</TableCell>
<TableCell className="font-mono text-[13px]">
<TableCell className="font-mono text-xs">
{row.target_id && row.target_id !== row.actor_id
? row.target_id
: '—'}
@@ -406,9 +406,7 @@ export default function Activity() {
local
</span>
) : (
<span className="font-mono text-[13px]">
{row.ip}
</span>
<span className="font-mono text-xs">{row.ip}</span>
)}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
@@ -419,7 +417,7 @@ export default function Activity() {
</TableRow>,
isOpen ? (
<TableRow key={`${key}:detail`}>
<TableCell colSpan={7} className="py-2">
<TableCell colSpan={7}>
<RowDetail row={row} />
</TableCell>
</TableRow>
+15 -11
View File
@@ -23,8 +23,8 @@ describe('eventLabel', () => {
describe('eventTone', () => {
it('flags removals and refusals', () => {
expect(eventTone('oidc_login_denied')).toBe('danger');
expect(eventTone('agent.deleted')).toBe('danger');
expect(eventTone('oidc_login_denied')).toBe('destructive');
expect(eventTone('agent.deleted')).toBe('destructive');
});
it('flags revocations as a warning, not a failure', () => {
@@ -32,18 +32,22 @@ describe('eventTone', () => {
expect(eventTone('agent.key_regenerated')).toBe('warning');
});
it('marks an admin grant with the brand tone', () => {
expect(eventTone('role_granted')).toBe('brand');
it('marks an admin grant with the brand (default) variant', () => {
expect(eventTone('role_granted')).toBe('default');
});
it('leaves routine events muted', () => {
expect(eventTone('oidc_login')).toBe('muted');
expect(eventTone('anything_else')).toBe('muted');
expect(eventTone('oidc_login')).toBe('neutral');
expect(eventTone('anything_else')).toBe('neutral');
});
});
describe('categoryTone', () => {
it('has a tone for every category the API declares', () => {
it('has a variant for every category the API declares', () => {
expect(categoryTone('identity')).toBe('outline');
expect(categoryTone('access')).toBe('default');
expect(categoryTone('safety')).toBe('destructive');
expect(categoryTone('other')).toBe('neutral');
for (const category of [
'identity',
'access',
@@ -58,7 +62,7 @@ describe('categoryTone', () => {
});
it('falls back for an unknown category', () => {
expect(categoryTone('invented')).toBe('muted');
expect(categoryTone('invented')).toBe('neutral');
});
});
@@ -66,11 +70,11 @@ describe('outcomeTone', () => {
// The values below are the only ones the writers produce: guardrails emit
// triggered/not_evaluated, the device feed emits dispatched.
it('flags a guardrail that fired', () => {
expect(outcomeTone('triggered')).toBe('danger');
expect(outcomeTone('triggered')).toBe('destructive');
});
it('leaves a check that never ran neutral', () => {
expect(outcomeTone('not_evaluated')).toBe('muted');
expect(outcomeTone('not_evaluated')).toBe('neutral');
});
it('reads a dispatched device command as a success', () => {
@@ -78,7 +82,7 @@ describe('outcomeTone', () => {
});
it('leaves an unfamiliar outcome muted', () => {
expect(outcomeTone('something-new')).toBe('muted');
expect(outcomeTone('something-new')).toBe('neutral');
});
});
+16 -41
View File
@@ -1,4 +1,7 @@
import type { VariantProps } from 'class-variance-authority';
import Spinner from '../components/Spinner';
import type { badgeVariants } from '../components/ui/badge';
import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils';
export function Loading() {
@@ -37,36 +40,8 @@ export function StatCard({
);
}
type Tone = 'default' | 'success' | 'danger' | 'muted' | 'brand' | 'warning';
const TONES: Record<Tone, string> = {
default: 'bg-muted text-foreground',
success:
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
muted: 'bg-muted text-muted-foreground',
// brand violet — matches the chart's --primary; used for the Admin role.
brand:
'bg-[#7D54D1]/15 text-[#7D54D1] dark:bg-violet-900/40 dark:text-violet-300',
warning:
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
export function Pill({
tone = 'default',
children,
}: {
tone?: Tone;
children: React.ReactNode;
}) {
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${TONES[tone]}`}
>
{children}
</span>
);
}
// Audit-feed pills render as Badge; these helpers pick its variant.
type Tone = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
export function fmtDate(value?: string | null): string {
return value ? formatDateTime(value) : '—';
@@ -202,25 +177,25 @@ const WARNING_EVENTS = new Set([
]);
export function eventTone(event: string): Tone {
if (DANGER_EVENTS.has(event)) return 'danger';
if (event === 'role_granted') return 'brand';
if (DANGER_EVENTS.has(event)) return 'destructive';
if (event === 'role_granted') return 'default';
if (WARNING_EVENTS.has(event)) return 'warning';
return 'muted';
return 'neutral';
}
// Category facet colors. Mirrors docsgpt/audit_events.py.
const CATEGORY_TONES: Record<string, Tone> = {
identity: 'default',
access: 'brand',
identity: 'outline',
access: 'default',
config: 'warning',
data: 'success',
device: 'warning',
safety: 'danger',
other: 'muted',
safety: 'destructive',
other: 'neutral',
};
export function categoryTone(category: string): Tone {
return CATEGORY_TONES[category] ?? 'muted';
return CATEGORY_TONES[category] ?? 'neutral';
}
// Outcome values the two side journals actually write.
@@ -232,13 +207,13 @@ export function categoryTone(category: string): Tone {
// produced, so every outcome rendered neutral grey -- including a guardrail
// that fired, which is the one signal the merged feed exists to surface.
const OUTCOME_TONES: Record<string, Tone> = {
triggered: 'danger',
not_evaluated: 'muted',
triggered: 'destructive',
not_evaluated: 'neutral',
dispatched: 'success',
};
export function outcomeTone(outcome: string): Tone {
return OUTCOME_TONES[outcome] ?? 'muted';
return OUTCOME_TONES[outcome] ?? 'neutral';
}
const OUTCOME_LABELS: Record<string, string> = {
+4 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService from '../api/services/adminService';
import { Badge } from '../components/ui/badge';
import {
Table,
TableBody,
@@ -12,7 +13,7 @@ import {
TableRow,
} from '../components/ui/table';
import { selectToken } from '../preferences/preferenceSlice';
import { Loading, Pill, fmtDate } from './AdminUI';
import { Loading, fmtDate } from './AdminUI';
type Admin = { user_id: string; granted_at?: string; sources?: string[] };
@@ -69,9 +70,9 @@ export default function Admins() {
<TableCell>
<div className="flex flex-wrap gap-1">
{(a.sources ?? []).map((s) => (
<Pill key={s} tone="muted">
<Badge key={s} variant="neutral">
{s}
</Pill>
</Badge>
))}
</div>
</TableCell>
+3 -5
View File
@@ -88,16 +88,14 @@ export default function Overview() {
<div
className={`rounded-2xl border px-6 py-5 ${
failed > 0
? 'border-red-300 bg-red-50 dark:border-red-900/50 dark:bg-red-950/30'
? 'border-destructive/50 bg-destructive/10'
: 'border-border'
}`}
>
<p className="text-muted-foreground text-sm">Failed logins (7d)</p>
<p
className={`mt-1 text-2xl font-bold tabular-nums ${
failed > 0
? 'text-red-600 dark:text-red-400'
: 'text-foreground'
failed > 0 ? 'text-destructive' : 'text-foreground'
}`}
>
{fmtNumber(failed)}
@@ -105,7 +103,7 @@ export default function Overview() {
{failed > 0 ? (
<Link
to="/admin/audit?event=oidc_login_denied"
className="mt-2 inline-block text-xs text-red-600 hover:underline dark:text-red-400"
className="text-destructive mt-2 inline-block text-xs hover:underline"
>
View in Audit →
</Link>
+10 -16
View File
@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Progress } from '../components/ui/progress';
import {
Select,
SelectContent,
@@ -45,11 +46,7 @@ export function UsageBar({
const fmt = (n: number) => (kind === 'cost' ? fmtUsd(n) : fmtNumber(n));
const percent = usagePercent(budget.used, budget.limit);
const tone =
percent >= 100
? 'bg-red-500'
: percent >= 80
? 'bg-amber-500'
: 'bg-[#7D54D1]';
percent >= 100 ? 'destructive' : percent >= 80 ? 'warning' : 'default';
return (
<div>
<div className="flex items-baseline justify-between gap-3 text-sm">
@@ -60,19 +57,16 @@ export function UsageBar({
</span>
</div>
{budget.limit !== null ? (
<div
className="bg-muted mt-1 h-1.5 w-full overflow-hidden rounded-full"
role="progressbar"
<Progress
className="mt-1"
size="sm"
variant={tone}
value={percent}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<div
className={`h-full rounded-full ${tone}`}
style={{ width: `${percent}%` }}
/>
</div>
/>
) : null}
{caption ? (
<p className="text-muted-foreground mt-1 text-xs">{caption}</p>
@@ -197,7 +191,7 @@ export default function QuotaEditor({
<div className="space-y-4">
<p className="text-muted-foreground text-xs">{inheritHint}</p>
{policy && !policy.enabled ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="text-warning text-xs">
This policy is disabled and is not enforced. Saving keeps it disabled.
</p>
) : null}
@@ -231,7 +225,7 @@ export default function QuotaEditor({
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
+7 -11
View File
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import teamsService from '../api/services/teamsService';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Modal } from '../components/ui/modal';
import {
@@ -22,14 +23,7 @@ import {
TableRow,
} from '../components/ui/table';
import { selectToken } from '../preferences/preferenceSlice';
import {
LoadError,
Loading,
Pill,
fmtDate,
fmtNumber,
fmtRelative,
} from './AdminUI';
import { LoadError, Loading, fmtDate, fmtNumber, fmtRelative } from './AdminUI';
import QuotaEditor from './QuotaEditor';
import { describeBudget, type QuotaPolicy } from './quotaUtils';
@@ -118,8 +112,10 @@ export default function Quotas() {
const bucketPill = (policy: QuotaPolicy) => (
<>
{isAll(policy) ? null : <Pill tone="muted">{policy.bucket} traffic</Pill>}
{policy.enabled ? null : <Pill tone="muted">Disabled</Pill>}
{isAll(policy) ? null : (
<Badge variant="neutral">{policy.bucket} traffic</Badge>
)}
{policy.enabled ? null : <Badge variant="neutral">Disabled</Badge>}
</>
);
@@ -132,7 +128,7 @@ export default function Quotas() {
</p>
{(data.unpriced_models ?? []).length > 0 ? (
<div className="rounded-2xl border border-amber-300 bg-amber-50 px-5 py-4 text-sm dark:border-amber-800 dark:bg-amber-950/30">
<div className="border-warning/50 bg-warning/10 rounded-2xl border px-5 py-4 text-sm">
<p className="text-foreground font-medium">
Models without a price are invisible to cost limits
</p>
+5 -5
View File
@@ -104,7 +104,7 @@ export default function Usage() {
key={range}
variant={range === days ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
shape="pill"
onClick={() => setDays(range)}
>
{range}d
@@ -130,7 +130,7 @@ export default function Usage() {
<Button
variant={metric === 'tokens' ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
shape="pill"
onClick={() => setMetric('tokens')}
>
Tokens
@@ -138,7 +138,7 @@ export default function Usage() {
<Button
variant={metric === 'cost' ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
shape="pill"
onClick={() => setMetric('cost')}
>
Cost
@@ -221,8 +221,8 @@ export default function Usage() {
</TableHead>
<TableBody>
{topUsers.map((user) => (
<TableRow key={user.user_id} className="hover:bg-muted/40">
<TableCell className="font-mono text-[13px] break-all">
<TableRow key={user.user_id}>
<TableCell className="font-mono text-xs break-all">
<button
type="button"
className="hover:text-foreground focus-visible:ring-ring cursor-pointer rounded text-left underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
+2 -2
View File
@@ -74,7 +74,7 @@ function SplitTable({
<TableBody>
{rows.map((row) => (
<TableRow key={row.key}>
<TableCell className="text-[13px] break-all">
<TableCell className="text-xs break-all">
{labels?.[row.key] ?? row.key}
</TableCell>
<TableCell align="right" className="tabular-nums">
@@ -170,7 +170,7 @@ export default function UserUsageModal({
key={range}
variant={range === days ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
shape="pill"
onClick={() => setDays(range)}
>
{range}d
+28 -35
View File
@@ -8,10 +8,11 @@ import {
UserX,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import adminService from '../api/services/adminService';
import ThreeDots from '../assets/three-dots.svg';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import {
DropdownMenu,
@@ -32,10 +33,10 @@ import {
} from '../components/ui/table';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import {
Loading,
Pill,
eventLabel,
fmtDateShort,
fmtNumber,
@@ -62,6 +63,7 @@ type Action = {
const PAGE_SIZE = 25;
export default function Users() {
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [users, setUsers] = useState<AdminUser[]>([]);
const [adminIds, setAdminIds] = useState<Set<string>>(new Set());
@@ -75,10 +77,6 @@ export default function Users() {
const [detail, setDetail] = useState<any | null>(null);
const [usageFor, setUsageFor] = useState<string | null>(null);
const [quotaUserId, setQuotaUserId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{
ok: boolean;
message: string;
} | null>(null);
const [confirm, setConfirm] = useState<{
message: string;
submitLabel: string;
@@ -112,12 +110,15 @@ export default function Users() {
load();
}, [load]);
// Auto-dismiss the inline feedback banner.
useEffect(() => {
if (!feedback) return;
const id = setTimeout(() => setFeedback(null), 4500);
return () => clearTimeout(id);
}, [feedback]);
// The result shows as a toast in the app's shared ToastViewport
// (ActionToast), which auto-dismisses it.
const setFeedback = (feedback: { ok: boolean; message: string }) =>
dispatch(
showActionToast({
variant: feedback.ok ? 'success' : 'destructive',
message: feedback.message,
}),
);
const run =
(fn: () => Promise<Response>, userId: string, successMsg: string) =>
@@ -249,18 +250,6 @@ export default function Users() {
return (
<div className="mt-6">
{feedback ? (
<div
className={`mb-4 rounded-xl border px-4 py-2 text-sm ${
feedback.ok
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-950/30 dark:text-green-300'
: 'border-red-300 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300'
}`}
>
{feedback.message}
</div>
) : null}
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="Filter by user id"
@@ -313,12 +302,11 @@ export default function Users() {
return (
<TableRow
key={u.user_id}
className="hover:bg-muted/40 cursor-pointer"
onClick={() => openDetail(u.user_id)}
>
<TableCell className="max-w-[280px]">
<span
className="block truncate font-mono text-[13px]"
className="block truncate font-mono text-xs"
title={u.user_id}
>
{u.user_id}
@@ -326,9 +314,11 @@ export default function Users() {
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{isAdmin ? <Pill tone="brand">Admin</Pill> : null}
{isAdmin ? (
<Badge variant="default">Admin</Badge>
) : null}
{!u.active ? (
<Pill tone="danger">Inactive</Pill>
<Badge variant="destructive">Inactive</Badge>
) : null}
</div>
</TableCell>
@@ -356,10 +346,10 @@ export default function Users() {
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
disabled={disabled}
className="text-muted-foreground hover:text-foreground h-[35px] w-7"
className="h-[35px] w-7"
aria-label="User actions"
>
<img
@@ -473,7 +463,7 @@ export default function Users() {
size="sm"
onClick={() => {
// Close the detail dialog before any confirm dialog opens
// (avoids stacked modals); the list + banner reflect the result.
// (avoids stacked modals); the list + toast reflect the result.
setDetail(null);
act.perform();
}}
@@ -494,14 +484,17 @@ export default function Users() {
</p>
<div className="flex flex-wrap gap-2">
{(detail.roles ?? []).map((r: string) => (
<Pill key={r} tone={r === 'admin' ? 'brand' : 'muted'}>
<Badge
key={r}
variant={r === 'admin' ? 'default' : 'neutral'}
>
{r}
</Pill>
</Badge>
))}
{detail.user?.active ? (
<Pill tone="success">Active</Pill>
<Badge variant="success">Active</Badge>
) : (
<Pill tone="danger">Inactive</Pill>
<Badge variant="destructive">Inactive</Badge>
)}
</div>
</div>
+8 -5
View File
@@ -17,6 +17,7 @@ import Trash from '../assets/red-trash.svg';
import ThreeDots from '../assets/three-dots.svg';
import UnPin from '../assets/unpin.svg';
import { Avatar } from '../components/ui/avatar';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import {
DropdownMenu,
@@ -388,7 +389,7 @@ export default function AgentCard({
role={agent.status === 'published' ? 'button' : undefined}
tabIndex={agent.status === 'published' ? 0 : undefined}
aria-label={agent.status === 'published' ? agent.name : undefined}
className={`bg-muted hover:bg-accent focus-visible:ring-ring/50 focus-visible:border-ring relative flex h-44 flex-col justify-between rounded-2xl px-4 py-5 outline-none focus-visible:ring-[3px] sm:w-48 sm:px-6 ${agent.status === 'published' && 'cursor-pointer'}`}
className={`bg-muted hover:bg-accent focus-visible:ring-ring/50 focus-visible:border-ring relative flex h-44 flex-col justify-between rounded-2xl px-4 py-5 outline-none focus-visible:ring-3 sm:w-48 sm:px-6 ${agent.status === 'published' && 'cursor-pointer'}`}
onClick={(e) => {
e.stopPropagation();
handleClick();
@@ -453,19 +454,21 @@ export default function AgentCard({
{/* Team access badge — pinned to the top row, left of the ⋯ menu
(right-11 clears the 19px trigger at right-4) so the two align. */}
{agent.ownership === 'team' && (
<span className="bg-muted dark:bg-accent text-muted-foreground absolute top-4 right-11 z-10 flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
<Badge variant="neutral" className="absolute top-4 right-11 z-10">
<Users size={11} strokeWidth={2} aria-hidden="true" />
{agent.team_access === 'editor'
? t('agents.teamBadge.editor')
: t('agents.teamBadge.viewer')}
</span>
</Badge>
)}
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<Avatar
src={agent.image}
alt={`${agent.name}`}
imgClassName="h-7 w-7 rounded-full object-contain"
size="sm"
shape="circle"
imgClassName="h-7 w-7 object-contain"
/>
{agent.status === 'draft' && (
<p className="text-foreground text-xs opacity-50">
@@ -508,7 +511,7 @@ export default function AgentCard({
<Button
type="button"
onClick={() => setExportError(null)}
className="rounded-3xl px-5"
shape="pill"
>
{t('agents.close')}
</Button>
@@ -0,0 +1,56 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import AgentPageHeader from './AgentPageHeader';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('AgentPageHeader sub-nav', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders the tabs as underline tab buttons, marking the current page', () => {
act(() => {
root.render(
<MemoryRouter>
<AgentPageHeader
agentId="a1"
agentName="Renewals"
currentPage="logs"
/>
</MemoryRouter>,
);
});
const nav = container.querySelector(
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
);
const tabs = Array.from(nav?.children ?? []);
expect(tabs).toHaveLength(3);
for (const tab of tabs) {
expect(tab.getAttribute('data-variant')).toBe('tab');
expect(tab.getAttribute('data-size')).toBe('inline');
}
const current = tabs[1];
expect(current.tagName).toBe('SPAN');
expect(current.getAttribute('aria-current')).toBe('page');
expect(current.getAttribute('data-active')).toBe('true');
expect(tabs[0].tagName).toBe('A');
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
});
});
+18 -21
View File
@@ -10,6 +10,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
@@ -100,7 +101,7 @@ export default function AgentPageHeader({
<BreadcrumbSeparator />
<BreadcrumbItem>
{currentPage === 'overview' ? (
<BreadcrumbPage title={displayName} className="w-[16ch] truncate">
<BreadcrumbPage title={displayName} className="w-[16ch]">
{displayName}
</BreadcrumbPage>
) : (
@@ -133,35 +134,31 @@ export default function AgentPageHeader({
>
{tabs.map((tab) => {
const isActive = tab.id === currentPage;
// Always render a 2px bottom border so row height stays constant
// between active/inactive; only the color changes.
const baseClasses =
'whitespace-nowrap border-b-2 pb-1 text-sm font-medium transition-colors';
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
if (isActive) {
return (
<span
<Button
key={tab.id}
aria-current="page"
className={cn(
baseClasses,
'border-primary text-foreground -mb-px',
)}
asChild
variant="tab"
size="inline"
data-active
className="-mb-px"
>
{tab.label}
</span>
<span aria-current="page">{tab.label}</span>
</Button>
);
}
return (
<Link
<Button
key={tab.id}
to={tab.href}
className={cn(
baseClasses,
'text-muted-foreground hover:text-foreground hover:border-border/60 -mb-px border-transparent',
)}
asChild
variant="tab"
size="inline"
className="-mb-px"
>
{tab.label}
</Link>
<Link to={tab.href}>{tab.label}</Link>
</Button>
);
})}
</nav>
+17 -8
View File
@@ -189,8 +189,8 @@ export default function AgentsList() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
label={t('agents.searchPlaceholder')}
labelBgClassName="bg-background"
className="rounded-full"
labelSurface="background"
shape="pill"
leftIcon={
<Search
className="text-muted-foreground size-4"
@@ -432,7 +432,7 @@ function AgentSection({
{config.showNewAgentButton && (
<Button
type="button"
className="rounded-full text-white"
shape="pill"
onClick={() => {
setModalFolderId(null);
setShowAgentTypeModal(true);
@@ -453,6 +453,9 @@ function AgentSection({
// Drilling into a folder is a trail, not a back button — the
// sidebar's back is the only thing that means "leave".
<Breadcrumb>
{/* eslint-disable-next-line shadcn/no-restyle -- the folder
trail stands in for the section <h2>, so it keeps heading
typography (DESIGN.md Approved exceptions). */}
<BreadcrumbList className="text-foreground gap-2 text-lg font-semibold sm:gap-2">
<BreadcrumbItem>
<BreadcrumbLink asChild>
@@ -469,7 +472,9 @@ function AgentSection({
<BreadcrumbSeparator />
<BreadcrumbItem>
{index === breadcrumbItems.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
<BreadcrumbPage className="max-w-[32ch]">
{item.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<button
@@ -525,7 +530,8 @@ function AgentSection({
<Button
type="button"
variant="outline"
className="bg-card shrink-0 rounded-full whitespace-nowrap"
shape="pill"
className="shrink-0 whitespace-nowrap"
onClick={() => {
setIsCreatingFolder(true);
setTimeout(() => newFolderInputRef.current?.focus(), 0);
@@ -538,7 +544,8 @@ function AgentSection({
<Button
type="button"
variant="outline"
className="bg-card shrink-0 rounded-full whitespace-nowrap"
shape="pill"
className="shrink-0 whitespace-nowrap"
onClick={() => setShowImportModal(true)}
>
{t('agents.importAgent')}
@@ -547,7 +554,8 @@ function AgentSection({
{config.showNewAgentButton && (
<Button
type="button"
className="shrink-0 rounded-full whitespace-nowrap text-white"
shape="pill"
className="shrink-0 whitespace-nowrap"
onClick={() => {
setModalFolderId(currentFolderId);
setShowAgentTypeModal(true);
@@ -610,7 +618,8 @@ function AgentSection({
{config.showNewAgentButton && !currentFolderId && (
<Button
type="button"
className="ml-2 rounded-full text-white"
shape="pill"
className="ml-2"
onClick={() => {
setModalFolderId(currentFolderId);
setShowAgentTypeModal(true);
+1 -1
View File
@@ -82,7 +82,7 @@ export default function FolderCard({
role="button"
tabIndex={0}
aria-pressed={isExpanded}
className={`focus-visible:ring-ring/50 focus-visible:border-ring relative flex cursor-pointer items-center justify-between rounded-2xl px-4 py-3 outline-none focus-visible:ring-[3px] sm:w-48 ${
className={`focus-visible:ring-ring/50 focus-visible:border-ring relative flex cursor-pointer items-center justify-between rounded-2xl px-4 py-3 outline-none focus-visible:ring-3 sm:w-48 ${
isExpanded ? 'bg-accent' : 'bg-muted hover:bg-accent'
}`}
onClick={() => onToggleExpand(folder.id)}
+301
View File
@@ -0,0 +1,301 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const mockState = {
preference: {
token: null,
sourceDocs: [],
selectedAgent: null,
prompts: [],
agentFolders: [],
},
};
vi.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown) => selector(mockState),
useDispatch: () => vi.fn(),
}));
const jsonResponse = (body: unknown, ok = true) =>
Promise.resolve({ ok, json: () => Promise.resolve(body) });
vi.mock('../api/services/userService', () => ({
default: {
getUserTools: () =>
jsonResponse({
tools: [
{
id: 'tool-1',
name: 'remote_device',
display_name: 'Laptop',
config: { device_id: 'device-1' },
},
],
}),
getAgentFolders: () => jsonResponse({ folders: [] }),
getAgent: () => jsonResponse({}),
createAgent: () => jsonResponse({ message: 'Name is taken' }, false),
updateAgent: () => jsonResponse({}),
deleteAgent: () => jsonResponse({}),
createPrompt: () => jsonResponse({}),
},
}));
vi.mock('../api/services/devicesService', () => ({
default: {
list: () =>
Promise.resolve({
devices: [{ id: 'device-1', last_seen_at: new Date().toISOString() }],
}),
},
}));
vi.mock('../api/services/modelService', () => ({
default: {
getModels: () => jsonResponse({ models: [] }),
transformModels: () => [],
},
}));
// The pickers render only their trigger here, plus each item's rich
// description so the device status pill can be checked.
vi.mock('../components/MultiSelectPopover', () => ({
MultiSelectPopover: ({
trigger,
items,
}: {
trigger: React.ReactNode;
items: MultiSelectPopoverItem[];
}) => (
<div data-testid="picker">
{trigger}
{items.map((item) => (
<div key={item.id}>{item.descriptionNode}</div>
))}
</div>
),
}));
vi.mock('./workflow/WorkflowBuilder', () => ({ default: () => null }));
vi.mock('./AgentPreview', () => ({ default: () => null }));
vi.mock('../settings/Prompts', () => ({ default: () => null }));
vi.mock('./components/GuardrailsSection', () => ({
default: () => null,
guardrailsIncomplete: () => false,
}));
vi.mock('../upload/Upload', () => ({ default: () => null }));
vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
vi.mock('../navigation/SectionPills', () => ({ default: () => null }));
vi.mock('../navigation/SectionPageHeader', () => ({
CurrentSectionHeader: () => null,
}));
vi.mock('../components/FileUpload', () => ({ FileUpload: () => null }));
vi.mock('../components/SourcesPopoverFooter', () => ({ default: () => null }));
vi.mock('../components/ToolIcon', () => ({ default: () => null }));
import NewAgent from './NewAgent';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const setNativeValue = (
el: HTMLInputElement | HTMLTextAreaElement,
value: string,
) => {
const proto =
el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
};
describe('NewAgent form', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async () => {
await act(async () => {
root.render(
<MemoryRouter>
<NewAgent mode="new" />
</MemoryRouter>,
);
});
};
const buttonByText = (text: string) =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes(text),
)!;
// Decision 63 (3): every field, picker and button in the form is 42px.
it('renders the name field as a default-size (42px) pill Input', async () => {
await render();
const name = container.querySelector<HTMLInputElement>(
'input[placeholder="agents.form.placeholders.agentName"]',
)!;
expect(name.getAttribute('data-slot')).toBe('input');
expect(name.getAttribute('data-size')).toBe('default');
expect(name.getAttribute('data-shape')).toBe('pill');
});
it('renders the Add prompt button at the field height', async () => {
await render();
const add = buttonByText('agents.form.buttons.add');
expect(add.getAttribute('data-variant')).toBe('outline-primary');
expect(add.getAttribute('data-size')).toBe('field');
expect(add.getAttribute('data-shape')).toBe('pill');
});
it('renders the token and request limits as default-size pill Inputs', async () => {
await render();
await act(async () =>
buttonByText('agents.form.sections.advanced').click(),
);
for (const key of ['enterTokenLimit', 'enterRequestLimit']) {
const field = container.querySelector(
`input[placeholder="agents.form.placeholders.${key}"]`,
)!;
expect(field.getAttribute('data-size')).toBe('default');
expect(field.getAttribute('data-shape')).toBe('pill');
}
});
// Decision 64: the Advanced header is a section-toggle with a leading
// lucide chevron; the panel draws the focus ring.
it('renders the Advanced header as a section-toggle', async () => {
await render();
const toggle = buttonByText('agents.form.sections.advanced');
expect(toggle.getAttribute('data-variant')).toBe('section-toggle');
expect(toggle.getAttribute('data-size')).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
const chevron = toggle.firstElementChild!;
expect(chevron.matches('svg.lucide-chevron-right')).toBe(true);
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
expect(toggle.querySelector('h2')).toBeNull();
expect(toggle.parentElement!.tagName).toBe('H2');
const panel = toggle.closest('.bg-card')!;
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
await act(async () => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(chevron.getAttribute('class')).toContain('rotate-90');
});
it('renders the description as a large ui Textarea', async () => {
await render();
const description = container.querySelector(
'textarea[placeholder="agents.form.placeholders.describeAgent"]',
)!;
expect(description.getAttribute('data-slot')).toBe('textarea');
expect(description.getAttribute('data-size')).toBe('lg');
});
it('renders the pickers as pill comboboxes, muted while empty', async () => {
await render();
const triggers = Array.from(
container.querySelectorAll('[data-testid="picker"] > button'),
);
// Sources, tools, models.
expect(triggers).toHaveLength(3);
for (const trigger of triggers) {
expect(trigger.getAttribute('data-variant')).toBe('combobox');
expect(trigger.getAttribute('data-size')).toBe('field');
expect(trigger.getAttribute('data-shape')).toBe('pill');
expect(trigger.hasAttribute('data-placeholder')).toBe(true);
expect(trigger.querySelector('span.truncate')).not.toBeNull();
}
});
it('shows the device status as a Badge', async () => {
await render();
const badge = container.querySelector('[data-slot="badge"]');
expect(badge?.textContent).toBe('settings.devices.online');
expect(badge?.getAttribute('data-variant')).toBe('success');
});
it('renders Save draft and Publish as pill button variants', async () => {
await render();
const draft = buttonByText('agents.form.buttons.saveDraft');
expect(draft.getAttribute('data-variant')).toBe('outline-primary');
expect(draft.getAttribute('data-shape')).toBe('pill');
const publish = buttonByText('agents.form.buttons.publish');
expect(publish.getAttribute('data-variant')).toBe('default');
expect(publish.getAttribute('data-shape')).toBe('pill');
expect(publish.disabled).toBe(true);
});
it('shows a failed save as a destructive alert with an icon', async () => {
await render();
const draft = buttonByText('agents.form.buttons.saveDraft');
await act(async () => draft.click());
const alert = container.querySelector('[role="alert"]')!;
expect(alert.textContent).toContain('Name is taken');
// ui/alert's destructive variant.
expect(alert.className).toContain('border-destructive/50');
expect(alert.className).toContain('bg-destructive/10');
expect(alert.querySelector('svg.lucide-circle-x')).not.toBeNull();
// Decision 69 (b): the notice sits above the form panel, not in the
// header's button row.
expect(draft.parentElement?.contains(alert)).toBe(false);
});
it('marks JSON schema validity with token colours and lucide icons', async () => {
await render();
await act(async () =>
buttonByText('agents.form.sections.advanced').click(),
);
const schema = Array.from(container.querySelectorAll('textarea')).find(
(el) => el.className.includes('font-mono'),
)!;
expect(schema.getAttribute('data-slot')).toBe('textarea');
expect(schema.getAttribute('data-size')).toBe('lg');
await act(async () => setNativeValue(schema, '{ not json'));
const invalid = Array.from(container.querySelectorAll('div')).find(
(el) => el.textContent === 'agents.form.advanced.invalidJson',
)!;
expect(invalid.className).toContain('text-destructive');
expect(invalid.querySelector('svg.lucide-circle-x')).not.toBeNull();
await act(async () => setNativeValue(schema, '{}'));
const valid = Array.from(container.querySelectorAll('div')).find(
(el) => el.textContent === 'agents.form.advanced.validJson',
)!;
expect(valid.className).toContain('text-success');
expect(valid.querySelector('svg.lucide-circle-check')).not.toBeNull();
});
it('shows the preview placeholder illustration as decorative images', async () => {
await render();
const images = container.querySelectorAll('img[alt=""]');
expect(images).toHaveLength(2);
for (const img of images) {
expect(img.getAttribute('aria-hidden')).toBe('true');
}
});
});
+132 -131
View File
@@ -1,5 +1,10 @@
import isEqual from 'lodash/isEqual';
import { MoreHorizontal } from 'lucide-react';
import {
ChevronRight,
CircleCheck,
CircleX,
MoreHorizontal,
} from 'lucide-react';
import React, {
useCallback,
useEffect,
@@ -11,6 +16,8 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -28,10 +35,14 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import devicesService from '../api/services/devicesService';
import modelService from '../api/services/modelService';
import userService from '../api/services/userService';
import ScienceSparkDarkIcon from '../assets/science-spark-dark.svg';
import ScienceSparkIcon from '../assets/science-spark.svg';
import SourceIcon from '../assets/source.svg';
import { FileUpload } from '../components/FileUpload';
import {
@@ -589,17 +600,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const meta = devicesById.get(deviceId);
const online = meta?.online ?? false;
base.descriptionNode = (
<span
className={`mt-0.5 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
online
? 'bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-200 text-gray-700 dark:bg-gray-700/40 dark:text-gray-300'
}`}
<Badge
variant={online ? 'success' : 'neutral'}
className="mt-0.5"
>
{online
? t('settings.devices.online')
: t('settings.devices.offline')}
</span>
</Badge>
);
}
return base;
@@ -808,33 +816,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<div className="flex w-full flex-wrap items-center justify-between gap-2">
{showAgentNav ? <CurrentSectionHeader /> : <span aria-hidden />}
<div className="flex flex-wrap items-center gap-2">
{submitError && (
<div
role="alert"
className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400"
>
<span className="h-4 w-4 shrink-0 bg-[url('/src/assets/circle-x.svg')] bg-contain bg-center bg-no-repeat" />
{submitError}
</div>
)}
{hasChanges && (
<Button
type="button"
variant="ghost"
onClick={handleCancel}
className="text-primary dark:text-foreground rounded-3xl px-2 hover:bg-transparent"
>
<Button type="button" variant="link" onClick={handleCancel}>
{t('agents.form.buttons.cancel')}
</Button>
)}
{modeConfig[effectiveMode].showSaveDraft && (
<Button
type="button"
variant="outline-primary"
shape="pill"
disabled={isDraftBlocked()}
onClick={handleSaveDraft}
className={`border-primary text-primary hover:bg-primary/90 min-w-28 rounded-3xl border border-solid bg-transparent px-5 whitespace-nowrap hover:text-white ${
isDraftBlocked() ? 'disabled:opacity-30' : ''
}`}
className="min-w-28"
>
<span className="flex items-center justify-center transition-all duration-200">
{draftLoading ? (
@@ -849,7 +843,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
type="button"
disabled={!isPublishable() || !hasChanges}
onClick={handlePublish}
className={`${!isPublishable() || !hasChanges ? 'disabled:opacity-30' : ''} min-w-28 rounded-3xl px-5 whitespace-nowrap text-white`}
shape="pill"
className="min-w-28"
>
<span className="flex items-center justify-center transition-all duration-200">
{publishLoading ? (
@@ -889,6 +884,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</div>
</div>
{showAgentNav && <SectionPills className="mt-4" />}
{submitError && (
<Alert variant="destructive" className="mt-3">
<CircleX aria-hidden="true" className="size-4" />
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
<div className="bg-muted dark:bg-background mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-2xl p-5 max-[1179px]:overflow-visible min-[1180px]:grid min-[1180px]:gap-5 min-[1180px]:overflow-hidden">
<div className="scrollbar-overlay col-span-2 flex flex-col gap-5 max-[1179px]:overflow-visible min-[1180px]:max-h-full min-[1180px]:overflow-y-auto min-[1180px]:pr-3">
<div className="bg-card rounded-2xl px-6 py-3">
@@ -896,14 +897,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{t('agents.form.sections.meta')}
</h2>
<Input
className="bg-card mt-3 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm"
shape="pill"
className="mt-3"
type="text"
value={agent.name}
placeholder={t('agents.form.placeholders.agentName')}
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
<textarea
className="border-border text-foreground dark:text-foreground dark:placeholder:text-muted-foreground bg-card dark:border-border focus-visible:ring-ring/50 focus-visible:border-ring mt-3 h-32 w-full rounded-xl border px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 focus-visible:ring-[3px]"
<Textarea
size="lg"
className="mt-3 h-32"
placeholder={t('agents.form.placeholders.describeAgent')}
value={agent.description}
onChange={(e) =>
@@ -913,17 +916,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<div className="mt-3">
<FileUpload
showPreview
className="bg-card"
size="compact"
previewSize={56}
onUpload={handleUpload}
onRemove={() => setImageFile(null)}
uploadText={[
{
text: t('agents.form.upload.clickToUpload'),
colorClass: 'text-primary',
highlight: true,
},
{
text: t('agents.form.upload.dragAndDrop'),
colorClass: 'text-[#525252]',
},
]}
/>
@@ -960,16 +963,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trigger={
<Button
type="button"
variant="outline"
variant="combobox"
size="field"
shape="pill"
ref={sourceAnchorButtonRef}
title={selectedSourceNames.join(', ')}
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
selectedSourceIds.size > 0
? 'text-foreground dark:text-foreground'
: 'dark:text-muted-foreground text-gray-400'
}`}
data-placeholder={
selectedSourceIds.size > 0 ? undefined : ''
}
className="w-full justify-start text-left"
>
{sourceTriggerLabel}
<span className="truncate">{sourceTriggerLabel}</span>
</Button>
}
/>
@@ -1016,8 +1020,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</div>
<Button
type="button"
variant="outline-primary"
size="field"
shape="pill"
onClick={() => setAddPromptModal('ACTIVE')}
className="border-primary text-primary hover:bg-primary/90 h-auto min-w-20 shrink-0 basis-full rounded-3xl border border-solid bg-transparent px-5 py-3 whitespace-nowrap hover:text-white sm:basis-auto"
className="min-w-20 shrink-0 basis-full sm:basis-auto"
>
{t('agents.form.buttons.add')}
</Button>
@@ -1059,20 +1066,21 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trigger={
<Button
type="button"
variant="outline"
variant="combobox"
size="field"
shape="pill"
ref={toolAnchorButtonRef}
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
selectedTools.length > 0
? 'text-foreground dark:text-foreground'
: 'dark:text-muted-foreground text-gray-400'
}`}
data-placeholder={selectedTools.length > 0 ? undefined : ''}
className="w-full justify-start text-left"
>
{selectedTools.length > 0
? selectedTools
.map((tool) => getToolDisplayName(tool))
.filter(Boolean)
.join(', ')
: t('agents.form.placeholders.selectTools')}
<span className="truncate">
{selectedTools.length > 0
? selectedTools
.map((tool) => getToolDisplayName(tool))
.filter(Boolean)
.join(', ')
: t('agents.form.placeholders.selectTools')}
</span>
</Button>
}
/>
@@ -1089,10 +1097,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setAgent({ ...agent, agent_type: value })
}
>
<SelectTrigger
className="w-full rounded-3xl px-5 py-3 text-sm"
size="lg"
>
<SelectTrigger className="w-full" shape="pill" size="lg">
<SelectValue
placeholder={t('agents.form.placeholders.selectType')}
/>
@@ -1148,20 +1153,23 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trigger={
<Button
type="button"
variant="outline"
variant="combobox"
size="field"
shape="pill"
ref={modelAnchorButtonRef}
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
selectedModelIds.size > 0
? 'text-foreground dark:text-foreground'
: 'dark:text-muted-foreground text-gray-400'
}`}
data-placeholder={
selectedModelIds.size > 0 ? undefined : ''
}
className="w-full justify-start text-left"
>
{selectedModelIds.size > 0
? availableModels
.filter((m) => selectedModelIds.has(m.id))
.map((m) => m.display_name)
.join(', ')
: t('agents.form.placeholders.selectModels')}
<span className="truncate">
{selectedModelIds.size > 0
? availableModels
.filter((m) => selectedModelIds.has(m.id))
.map((m) => m.display_name)
.join(', ')
: t('agents.form.placeholders.selectModels')}
</span>
</Button>
}
/>
@@ -1176,10 +1184,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setAgent({ ...agent, default_model_id: value })
}
>
<SelectTrigger
className="w-full rounded-3xl px-5 py-3 text-sm"
size="lg"
>
<SelectTrigger className="w-full" shape="pill" size="lg">
<SelectValue
placeholder={t(
'agents.form.placeholders.selectDefaultModel',
@@ -1200,49 +1205,44 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)}
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<Button
type="button"
variant="ghost"
onClick={() =>
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
}
className="h-auto w-full justify-between px-0 py-0 text-left hover:bg-transparent"
>
<div>
<h2 className="text-lg font-semibold">
<div className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset">
{/* The heading wraps the toggle: a button's children are
presentational, so a heading inside it is lost to screen readers. */}
<h2>
<Button
type="button"
variant="section-toggle"
onClick={() =>
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
}
size="sm"
aria-expanded={isAdvancedSectionExpanded}
className="-ml-3 w-fit justify-start"
>
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform',
isAdvancedSectionExpanded && 'rotate-90',
)}
/>
<span className="text-lg font-semibold">
{t('agents.form.sections.advanced')}
</h2>
</div>
<div className="ml-4 flex items-center">
<svg
className={`size-5 transform transition-transform duration-200 ${
isAdvancedSectionExpanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</Button>
</span>
</Button>
</h2>
{isAdvancedSectionExpanded && (
<div className="mt-3">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.jsonSchema')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.advanced.jsonSchemaDescription')}
</p>
</div>
<textarea
<Textarea
size="lg"
value={jsonSchemaText}
onChange={(e) => validateAndSetJsonSchema(e.target.value)}
placeholder={`{
@@ -1255,23 +1255,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
"additionalProperties": false
}`}
rows={9}
className={`border-border text-foreground dark:text-foreground bg-card dark:border-border focus-visible:ring-ring/50 focus-visible:border-ring mt-2 w-full rounded-2xl border px-4 py-3 font-mono text-sm outline-hidden focus-visible:ring-[3px]`}
className="mt-2 font-mono"
/>
{jsonSchemaText.trim() !== '' && (
<div
className={`mt-2 flex items-center gap-2 text-sm ${
jsonSchemaValid
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
jsonSchemaValid ? 'text-success' : 'text-destructive'
}`}
>
<span
className={`h-4 w-4 bg-contain bg-center bg-no-repeat ${
jsonSchemaValid
? "bg-[url('/src/assets/circle-check.svg')]"
: "bg-[url('/src/assets/circle-x.svg')]"
}`}
/>
{jsonSchemaValid ? (
<CircleCheck className="size-4" aria-hidden="true" />
) : (
<CircleX className="size-4" aria-hidden="true" />
)}
{jsonSchemaValid
? t('agents.form.advanced.validJson')
: t('agents.form.advanced.invalidJson')}
@@ -1284,7 +1280,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<h2 className="text-sm font-medium">
{t('agents.form.advanced.tokenLimiting')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.advanced.tokenLimitingDescription')}
</p>
</div>
@@ -1315,11 +1311,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
disabled={!agent.limited_token_mode}
placeholder={t('agents.form.placeholders.enterTokenLimit')}
className={`bg-card mt-2 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm ${
!agent.limited_token_mode
? 'cursor-not-allowed opacity-50'
: ''
}`}
shape="pill"
className="mt-2"
/>
</div>
@@ -1329,7 +1322,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<h2 className="text-sm font-medium">
{t('agents.form.advanced.requestLimiting')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.advanced.requestLimitingDescription')}
</p>
</div>
@@ -1362,11 +1355,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
placeholder={t(
'agents.form.placeholders.enterRequestLimit',
)}
className={`bg-card mt-2 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm ${
!agent.limited_request_mode
? 'cursor-not-allowed opacity-50'
: ''
}`}
shape="pill"
className="mt-2"
/>
</div>
@@ -1376,7 +1366,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<h2 className="text-sm font-medium">
{t('agents.form.advanced.systemPromptOverride')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground mt-1 text-xs">
{t(
'agents.form.advanced.systemPromptOverrideDescription',
)}
@@ -1508,7 +1498,18 @@ function AgentPreviewArea() {
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<span className="block h-12 w-12 bg-[url('/src/assets/science-spark.svg')] bg-contain bg-center bg-no-repeat transition-all dark:bg-[url('/src/assets/science-spark-dark.svg')]" />{' '}
<img
src={ScienceSparkIcon}
alt=""
aria-hidden="true"
className="block size-12 dark:hidden"
/>
<img
src={ScienceSparkDarkIcon}
alt=""
aria-hidden="true"
className="hidden size-12 dark:block"
/>{' '}
<p className="text-muted-foreground text-xs">
{t('agents.form.preview.publishedPreview')}
</p>
+3 -1
View File
@@ -155,7 +155,9 @@ export default function SharedAgent() {
<Avatar
src={sharedAgent.image}
alt="agent-logo"
imgClassName="h-6 w-6 rounded-full object-contain"
shape="circle"
className="overflow-hidden"
imgClassName="h-6 w-6 object-contain"
/>
<h2 className="text-foreground text-lg font-semibold">
{sharedAgent.name}
+5 -3
View File
@@ -29,8 +29,9 @@ export default function SharedAgentCard({
<Avatar
src={agent.image}
alt={agent.name}
className="h-full w-full"
imgClassName="h-full w-full rounded-full object-contain"
shape="circle"
className="size-full overflow-hidden"
imgClassName="size-full object-contain"
/>
</div>
<div className="flex max-h-[92px] flex-1 flex-col gap-px">
@@ -46,8 +47,9 @@ export default function SharedAgentCard({
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={onEdit}
className="shrink-0 gap-1.5 rounded-full"
className="shrink-0"
aria-label={t('agents.edit')}
>
<img src={EditIcon} alt="" className="h-3.5 w-3.5" />
@@ -0,0 +1,72 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const navigate = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigate,
}));
import AgentTypeModal from './AgentTypeModal';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('AgentTypeModal', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
navigate.mockReset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (onClose = vi.fn()) => {
await act(async () => {
root.render(<AgentTypeModal isOpen onClose={onClose} folderId="f1" />);
});
return onClose;
};
const tiles = () =>
Array.from(
document.body.querySelectorAll<HTMLButtonElement>(
'[data-slot="option-card"]',
),
);
it('renders the two agent types as OptionCards', async () => {
await render();
const found = tiles();
expect(found).toHaveLength(2);
expect(found[0].textContent).toContain('Classic Agent');
expect(found[1].textContent).toContain('Workflow Agent');
expect(found[0].querySelector('[data-slot="option-card-icon"]')).not.toBe(
null,
);
expect(found[0].querySelector('[data-slot="card-description"]')).not.toBe(
null,
);
});
it('navigates to the workflow builder and closes', async () => {
const onClose = await render();
await act(async () => tiles()[1].click());
expect(navigate).toHaveBeenCalledTimes(1);
expect(String(navigate.mock.calls[0][0])).toContain('workflow');
expect(onClose).toHaveBeenCalled();
});
it('navigates to the classic form and closes', async () => {
const onClose = await render();
await act(async () => tiles()[0].click());
expect(navigate).toHaveBeenCalledTimes(1);
expect(String(navigate.mock.calls[0][0])).not.toContain('workflow');
expect(onClose).toHaveBeenCalled();
});
});
@@ -3,6 +3,7 @@ import { agentNewPath } from '../paths';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../../components/ui/modal';
import { OptionCard } from '../../components/ui/option-card';
interface AgentTypeModalProps {
isOpen: boolean;
@@ -35,43 +36,18 @@ export default function AgentTypeModal({
size="md"
>
<div className="flex flex-col gap-4">
<button
type="button"
<OptionCard
icon={<Bot />}
title="Classic Agent"
description="Create a standard AI agent with a single model, tools, and knowledge sources"
onClick={() => handleSelect('normal')}
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 focus-visible:border-ring focus-visible:ring-ring/50 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all outline-none focus-visible:ring-[3px]"
>
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary/90 flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
<Bot size={28} />
</div>
<div className="flex-1">
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
Classic Agent
</h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
Create a standard AI agent with a single model, tools, and
knowledge sources
</p>
</div>
</button>
<button
type="button"
/>
<OptionCard
icon={<Workflow />}
title="Workflow Agent"
description="Design complex multi-step workflows with different models, conditional logic, and state management"
onClick={() => handleSelect('workflow')}
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 focus-visible:border-ring focus-visible:ring-ring/50 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all outline-none focus-visible:ring-[3px]"
>
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
<Workflow size={28} />
</div>
<div className="flex-1">
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
Workflow Agent
</h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
Design complex multi-step workflows with different models,
conditional logic, and state management
</p>
</div>
</button>
/>
</div>
</Modal>
);
@@ -0,0 +1,92 @@
import { configureStore } from '@reduxjs/toolkit';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Provider } from 'react-redux';
// A stable `t`: the component lists it as an effect dependency.
vi.mock('react-i18next', () => {
const t = (key: string) => key;
return { useTranslation: () => ({ t }) };
});
const events = [
{ id: '1', action: 'block', outcome: 'triggered' },
{ id: '2', action: 'redact', outcome: 'triggered' },
{ id: '3', action: 'flag', outcome: 'triggered' },
{ id: '4', action: 'flag', outcome: 'not_evaluated' },
].map((e) => ({
...e,
check_name: `check-${e.id}`,
stage: 'input',
category: null,
detail: '',
created_at: '2026-09-01T00:00:00Z',
}));
vi.mock('../../api/services/userService', () => ({
default: {
getGuardrailEvents: () =>
Promise.resolve({
json: () => Promise.resolve({ success: true, events }),
}),
getGuardrailSummary: () =>
Promise.resolve({
json: () =>
Promise.resolve({
success: true,
breakdown: [],
totals: { blocked: 1, redacted: 1, flagged: 1, not_evaluated: 1 },
}),
}),
},
}));
import GuardrailEvents from './GuardrailEvents';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const makeStore = () =>
configureStore({ reducer: { preference: () => ({ token: null }) } });
describe('GuardrailEvents tones', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(async () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(
<Provider store={makeStore()}>
<GuardrailEvents agentId="a1" />
</Provider>,
);
});
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('colours outcome badges by meaning', () => {
const variants = Array.from(
container.querySelectorAll(
'[data-testid="guardrail-events-rows"] [data-slot="badge"]',
),
).map((b) => b.getAttribute('data-variant'));
expect(variants).toEqual(['destructive', 'info', 'warning', 'neutral']);
});
it('colours the stat tiles to match', () => {
const tone = (id: string) =>
container.querySelector(`[data-testid="${id}"] p:last-child`)?.className;
expect(tone('guardrail-stat-blocked')).toContain('text-destructive');
expect(tone('guardrail-stat-redacted')).toContain('text-info');
expect(tone('guardrail-stat-flagged')).toContain('text-warning');
expect(tone('guardrail-stat-not-evaluated')).toContain(
'text-muted-foreground',
);
});
});
@@ -9,6 +9,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import userService from '../../api/services/userService';
import SkeletonLoader from '../../components/SkeletonLoader';
@@ -32,15 +35,18 @@ const ACTION_KEYS: Record<string, string> = {
block: 'agents.form.guardrails.actions.block',
};
/** Colour by consequence, so "we refused" reads differently from "we noticed". */
function actionTone(action: string, outcome: string): string {
if (outcome === 'not_evaluated')
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
if (action === 'block')
return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300';
if (action === 'redact')
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300';
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300';
/**
* Colour by meaning, matching the status tokens used across the app: a block
* is a refusal, a flag needs review, a redaction is informational.
*/
function actionTone(
action: string,
outcome: string,
): 'neutral' | 'destructive' | 'warning' | 'info' {
if (outcome === 'not_evaluated') return 'neutral';
if (action === 'block') return 'destructive';
if (action === 'redact') return 'info';
return 'warning';
}
type Props = { agentId?: string };
@@ -122,8 +128,9 @@ export default function GuardrailEvents({ agentId }: Props) {
onValueChange={(value) => setDays(Number(value))}
>
<SelectTrigger
className="w-[150px] rounded-3xl px-5 py-3 text-sm"
className="w-[150px]"
size="lg"
shape="pill"
data-testid="guardrail-events-window"
>
<SelectValue />
@@ -148,21 +155,21 @@ export default function GuardrailEvents({ agentId }: Props) {
value={totals?.blocked}
loading={loading}
testId="guardrail-stat-blocked"
tone="text-red-700 dark:text-red-400"
tone="text-destructive"
/>
<StatTile
label={t('agents.guardrailEvents.redacted')}
value={totals?.redacted}
loading={loading}
testId="guardrail-stat-redacted"
tone="text-amber-700 dark:text-amber-400"
tone="text-info"
/>
<StatTile
label={t('agents.guardrailEvents.flagged')}
value={totals?.flagged}
loading={loading}
testId="guardrail-stat-flagged"
tone="text-emerald-700 dark:text-emerald-400"
tone="text-warning"
/>
<StatTile
label={t('agents.guardrailEvents.notEvaluated')}
@@ -179,8 +186,9 @@ export default function GuardrailEvents({ agentId }: Props) {
<div className="mt-6 flex flex-wrap items-center gap-3">
<Select value={checkFilter} onValueChange={setCheckFilter}>
<SelectTrigger
className="w-[170px] rounded-3xl px-5 py-3 text-sm"
className="w-[170px]"
size="lg"
shape="pill"
data-testid="guardrail-events-check-filter"
>
<SelectValue />
@@ -198,8 +206,9 @@ export default function GuardrailEvents({ agentId }: Props) {
</Select>
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
<SelectTrigger
className="w-[170px] rounded-3xl px-5 py-3 text-sm"
className="w-[170px]"
size="lg"
shape="pill"
data-testid="guardrail-events-outcome-filter"
>
<SelectValue />
@@ -288,16 +297,11 @@ export default function GuardrailEvents({ agentId }: Props) {
{t(STAGE_KEYS[event.stage] ?? event.stage)}
</td>
<td className="px-3 py-2">
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${actionTone(
event.action,
event.outcome,
)}`}
>
<Badge variant={actionTone(event.action, event.outcome)}>
{event.outcome === 'not_evaluated'
? t('agents.guardrailEvents.notEvaluated')
: t(ACTION_KEYS[event.action] ?? event.action)}
</span>
</Badge>
</td>
<td className="text-muted-foreground max-w-[28rem] px-3 py-2">
{event.detail || '—'}
@@ -345,7 +349,7 @@ function StatTile({
<SkeletonLoader count={1} />
</div>
) : (
<p className={`mt-1 text-xl font-semibold ${tone}`}>{value ?? 0}</p>
<p className={cn('mt-1 text-xl font-semibold', tone)}>{value ?? 0}</p>
)}
</div>
);
@@ -381,12 +385,10 @@ function ByCheck({ summary }: { summary: GuardrailSummary }) {
{rows.map(([check, total]) => (
<div key={check} className="flex items-center gap-3">
<span className="w-32 shrink-0 truncate text-xs">{check}</span>
<div className="bg-border/60 h-2 flex-1 overflow-hidden rounded-full">
<div
className="bg-violets-are-blue h-full rounded-full"
style={{ width: `${max ? (total / max) * 100 : 0}%` }}
/>
</div>
<Progress
className="flex-1"
value={max ? (total / max) * 100 : 0}
/>
<span className="text-muted-foreground w-10 shrink-0 text-right text-xs">
{total}
</span>
@@ -0,0 +1,275 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { GuardrailCatalog, GuardrailsConfig } from '../types';
const getGuardrailCatalog = vi.fn();
vi.mock('../../api/services/userService', () => ({
default: {
getGuardrailCatalog: (...args: unknown[]) => getGuardrailCatalog(...args),
},
}));
// A stable `t`: the component refetches the catalog whenever `t` changes.
const t = (key: string) => key;
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t }),
}));
import GuardrailsSection from './GuardrailsSection';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const catalog: GuardrailCatalog = {
enabled: false,
checks: [
{
name: 'pii',
label: 'PII',
description: 'Detects personal data',
stages: ['input', 'output'],
supports_redaction: true,
latency_hint_ms: 50,
remote: false,
available: false,
},
{
name: 'policy',
label: 'Policy',
description: 'LLM judge',
stages: ['input'],
supports_redaction: false,
latency_hint_ms: 1500,
remote: true,
available: true,
},
],
stages: ['input', 'output'],
modes: ['monitor_only', 'scan_all'],
actions_by_stage: {
input: ['flag', 'block'],
retrieval: ['flag'],
tool_result: ['flag'],
output: ['flag', 'redact', 'block'],
},
default_block_message: 'No',
pii_entities: ['EMAIL', 'PHONE'],
default_pii_entities: ['EMAIL'],
floor: {
enabled: true,
mode: 'scan_all',
fail_open: true,
timeout_ms: 2000,
block_message: '',
controls: [
{
check: 'pii',
stage: 'output',
action: 'block',
enabled: true,
settings: {},
},
],
},
};
const config: GuardrailsConfig = {
enabled: true,
mode: 'monitor_only',
fail_open: true,
timeout_ms: 2000,
block_message: 'Sorry',
controls: [
{
check: 'pii',
stage: 'input',
action: 'flag',
enabled: true,
settings: { entities: ['EMAIL'] },
},
{
check: 'policy',
stage: 'input',
action: 'flag',
enabled: true,
settings: { policy: '' },
},
{
check: 'gone',
stage: 'input',
action: 'flag',
enabled: true,
settings: {},
},
],
};
describe('GuardrailsSection', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
getGuardrailCatalog.mockReset();
getGuardrailCatalog.mockResolvedValue({
json: async () => ({ success: true, ...catalog }),
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const q = (testId: string) =>
container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
const render = async (
props: Partial<Parameters<typeof GuardrailsSection>[0]> = {},
) => {
await act(async () => {
root.render(
<GuardrailsSection
value={config}
onChange={() => undefined}
token="tok"
disabledNotice="Read only"
{...props}
/>,
);
});
await act(async () => {
q('guardrails-toggle')?.click();
});
};
it('announces whether the section is open with aria-expanded', async () => {
await render();
expect(q('guardrails-toggle')?.getAttribute('aria-expanded')).toBe('true');
await act(async () => {
q('guardrails-toggle')?.click();
});
expect(q('guardrails-toggle')?.getAttribute('aria-expanded')).toBe('false');
});
// Decision 64: the header is a section-toggle with a leading lucide
// chevron; the badges sit beside it and the panel draws the focus ring.
it('renders the header as a section-toggle with the badges beside it', async () => {
await render();
const toggle = q('guardrails-toggle')!;
expect(toggle.dataset.variant).toBe('section-toggle');
expect(toggle.dataset.size).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
const chevron = toggle.firstElementChild!;
expect(chevron.matches('svg.lucide-chevron-right')).toBe(true);
expect(chevron.getAttribute('class')).toContain('rotate-90');
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
// The heading wraps the button (a button's children are presentational,
// so a heading inside it would be lost to screen readers).
expect(toggle.querySelector('h2')).toBeNull();
const heading = toggle.closest('h2')!;
expect(heading.textContent).toBe('agents.form.sections.guardrails');
const active = q('guardrails-active-badge')!;
const incomplete = q('guardrails-incomplete-badge')!;
expect(toggle.contains(active)).toBe(false);
expect(toggle.contains(incomplete)).toBe(false);
expect(active.parentElement).toBe(heading.parentElement);
expect(heading.parentElement?.firstElementChild).toBe(heading);
const panel = q('guardrails-section')!;
expect(panel.contains(toggle)).toBe(true);
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
expect(panel.className).toContain('bg-card');
await act(async () => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
});
it('renders the header pills as badges in their status roles', async () => {
await render();
expect(q('guardrails-active-badge')?.dataset.slot).toBe('badge');
expect(q('guardrails-active-badge')?.dataset.variant).toBe('success');
expect(q('guardrails-incomplete-badge')?.dataset.variant).toBe(
'destructive',
);
expect(q('guardrail-latency-pii')?.dataset.variant).toBe('neutral');
expect(q('guardrail-latency-pii')?.title).toBe(
'agents.form.guardrails.latencyHint',
);
});
it('shows the section notices as polite or warning alerts', async () => {
await render({ disabled: true });
const readOnly = q('guardrails-read-only');
expect(readOnly?.getAttribute('role')).toBe('status');
expect(readOnly?.textContent).toContain('Read only');
const instance = q('guardrails-instance-disabled');
expect(instance?.getAttribute('role')).toBe('alert');
expect(instance?.className).toContain('text-warning');
const floor = Array.from(
container.querySelectorAll<HTMLElement>('[role]'),
).find((el) =>
el.textContent?.includes('agents.form.guardrails.floorNotice'),
);
expect(floor?.getAttribute('role')).toBe('status');
expect(floor?.className).toContain('text-info');
});
it('uses the pressed-toggle variants for stage chips', async () => {
await render();
expect(q('guardrail-stage-pii-input')?.dataset.variant).toBe('secondary');
const locked = q('guardrail-stage-pii-output');
expect(locked?.dataset.variant).toBe('secondary');
expect((locked as HTMLButtonElement).disabled).toBe(true);
expect(locked?.title).toBe('agents.form.guardrails.lockedByFloor');
expect(q('guardrail-stage-policy-input')?.dataset.variant).toBe(
'secondary',
);
});
it('renders remove actions as ghost-destructive xs buttons', async () => {
await render();
const remove = q('guardrail-remove-pii-input');
expect(remove?.dataset.variant).toBe('ghost-destructive');
expect(remove?.dataset.size).toBe('xs');
const orphan = q('guardrail-orphan-gone')?.querySelector('button');
expect(orphan?.dataset.variant).toBe('ghost-destructive');
expect(orphan?.dataset.size).toBe('xs');
});
// Decision 63 (3): the form's fields and selects are all 42px.
it('sizes the fields through Input and Textarea props', async () => {
await render();
const block = q('guardrails-block-message');
expect(block?.dataset.size).toBe('default');
expect(block?.dataset.shape).toBe('pill');
expect(block?.className).not.toContain('bg-card');
const timeout = q('guardrails-timeout');
expect(timeout?.dataset.variant).toBe('filled');
expect(timeout?.dataset.size).toBe('default');
expect(timeout?.dataset.shape).toBe('pill');
const mode = q('guardrails-mode');
expect(mode?.dataset.size).toBe('lg');
expect(mode?.dataset.shape).toBe('pill');
await act(async () => {
q('guardrail-configure-policy-input')?.click();
});
expect(q('guardrail-policy-text')?.dataset.slot).toBe('textarea');
});
it('shows PII entities as toggle chips', async () => {
await render();
await act(async () => {
q('guardrail-configure-pii-input')?.click();
});
expect(q('guardrail-pii-EMAIL')?.dataset.variant).toBe('secondary');
expect(q('guardrail-pii-PHONE')?.dataset.variant).toBe('ghost-muted');
});
});
@@ -1,6 +1,9 @@
import React from 'react';
import { ChevronRight, Info, Lock, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -11,6 +14,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import userService from '../../api/services/userService';
import {
@@ -202,59 +207,49 @@ export default function GuardrailsSection({
return (
<div
className="bg-card rounded-2xl px-6 py-3"
className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset"
data-testid="guardrails-section"
>
<Button
type="button"
variant="ghost"
onClick={() => setExpanded(!expanded)}
className="h-auto w-full justify-between px-0 py-0 text-left hover:bg-transparent"
data-testid="guardrails-toggle"
>
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.guardrails')}
</h2>
{config.enabled && (
<span
className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
data-testid="guardrails-active-badge"
>
{t('agents.form.guardrails.activeCount', {
count: config.controls.length + floorControls.size,
})}
</span>
)}
{incompleteCount > 0 && (
<span
className="bg-destructive/10 text-destructive rounded-full px-2 py-0.5 text-xs font-medium"
data-testid="guardrails-incomplete-badge"
>
{t('agents.form.guardrails.needsSetup', {
count: incompleteCount,
})}
</span>
)}
</div>
<div className="ml-4 flex items-center">
<svg
className={`size-5 transform transition-transform duration-200 ${
expanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className="flex flex-wrap items-center gap-2">
{/* The heading wraps the toggle: a button's children are
presentational, so a heading inside it is lost to screen readers. */}
<h2>
<Button
type="button"
variant="section-toggle"
size="sm"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
className="-ml-3 w-fit justify-start"
data-testid="guardrails-toggle"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
<ChevronRight
aria-hidden="true"
className={cn('transition-transform', expanded && 'rotate-90')}
/>
</svg>
</div>
</Button>
<span className="text-lg font-semibold">
{t('agents.form.sections.guardrails')}
</span>
</Button>
</h2>
{config.enabled && (
<Badge variant="success" data-testid="guardrails-active-badge">
{t('agents.form.guardrails.activeCount', {
count: config.controls.length + floorControls.size,
})}
</Badge>
)}
{incompleteCount > 0 && (
<Badge
variant="destructive"
data-testid="guardrails-incomplete-badge"
>
{t('agents.form.guardrails.needsSetup', {
count: incompleteCount,
})}
</Badge>
)}
</div>
{expanded && (
<div className="mt-3 pb-3">
@@ -263,29 +258,38 @@ export default function GuardrailsSection({
)}
{disabled && disabledNotice && (
<p
className="mt-3 rounded-lg bg-gray-100 px-3 py-2 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300"
<Alert
role="status"
className="mt-3"
data-testid="guardrails-read-only"
>
{disabledNotice}
</p>
<Lock aria-hidden="true" className="size-4" />
<AlertDescription>{disabledNotice}</AlertDescription>
</Alert>
)}
{instanceDisabled && (
<p
className="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
<Alert
variant="warning"
className="mt-3"
data-testid="guardrails-instance-disabled"
>
{t('agents.form.guardrails.instanceDisabled')}
</p>
<TriangleAlert aria-hidden="true" className="size-4" />
<AlertDescription>
{t('agents.form.guardrails.instanceDisabled')}
</AlertDescription>
</Alert>
)}
{floorControls.size > 0 && (
<p className="mt-3 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
{t('agents.form.guardrails.floorNotice', {
count: floorControls.size,
})}
</p>
<Alert variant="info" role="status" className="mt-3">
<Info aria-hidden="true" className="size-4" />
<AlertDescription>
{t('agents.form.guardrails.floorNotice', {
count: floorControls.size,
})}
</AlertDescription>
</Alert>
)}
<div className="mt-4 flex items-center justify-between gap-4">
@@ -293,7 +297,7 @@ export default function GuardrailsSection({
<h3 className="text-sm font-medium">
{t('agents.form.guardrails.enable')}
</h3>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.guardrails.enableDescription')}
</p>
</div>
@@ -320,8 +324,9 @@ export default function GuardrailsSection({
disabled={disabled}
>
<SelectTrigger
className="w-full rounded-3xl px-5 py-3 text-sm"
className="w-full"
size="lg"
shape="pill"
data-testid="guardrails-mode"
>
<SelectValue />
@@ -335,7 +340,7 @@ export default function GuardrailsSection({
</SelectContent>
</Select>
{config.mode === 'monitor_only' && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
<p className="text-warning mt-2 text-xs">
{t('agents.form.guardrails.monitorHint')}
</p>
)}
@@ -375,10 +380,9 @@ export default function GuardrailsSection({
</p>
<Button
type="button"
variant="ghost"
size="sm"
variant="ghost-destructive"
size="xs"
disabled={disabled}
className="text-destructive h-auto px-2 py-1 text-xs"
onClick={() =>
removeControl(control.check, control.stage)
}
@@ -401,9 +405,9 @@ export default function GuardrailsSection({
disabled={disabled}
data-testid="guardrails-block-message"
onChange={(e) => patch({ block_message: e.target.value })}
className="bg-card h-auto rounded-3xl px-5 py-3 text-sm md:text-sm"
shape="pill"
/>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.guardrails.blockMessageDescription')}
</p>
</div>
@@ -413,7 +417,7 @@ export default function GuardrailsSection({
<h3 className="text-sm font-medium">
{t('agents.form.guardrails.failOpen')}
</h3>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.guardrails.failOpenDescription')}
</p>
</div>
@@ -494,7 +498,8 @@ function NumberField({
setDraft(String(next));
onCommit(next);
}}
className="bg-card h-auto rounded-3xl px-5 py-3 text-sm md:text-sm"
variant="filled"
shape="pill"
/>
);
}
@@ -566,28 +571,26 @@ function CheckCard({
return (
<div
className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700"
className="border-border rounded-xl border px-4 py-3"
data-testid={`guardrail-check-${info.name}`}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-medium">{info.label}</h4>
<span
className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
<Badge
variant="neutral"
title={t('agents.form.guardrails.latencyHint')}
data-testid={`guardrail-latency-${info.name}`}
>
{latencyLabel(info.latency_hint_ms)}
</span>
</Badge>
{unavailable && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
<Badge variant="warning">
{t('agents.form.guardrails.notConfigured')}
</span>
</Badge>
)}
</div>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{info.description}
</p>
<p className="text-muted-foreground mt-1 text-xs">{info.description}</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
@@ -602,25 +605,36 @@ function CheckCard({
// out a credential strands a control that can never be cleared.
const canToggle =
!disabled && !locked && (Boolean(control) || !unavailable);
return (
<button
const chip = (
<Button
key={stage}
type="button"
variant={on ? 'secondary' : 'ghost-muted'}
size="xs"
shape="pill"
disabled={!canToggle}
data-testid={`guardrail-stage-${info.name}-${stage}`}
onClick={() => toggleControl(info, stage, !control)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
on
? 'border-violets-are-blue bg-violets-are-blue/10 text-violets-are-blue'
: 'border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-400'
} ${!canToggle ? 'cursor-not-allowed opacity-60' : ''}`}
title={
locked ? t('agents.form.guardrails.lockedByFloor') : undefined
}
>
{t(STAGE_KEYS[stage] ?? stage)}
{locked ? ' 🔒' : ''}
</button>
</Button>
);
// A disabled Button takes no pointer events, so the locked chip's
// hint lives on a wrapper that can still be hovered.
return locked ? (
<span
key={stage}
className="inline-flex"
title={t('agents.form.guardrails.lockedByFloor')}
>
{chip}
</span>
) : (
chip
);
})}
</div>
@@ -628,7 +642,7 @@ function CheckCard({
{floorForCheck.map(([k, action]) => (
<div
key={`floor-${k}`}
className="mt-3 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
className="bg-info/10 text-info mt-3 rounded-lg px-3 py-2 text-xs"
data-testid={`guardrail-floor-${k}`}
>
{t('agents.form.guardrails.floorControl', {
@@ -644,11 +658,12 @@ function CheckCard({
return (
<div
key={panelKey}
className={`mt-3 rounded-lg px-3 py-2 ${
className={cn(
'mt-3 rounded-lg px-3 py-2',
needsSetup
? 'border-destructive/50 bg-destructive/5 border'
: 'bg-gray-50 dark:bg-gray-900/40'
}`}
: 'bg-muted',
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium">
@@ -665,7 +680,8 @@ function CheckCard({
}
>
<SelectTrigger
className="h-auto rounded-full px-3 py-1 text-xs"
size="sm"
shape="pill"
data-testid={`guardrail-action-${control.check}-${control.stage}`}
>
<SelectValue />
@@ -687,8 +703,7 @@ function CheckCard({
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
size="xs"
data-testid={`guardrail-configure-${control.check}-${control.stage}`}
onClick={() =>
setOpenSettings(
@@ -701,10 +716,9 @@ function CheckCard({
)}
<Button
type="button"
variant="ghost"
size="sm"
variant="ghost-destructive"
size="xs"
disabled={disabled}
className="text-destructive h-auto px-2 py-1 text-xs"
data-testid={`guardrail-remove-${control.check}-${control.stage}`}
onClick={() => removeControl(control.check, control.stage)}
>
@@ -759,8 +773,9 @@ function CheckSettings({
const listField = (fieldKey: string, label: string, placeholder: string) => (
<div className="mt-2">
<label className="mb-1 block text-xs font-medium">{label}</label>
<textarea
<Textarea
rows={2}
size="sm"
disabled={disabled}
data-testid={`guardrail-setting-${control.check}-${fieldKey}`}
value={(s[fieldKey] ?? []).join('\n')}
@@ -773,21 +788,24 @@ function CheckSettings({
.filter(Boolean),
})
}
className="bg-card w-full rounded-xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700"
variant="filled"
/>
</div>
);
return (
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
<div className="border-border mt-3 border-t pt-3">
{control.check === 'pii' && (
<div className="flex flex-wrap gap-2">
{(catalog?.pii_entities ?? []).map((entity) => {
const on = (s.entities ?? []).includes(entity);
return (
<button
<Button
key={entity}
type="button"
variant={on ? 'secondary' : 'ghost-muted'}
size="xs"
shape="pill"
disabled={disabled}
data-testid={`guardrail-pii-${entity}`}
onClick={() =>
@@ -797,14 +815,9 @@ function CheckSettings({
: [...(s.entities ?? []), entity],
})
}
className={`rounded-full border px-2 py-0.5 text-[11px] ${
on
? 'border-violets-are-blue bg-violets-are-blue/10 text-violets-are-blue'
: 'border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-400'
}`}
>
{entity}
</button>
</Button>
);
})}
{(s.entities ?? []).length === 0 && (
@@ -842,13 +855,14 @@ function CheckSettings({
<label className="mb-1 block text-xs font-medium">
{t('agents.form.guardrails.policyText')}
</label>
<textarea
<Textarea
rows={4}
size="sm"
value={s.policy ?? ''}
disabled={disabled}
data-testid="guardrail-policy-text"
onChange={(e) => set({ policy: e.target.value })}
className="bg-card w-full rounded-xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700"
variant="filled"
/>
</div>
)}
@@ -900,7 +914,7 @@ function CheckSettings({
disabled={disabled}
onCommit={(confidence_threshold) => set({ confidence_threshold })}
/>
<p className="mt-1 text-[11px] text-gray-500">
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.guardrails.confidenceHint')}
</p>
</div>
@@ -43,13 +43,7 @@ export default function RunDetailDrawer({
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Run details</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
className="text-muted-foreground"
>
<Button type="button" variant="outline" size="sm" onClick={onClose}>
Close
</Button>
</div>
+2 -2
View File
@@ -71,9 +71,9 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
<Button
type="button"
variant="link"
size="sm"
size="xs"
onClick={() => onSelect(run)}
className="h-auto p-0 text-xs underline"
className="-mx-2 -my-1"
>
Details
</Button>
@@ -17,7 +17,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import { TimePicker } from '@/components/ui/time-picker';
import { cn } from '@/lib/utils';
import { Modal } from '../../components/ui/modal';
import { formatDateOnly } from '../../utils/dateTimeUtils';
@@ -195,7 +194,7 @@ export default function ScheduleFormModal({
}
size="md"
mobileVariant="sheet"
className="w-[min(560px,92vw)] sm:p-6"
className="w-[min(560px,92vw)]"
contentClassName="max-h-[80vh]"
>
<div className="flex flex-col gap-5">
@@ -273,7 +272,7 @@ export default function ScheduleFormModal({
type="button"
disabled={submitting}
onClick={submit}
className="rounded-3xl px-5"
shape="pill"
>
{submitting
? '…'
@@ -302,15 +301,11 @@ function FrequencyTabs({ frequency, onChange, labels }: FrequencyTabsProps) {
<Button
key={f}
type="button"
variant="ghost"
size="sm"
variant={active ? 'outline' : 'ghost-muted'}
size="xs"
shape="pill"
onClick={() => onChange(f)}
className={cn(
'h-auto flex-1 rounded-full px-3 py-1.5 text-xs font-medium',
active
? 'bg-card text-foreground hover:bg-card shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-transparent',
)}
className="flex-1"
aria-pressed={active}
>
{labels[f]}
@@ -380,7 +375,7 @@ function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
size="sm"
variant={active ? 'default' : 'outline'}
onClick={() => set({ dayOfWeek: d.value })}
className="rounded-full"
shape="pill"
aria-pressed={active}
>
{tDay(d.key)}
@@ -464,13 +459,11 @@ function DatePicker({ value, onChange, placeholder }: DatePickerProps) {
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
variant="combobox"
size="sm"
aria-label={placeholder}
className={cn(
'h-9 justify-start gap-2 px-3 font-normal',
!value && 'text-muted-foreground',
)}
data-placeholder={value ? undefined : ''}
className="h-9 justify-start"
>
<CalendarIcon className="size-4 opacity-70" />
{value ? formatDateLabel(value) : placeholder}
@@ -161,10 +161,10 @@ export default function SchedulerToolCallCard({
{taskId && !cancelled && (
<Button
type="button"
variant="outline"
size="sm"
variant="destructive-outline"
size="xs"
onClick={cancel}
className="text-destructive mt-2 text-xs"
className="mt-2"
>
Cancel
</Button>
@@ -214,15 +214,18 @@ export default function SchedulesView() {
<div className="flex gap-2">
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
setSchedulePaused({
@@ -235,7 +238,6 @@ export default function SchedulesView() {
}),
)
}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{schedule.status === 'active'
? t('agents.schedules.pause')
@@ -243,13 +245,14 @@ export default function SchedulesView() {
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.runNow')}
</Button>
@@ -257,8 +260,8 @@ export default function SchedulesView() {
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
className="rounded-full px-5"
>
{t('agents.schedules.delete')}
</Button>
@@ -267,13 +270,13 @@ export default function SchedulesView() {
<Button
type="button"
variant="link"
size="sm"
size="xs"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-2 h-auto p-0 text-xs underline"
className="mt-0.5 -ml-2"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
@@ -324,9 +327,10 @@ export default function SchedulesView() {
{schedule.status === 'active' && (
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.edit')}
</Button>
@@ -336,8 +340,8 @@ export default function SchedulesView() {
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
className="rounded-full px-5"
>
{t('agents.schedules.cancel')}
</Button>
@@ -3,14 +3,14 @@ import { describe, expect, it } from 'vitest';
import ScheduleStatusBadge, {
formatStatusLabel,
getStatusClasses,
getStatusVariant,
type ScheduleStatusBadgeStatus,
} from './StatusBadge';
type Case = {
status: ScheduleStatusBadgeStatus;
label: string;
classes: string[];
variant: string;
};
const CASES: Case[] = [
@@ -18,68 +18,67 @@ const CASES: Case[] = [
{
status: 'active',
label: 'Active',
classes: ['bg-green-100', 'text-green-700'],
variant: 'success',
},
{
status: 'paused',
label: 'Paused',
classes: ['bg-amber-100', 'text-amber-700'],
variant: 'warning',
},
{
status: 'completed',
label: 'Completed',
classes: ['bg-blue-100', 'text-blue-700'],
variant: 'success',
},
{
status: 'cancelled',
label: 'Cancelled',
classes: ['bg-muted', 'text-muted-foreground'],
variant: 'neutral',
},
// Run statuses
{
status: 'success',
label: 'Success',
classes: ['bg-green-100', 'text-green-700'],
variant: 'success',
},
{
status: 'failed',
label: 'Failed',
classes: ['bg-red-100', 'text-red-700'],
variant: 'destructive',
},
{
status: 'skipped',
label: 'Skipped',
classes: ['bg-amber-100', 'text-amber-700'],
variant: 'warning',
},
{
status: 'running',
label: 'Running',
classes: ['bg-blue-100', 'text-blue-700'],
variant: 'info',
},
{
status: 'pending',
label: 'Pending',
classes: ['bg-muted', 'text-muted-foreground'],
variant: 'neutral',
},
{
status: 'timeout',
label: 'Timeout',
classes: ['bg-red-100', 'text-red-700'],
variant: 'destructive',
},
];
describe('ScheduleStatusBadge', () => {
it.each(CASES)(
'renders $status with label "$label" and the right color classes',
({ status, label, classes }) => {
'renders $status with label "$label" as a $variant badge',
({ status, label, variant }) => {
const html = renderToStaticMarkup(
<ScheduleStatusBadge status={status} />,
);
expect(html).toContain(label);
expect(html).toContain(`data-status="${status}"`);
for (const cls of classes) {
expect(html).toContain(cls);
}
expect(html).toContain('data-slot="badge"');
expect(html).toContain(`data-variant="${variant}"`);
},
);
@@ -101,9 +100,9 @@ describe('formatStatusLabel', () => {
});
});
describe('getStatusClasses', () => {
it('returns muted classes for an unknown status', () => {
describe('getStatusVariant', () => {
it('returns neutral for an unknown status', () => {
// @ts-expect-error -- exercising the runtime fallback for unknown values
expect(getStatusClasses('unknown')).toContain('text-muted-foreground');
expect(getStatusVariant('unknown')).toBe('neutral');
});
});
+22 -26
View File
@@ -1,4 +1,4 @@
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import type { ScheduleRunStatus, ScheduleStatus } from '../types/schedule';
@@ -9,23 +9,21 @@ export type ScheduleStatusBadgeProps = {
className?: string;
};
const STATUS_CLASSES: Record<ScheduleStatusBadgeStatus, string> = {
type StatusVariant = 'success' | 'warning' | 'info' | 'destructive' | 'neutral';
const STATUS_VARIANTS: Record<ScheduleStatusBadgeStatus, StatusVariant> = {
// Schedule statuses
active:
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
paused:
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
completed: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
cancelled: 'bg-muted text-muted-foreground',
active: 'success',
paused: 'warning',
completed: 'success',
cancelled: 'neutral',
// Run statuses
success:
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
skipped:
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
running: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
pending: 'bg-muted text-muted-foreground',
timeout: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
success: 'success',
failed: 'destructive',
skipped: 'warning',
running: 'info',
pending: 'neutral',
timeout: 'destructive',
};
/** Maps a status string to a human label (sentence-cased, underscores spaced). */
@@ -34,9 +32,10 @@ export const formatStatusLabel = (status: string): string => {
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
};
/** Returns the Tailwind classes for a given schedule/run status pill. */
export const getStatusClasses = (status: ScheduleStatusBadgeStatus): string =>
STATUS_CLASSES[status] ?? 'bg-muted text-muted-foreground';
/** Returns the Badge variant for a given schedule/run status. */
export const getStatusVariant = (
status: ScheduleStatusBadgeStatus,
): StatusVariant => STATUS_VARIANTS[status] ?? 'neutral';
/** Colored pill for schedule or run statuses. */
export default function ScheduleStatusBadge({
@@ -44,15 +43,12 @@ export default function ScheduleStatusBadge({
className,
}: ScheduleStatusBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium',
getStatusClasses(status),
className,
)}
<Badge
variant={getStatusVariant(status)}
className={className}
data-status={status}
>
{formatStatusLabel(status)}
</span>
</Badge>
);
}
@@ -125,16 +125,12 @@ export default function TimezoneCombobox({
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
variant="combobox"
role="combobox"
size="sm"
aria-expanded={open}
aria-label={ariaLabel}
className={cn(
'h-9 w-full justify-between gap-2 px-3 font-normal',
!value && 'text-muted-foreground',
className,
)}
data-placeholder={value ? undefined : ''}
className={cn('w-full justify-between', className)}
>
{value ? (
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
+152 -170
View File
@@ -53,6 +53,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import modelService from '../../api/services/modelService';
import userService from '../../api/services/userService';
@@ -1557,8 +1559,8 @@ function WorkflowBuilderInner() {
<Button
type="button"
variant="outline"
shape="pill"
onClick={navigateBackToAgents}
className="text-muted-foreground rounded-full px-4 py-2 text-sm font-normal shadow-none"
>
{t('agents.backToAll')}
</Button>
@@ -1566,7 +1568,7 @@ function WorkflowBuilderInner() {
{!canManageAgent && (
<div className="min-w-0">
<div
className="max-w-xs truncate text-xl font-bold text-gray-900 dark:text-white"
className="text-foreground max-w-xs truncate text-xl font-bold"
title={workflowName || 'New Workflow'}
>
{workflowName || 'New Workflow'}
@@ -1584,10 +1586,9 @@ function WorkflowBuilderInner() {
<div className="relative flex items-center">
<Button
type="button"
variant="ghost"
size="icon-sm"
variant="ghost-muted"
size="icon-xs"
onClick={() => setShowWorkflowSettings(!showWorkflowSettings)}
className="text-muted-foreground hover:bg-accent hover:text-foreground size-auto p-1"
aria-label="Edit workflow details"
title={
workflowDescription
@@ -1603,31 +1604,29 @@ function WorkflowBuilderInner() {
className="border-border bg-card absolute top-full left-0 z-50 mt-2 w-80 rounded-xl border p-4 shadow-lg"
>
<div className="mb-3">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-1 block text-sm font-medium">
Workflow Name
</label>
<Input
type="text"
value={workflowName}
onChange={(e) => setWorkflowName(e.target.value)}
className="bg-card h-auto rounded-lg px-3 py-2 text-sm shadow-none"
placeholder="Enter workflow name"
/>
</div>
<div className="mb-3">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-1 block text-sm font-medium">
Description
</label>
<textarea
<Textarea
value={workflowDescription}
onChange={(e) => setWorkflowDescription(e.target.value)}
className="focus-visible:ring-ring/50 focus-visible:border-ring border-border bg-card w-full rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 dark:text-white"
rows={3}
placeholder="Describe what this workflow does"
/>
</div>
<div className="mb-3">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-1 block text-sm font-medium">
Agent Image
</label>
{currentAgentImage && !imageFile && (
@@ -1637,7 +1636,7 @@ function WorkflowBuilderInner() {
alt="Agent image"
className="h-10 w-10 rounded-full object-cover"
/>
<span className="text-xs text-gray-500 dark:text-gray-400">
<span className="text-muted-foreground text-xs">
Current image
</span>
</div>
@@ -1646,19 +1645,18 @@ function WorkflowBuilderInner() {
showPreview
maxFiles={1}
previewSize={56}
size="compact"
onUpload={handleUpload}
onRemove={() => setImageFile(null)}
uploadText={[
{
text: 'Click to upload',
colorClass: 'text-primary',
highlight: true,
},
{
text: ' or drag and drop',
colorClass: 'text-muted-foreground',
},
]}
className="border-border rounded-lg border-2 border-dashed p-3 text-center transition-colors"
/>
<p className="text-muted-foreground mt-1 text-xs">
Image updates are included the next time you save.
@@ -1667,44 +1665,38 @@ function WorkflowBuilderInner() {
<div className="mb-3">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
<label
htmlFor="workflow-system-prompt-override"
className="text-foreground block text-sm font-medium"
>
{t('agents.form.advanced.systemPromptOverride')}
</label>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground mt-0.5 text-xs">
{t(
'agents.form.advanced.systemPromptOverrideDescription',
)}
</p>
</div>
<button
onClick={() =>
<Switch
id="workflow-system-prompt-override"
checked={Boolean(
currentAgent.allow_system_prompt_override,
)}
onCheckedChange={() =>
setCurrentAgent((prev) => ({
...prev,
allow_system_prompt_override:
!prev.allow_system_prompt_override,
}))
}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
currentAgent.allow_system_prompt_override
? 'bg-primary'
: 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
currentAgent.allow_system_prompt_override
? ''
: '-translate-x-5'
}`}
/>
</button>
/>
</div>
</div>
<Button
type="button"
onClick={handleWorkflowSettingsDone}
disabled={isPublishing}
className="w-full rounded-lg text-white"
className="w-full"
>
Done
</Button>
@@ -1717,8 +1709,8 @@ function WorkflowBuilderInner() {
<Button
type="button"
variant="outline"
shape="pill"
onClick={() => setAgentDetails('ACTIVE')}
className="rounded-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200"
>
<Link size={16} />
Access Details
@@ -1728,9 +1720,9 @@ function WorkflowBuilderInner() {
<Button
type="button"
variant="destructive-outline"
shape="pill"
onClick={() => setDeleteConfirmation('ACTIVE')}
disabled={isDeletingAgent}
className="bg-card rounded-full border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-600 dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-900/10 dark:hover:text-red-400"
>
<Trash2 size={16} />
{isDeletingAgent ? 'Deleting...' : 'Delete'}
@@ -1739,6 +1731,7 @@ function WorkflowBuilderInner() {
<Button
type="button"
variant="outline"
shape="pill"
onClick={() => {
const validationErrors = validateWorkflow();
if (validationErrors.length > 0) {
@@ -1748,7 +1741,6 @@ function WorkflowBuilderInner() {
}
setShowPreview(true);
}}
className="rounded-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200"
>
<Play size={16} />
Preview
@@ -1757,11 +1749,9 @@ function WorkflowBuilderInner() {
type="button"
onClick={handlePrimaryAction}
disabled={isPrimaryActionDisabled}
className={`relative rounded-full px-6 py-2 text-sm font-medium shadow-sm ${
canManageAgent && !hasSavableChanges
? 'dark:bg-accent bg-gray-200 text-gray-500 hover:bg-gray-200 dark:text-gray-400'
: 'text-white'
}`}
size="lg"
shape="pill"
className="relative"
>
<span
className={
@@ -1779,42 +1769,43 @@ function WorkflowBuilderInner() {
{publishErrors.length > 0 && (
<div className="pointer-events-none absolute top-20 right-0 left-0 z-50 flex justify-center px-4">
<Alert
variant="destructive"
className="pointer-events-auto w-full max-w-md bg-red-50 shadow-lg dark:bg-red-950/20"
>
<AlertCircle className="h-4 w-4" />
<AlertTitle>
{errorContext === 'preview'
? 'Unable to preview workflow'
: canManageAgent
? 'Unable to save workflow'
: 'Unable to publish workflow'}
</AlertTitle>
<AlertDescription>
<ul className="mt-2 list-inside list-disc space-y-1 wrap-break-word">
{publishErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</AlertDescription>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setPublishErrors([])}
className="absolute top-4 right-4 size-auto p-0 text-red-700 hover:bg-transparent hover:text-red-900 dark:text-red-300 dark:hover:bg-transparent dark:hover:text-red-100"
>
<X size={16} />
</Button>
</Alert>
<div className="bg-card pointer-events-auto w-full max-w-md rounded-lg shadow-lg">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>
{errorContext === 'preview'
? 'Unable to preview workflow'
: canManageAgent
? 'Unable to save workflow'
: 'Unable to publish workflow'}
</AlertTitle>
<AlertDescription>
<ul className="mt-2 list-inside list-disc space-y-1 wrap-break-word">
{publishErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</AlertDescription>
<div className="absolute top-2.5 right-2.5">
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => setPublishErrors([])}
aria-label={t('agents.close')}
>
<X size={16} aria-hidden />
</Button>
</div>
</Alert>
</div>
</div>
)}
<div className="flex flex-1 overflow-hidden">
<div className="border-border bg-muted dark:bg-background flex w-64 flex-col gap-6 border-r p-4">
<div>
<h3 className="mb-3 text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
<h3 className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
Core Nodes
</h3>
<div className="flex flex-col gap-2">
@@ -1823,10 +1814,10 @@ function WorkflowBuilderInner() {
draggable
onDragStart={(e) => handleNodeDragStart(e, 'agent')}
>
<div className="text-primary group-hover:bg-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-100 transition-colors group-hover:text-white dark:bg-purple-900/40">
<div className="bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
<Bot size={18} />
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
<span className="text-foreground text-sm font-medium">
AI Agent
</span>
</div>
@@ -1835,10 +1826,10 @@ function WorkflowBuilderInner() {
draggable
onDragStart={(e) => handleNodeDragStart(e, 'end')}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 transition-colors group-hover:bg-green-600 group-hover:text-white dark:bg-green-900/40 dark:text-green-300">
<div className="bg-success/10 text-success group-hover:bg-success group-hover:text-success-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
<Flag size={18} />
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
<span className="text-foreground text-sm font-medium">
End
</span>
</div>
@@ -1847,10 +1838,10 @@ function WorkflowBuilderInner() {
draggable
onDragStart={(e) => handleNodeDragStart(e, 'note')}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-600 transition-colors group-hover:bg-yellow-500 group-hover:text-white dark:bg-yellow-900/40 dark:text-yellow-300">
<div className="bg-warning/10 text-warning group-hover:bg-warning group-hover:text-warning-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
<StickyNote size={18} />
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
<span className="text-foreground text-sm font-medium">
Note
</span>
</div>
@@ -1858,7 +1849,7 @@ function WorkflowBuilderInner() {
</div>
<div>
<h3 className="mb-3 text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
<h3 className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
Logic & Data
</h3>
<div className="flex flex-col gap-2">
@@ -1867,7 +1858,7 @@ function WorkflowBuilderInner() {
draggable
onDragStart={(e) => handleNodeDragStart(e, 'state')}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-900/40 dark:text-blue-300">
<div className="bg-info/10 text-info group-hover:bg-info group-hover:text-info-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
<Database size={18} />
</div>
<div className="flex flex-col">
@@ -1884,7 +1875,7 @@ function WorkflowBuilderInner() {
draggable
onDragStart={(e) => handleNodeDragStart(e, 'condition')}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 transition-colors group-hover:bg-orange-600 group-hover:text-white dark:bg-orange-900/40 dark:text-orange-300">
<div className="bg-warning/10 text-warning group-hover:bg-warning group-hover:text-warning-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
<GitBranch size={18} />
</div>
<div className="flex flex-col">
@@ -1901,7 +1892,7 @@ function WorkflowBuilderInner() {
draggable
onDragStart={(e) => handleNodeDragStart(e, 'code')}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 transition-colors group-hover:bg-indigo-600 group-hover:text-white dark:bg-indigo-900/40 dark:text-indigo-300">
<div className="bg-info/10 text-info group-hover:bg-info group-hover:text-info-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
<Code2 size={18} />
</div>
<div className="flex flex-col">
@@ -1950,7 +1941,6 @@ function WorkflowBuilderInner() {
disabled={!canUndo}
title="Undo (Ctrl+Z)"
aria-label="Undo"
className="bg-card"
>
<Undo2 size={16} />
</Button>
@@ -1962,7 +1952,6 @@ function WorkflowBuilderInner() {
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z)"
aria-label="Redo"
className="bg-card"
>
<Redo2 size={16} />
</Button>
@@ -1973,7 +1962,7 @@ function WorkflowBuilderInner() {
<>
<div className="border-border bg-card shadow-modal absolute top-4 right-4 z-20 w-96 rounded-2xl border">
<div className="border-border flex items-center justify-between border-b p-4">
<h3 className="font-semibold text-gray-900 dark:text-white">
<h3 className="text-foreground font-semibold">
{selectedNode.type === 'start' && 'Start Node'}
{selectedNode.type === 'end' && 'End Node'}
{selectedNode.type === 'agent' && 'AI Agent'}
@@ -1984,22 +1973,21 @@ function WorkflowBuilderInner() {
</h3>
<Button
type="button"
variant="ghost"
size="icon-sm"
variant="ghost-muted"
size="icon-xs"
onClick={() => setShowNodeConfig(false)}
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-gray-600 dark:hover:bg-transparent dark:hover:text-gray-200 [&_svg:not([class*='size-'])]:size-5"
>
<X size={20} />
<X size={20} className="size-5" />
</Button>
</div>
<div className="max-h-[calc(100vh-200px)] overflow-y-auto p-4">
<div className="mb-4 flex flex-col gap-2">
<div className="bg-muted rounded-lg p-3">
<div className="mb-1 text-xs text-gray-500 dark:text-gray-400">
<div className="text-muted-foreground mb-1 text-xs">
Node ID
</div>
<div className="truncate font-mono text-xs text-gray-700 dark:text-gray-300">
<div className="text-foreground truncate font-mono text-xs">
{selectedNode.id}
</div>
</div>
@@ -2008,7 +1996,7 @@ function WorkflowBuilderInner() {
selectedNode.type !== 'end' && (
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Title
</label>
<Input
@@ -2024,7 +2012,6 @@ function WorkflowBuilderInner() {
label: e.target.value,
})
}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Enter node title"
/>
</div>
@@ -2032,7 +2019,7 @@ function WorkflowBuilderInner() {
{selectedNode.type === 'agent' && (
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Agent Type
</label>
<Select
@@ -2049,7 +2036,7 @@ function WorkflowBuilderInner() {
})
}
>
<SelectTrigger className="w-full">
<SelectTrigger size="lg" className="w-full">
<SelectValue placeholder="Select agent type" />
</SelectTrigger>
<SelectContent>
@@ -2063,7 +2050,7 @@ function WorkflowBuilderInner() {
</Select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Model
</label>
<Select
@@ -2085,7 +2072,7 @@ function WorkflowBuilderInner() {
});
}}
>
<SelectTrigger className="w-full">
<SelectTrigger size="lg" className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
@@ -2141,10 +2128,10 @@ function WorkflowBuilderInner() {
</Select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
System Prompt
</label>
<textarea
<Textarea
value={
selectedNode.data.config?.system_prompt ??
''
@@ -2157,7 +2144,6 @@ function WorkflowBuilderInner() {
},
})
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
rows={3}
placeholder="System prompt for the agent"
/>
@@ -2182,7 +2168,7 @@ function WorkflowBuilderInner() {
placeholder="Use {{ agent.variable }} for dynamic content"
/>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Output Variable
</label>
<Input
@@ -2200,7 +2186,6 @@ function WorkflowBuilderInner() {
},
});
}}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Variable name for output"
/>
</div>
@@ -2224,13 +2209,13 @@ function WorkflowBuilderInner() {
/>
<label
htmlFor="stream_to_user"
className="text-sm text-gray-700 dark:text-gray-300"
className="text-foreground text-sm"
>
Stream output to user
</label>
</div>{' '}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Tools
</label>
<MultiSelect
@@ -2255,7 +2240,7 @@ function WorkflowBuilderInner() {
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Sources
</label>
<MultiSelect
@@ -2295,7 +2280,7 @@ function WorkflowBuilderInner() {
helpText="Documents passed to this agent from uploads or upstream nodes."
/>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
File passing
</label>
<Select
@@ -2311,7 +2296,7 @@ function WorkflowBuilderInner() {
})
}
>
<SelectTrigger className="w-full">
<SelectTrigger size="lg" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -2331,23 +2316,23 @@ function WorkflowBuilderInner() {
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Structured Output (JSON Schema)
</label>
{!selectedAgentModelSupportsStructuredOutput && (
<p className="mb-2 text-xs text-red-600 dark:text-red-400">
<p className="text-destructive mb-2 text-xs">
Selected model does not support structured
output.
</p>
)}
<textarea
<Textarea
value={selectedAgentJsonSchemaText}
onChange={(e) =>
handleAgentJsonSchemaChange(
e.target.value,
)
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
className="font-mono"
rows={8}
placeholder={`{
"type": "object",
@@ -2362,8 +2347,8 @@ function WorkflowBuilderInner() {
<p
className={`mt-2 text-xs ${
selectedAgentJsonSchemaError
? 'text-red-600 dark:text-red-400'
: 'text-green-600 dark:text-green-400'
? 'text-destructive'
: 'text-success'
}`}
>
{selectedAgentJsonSchemaError
@@ -2377,17 +2362,16 @@ function WorkflowBuilderInner() {
{selectedNode.type === 'note' && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Note Content
</label>
<textarea
<Textarea
value={selectedNode.data.content || ''}
onChange={(e) =>
handleUpdateNodeData({
content: e.target.value,
})
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
rows={4}
placeholder="Enter note content"
/>
@@ -2396,7 +2380,7 @@ function WorkflowBuilderInner() {
{selectedNode.type === 'state' && (
<>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground text-xs">
Assign values to workflow&apos;s state
variables
</p>
@@ -2415,7 +2399,7 @@ function WorkflowBuilderInner() {
className="border-border rounded-xl border p-3"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
<span className="text-foreground text-sm font-medium">
Assign value
</span>
{(
@@ -2424,8 +2408,8 @@ function WorkflowBuilderInner() {
).length > 1 && (
<Button
type="button"
variant="ghost"
size="icon-sm"
variant="ghost-destructive"
size="icon-xs"
onClick={() => {
const ops = [
...(selectedNode.data.config
@@ -2440,13 +2424,12 @@ function WorkflowBuilderInner() {
},
});
}}
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-red-500 dark:hover:bg-transparent"
>
<Trash2 size={14} />
</Button>
)}
</div>
<textarea
<Textarea
value={op.expression}
onChange={(e) => {
const ops = [
@@ -2465,7 +2448,7 @@ function WorkflowBuilderInner() {
},
});
}}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card dark:bg-accent mb-1 w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
className="mb-1"
rows={2}
placeholder="query"
/>
@@ -2484,7 +2467,7 @@ function WorkflowBuilderInner() {
</a>
</p>
<div>
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
<span className="text-foreground mb-1 block text-sm font-medium">
To variable
</span>
<Input
@@ -2507,7 +2490,6 @@ function WorkflowBuilderInner() {
},
});
}}
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="variable_name"
/>
</div>
@@ -2516,7 +2498,7 @@ function WorkflowBuilderInner() {
)}
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="sm"
onClick={() => {
const ops = [
@@ -2531,7 +2513,7 @@ function WorkflowBuilderInner() {
},
});
}}
className="h-auto gap-1 self-start rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400"
className="self-start"
>
<Plus size={14} />
Add
@@ -2541,13 +2523,19 @@ function WorkflowBuilderInner() {
{selectedNode.type === 'condition' && (
<>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground text-xs">
Create conditions to branch your workflow
</p>
<div className="border-border flex overflow-hidden rounded-lg border">
<div className="border-border bg-card flex gap-1 rounded-xl border p-1">
<Button
type="button"
variant="ghost"
variant={
(selectedNode.data.config?.mode ||
'simple') === 'simple'
? 'outline'
: 'ghost-muted'
}
size="xs"
onClick={() =>
handleUpdateNodeData({
config: {
@@ -2556,18 +2544,19 @@ function WorkflowBuilderInner() {
},
})
}
className={`h-auto flex-1 rounded-none px-3 py-1.5 text-xs font-medium ${
(selectedNode.data.config?.mode ||
'simple') === 'simple'
? 'bg-primary hover:bg-primary text-white hover:text-white'
: 'text-gray-600 dark:text-gray-400'
}`}
className="flex-1"
>
Simple
</Button>
<Button
type="button"
variant="ghost"
variant={
selectedNode.data.config?.mode ===
'advanced'
? 'outline'
: 'ghost-muted'
}
size="xs"
onClick={() =>
handleUpdateNodeData({
config: {
@@ -2576,12 +2565,7 @@ function WorkflowBuilderInner() {
},
})
}
className={`h-auto flex-1 rounded-none px-3 py-1.5 text-xs font-medium ${
selectedNode.data.config?.mode ===
'advanced'
? 'bg-primary hover:bg-primary text-white hover:text-white'
: 'text-gray-600 dark:text-gray-400'
}`}
className="flex-1"
>
Advanced
</Button>
@@ -2594,15 +2578,15 @@ function WorkflowBuilderInner() {
className="border-border rounded-xl border p-3"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-orange-600 dark:text-orange-400">
<span className="text-warning text-sm font-semibold">
{idx === 0 ? 'If' : 'Else if'}
</span>
{(selectedNode.data.config?.cases || [])
.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon-sm"
variant="ghost-destructive"
size="icon-xs"
onClick={() => {
const cases =
normalizeConditionCases([
@@ -2633,7 +2617,6 @@ function WorkflowBuilderInner() {
);
}
}}
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-red-500 dark:hover:bg-transparent"
>
<Trash2 size={14} />
</Button>
@@ -2659,7 +2642,7 @@ function WorkflowBuilderInner() {
},
});
}}
className="bg-card dark:bg-accent mb-2 h-auto rounded-xl px-3 py-2 text-sm shadow-none"
className="mb-2"
placeholder="Case name (optional)"
/>
{(selectedNode.data.config?.mode ||
@@ -2695,7 +2678,6 @@ function WorkflowBuilderInner() {
},
});
}}
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Variable"
/>
<Select
@@ -2728,7 +2710,10 @@ function WorkflowBuilderInner() {
});
}}
>
<SelectTrigger className="w-24 shrink-0">
<SelectTrigger
size="lg"
className="w-24 shrink-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -2787,13 +2772,12 @@ function WorkflowBuilderInner() {
},
});
}}
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Value"
/>
</div>
) : (
<>
<textarea
<Textarea
value={c.expression}
onChange={(e) => {
const cases = [
@@ -2812,7 +2796,6 @@ function WorkflowBuilderInner() {
},
});
}}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card dark:bg-accent w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
rows={2}
placeholder='Enter condition, e.g. query.contains("refund")'
/>
@@ -2839,7 +2822,7 @@ function WorkflowBuilderInner() {
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="sm"
onClick={() => {
const cases = normalizeConditionCases([
@@ -2860,7 +2843,7 @@ function WorkflowBuilderInner() {
},
});
}}
className="h-auto gap-1 self-start rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400"
className="self-start"
>
<Plus size={14} />
Add
@@ -2870,15 +2853,15 @@ function WorkflowBuilderInner() {
{selectedNode.type === 'code' && (
<>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground text-xs">
Run code in the workflow sandbox. Produced
files are saved as artifacts.
</p>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Code
</label>
<textarea
<Textarea
value={selectedNode.data.config?.code ?? ''}
onChange={(e) =>
handleUpdateNodeData({
@@ -2888,7 +2871,7 @@ function WorkflowBuilderInner() {
},
})
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
className="font-mono"
rows={10}
spellCheck={false}
placeholder={'print("hello world")'}
@@ -2910,7 +2893,7 @@ function WorkflowBuilderInner() {
helpText="Artifacts/upstream refs staged as files in the sandbox (one becomes inputs/<name>)."
/>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Output Variable
</label>
<Input
@@ -2927,12 +2910,11 @@ function WorkflowBuilderInner() {
},
})
}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Variable name for output"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Timeout (seconds)
</label>
<Input
@@ -2958,20 +2940,19 @@ function WorkflowBuilderInner() {
},
});
}}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Optional"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
Structured Output (JSON Schema)
</label>
<textarea
<Textarea
value={selectedCodeJsonSchemaText}
onChange={(e) =>
handleCodeJsonSchemaChange(e.target.value)
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
className="font-mono"
rows={6}
placeholder={`{
"type": "object",
@@ -2985,8 +2966,8 @@ function WorkflowBuilderInner() {
<p
className={`mt-2 text-xs ${
selectedCodeJsonSchemaError
? 'text-red-600 dark:text-red-400'
: 'text-green-600 dark:text-green-400'
? 'text-destructive'
: 'text-success'
}`}
>
{selectedCodeJsonSchemaError
@@ -3006,7 +2987,8 @@ function WorkflowBuilderInner() {
variant="destructive-outline"
onClick={handleDeleteNode}
disabled={selectedNode?.type === 'start'}
className="w-full rounded-full border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-600 dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-900/10 dark:hover:text-red-400"
shape="pill"
className="w-full"
>
<Trash2 size={16} />
{selectedNode?.type === 'start'
@@ -3024,7 +3006,7 @@ function WorkflowBuilderInner() {
<SheetContent
side="right"
title="Workflow preview"
className="bg-card w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
className="w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
>
<WorkflowPreview
workflowId={workflowId}
@@ -0,0 +1,131 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { WorkflowNode } from '../types/workflow';
import type { WorkflowExecutionStep } from './workflowPreviewSlice';
import { WorkflowMiniMap } from './WorkflowPreview';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const node = (
id: string,
type: WorkflowNode['type'],
y: number,
): WorkflowNode => ({
id,
type,
title: id,
position: { x: 0, y },
data: {},
});
const nodes = [
node('start', 'start', 0),
node('done', 'agent', 1),
node('broken', 'agent', 2),
node('busy', 'agent', 3),
];
const step = (
nodeId: string,
status: WorkflowExecutionStep['status'],
): WorkflowExecutionStep => ({
nodeId,
nodeType: 'agent',
nodeTitle: nodeId,
status,
});
const steps = [
step('done', 'completed'),
step('broken', 'failed'),
step('busy', 'running'),
];
describe('WorkflowMiniMap', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const rows = (activeNodeId: string | null = null) => {
act(() => {
root.render(
<WorkflowMiniMap
nodes={nodes}
activeNodeId={activeNodeId}
executionSteps={steps}
/>,
);
});
const byName: Record<string, HTMLButtonElement> = {};
container.querySelectorAll<HTMLButtonElement>('button').forEach((b) => {
byName[b.textContent ?? ''] = b;
});
return byName;
};
const tokens = (el: HTMLElement) => el.className.split(/\s+/);
it('pins the success fill on hover for a completed row', () => {
const done = tokens(rows().done);
expect(done).toEqual(
expect.arrayContaining([
'bg-success/10',
'hover:bg-success/10',
'dark:hover:bg-success/10',
'hover:opacity-80',
]),
);
expect(done).not.toContain('hover:bg-accent');
expect(done).not.toContain('dark:hover:bg-input/50');
});
it('pins each status fill on hover', () => {
const r = rows();
expect(tokens(r.broken)).toEqual(
expect.arrayContaining([
'hover:bg-destructive/10',
'dark:hover:bg-destructive/10',
]),
);
expect(tokens(r.busy)).toEqual(
expect.arrayContaining([
'hover:bg-primary/10',
'dark:hover:bg-primary/10',
'animate-pulse',
]),
);
expect(tokens(r.Start)).toEqual(
expect.arrayContaining([
'bg-muted',
'hover:bg-muted',
'dark:hover:bg-muted',
]),
);
expect(r.Start.disabled).toBe(true);
expect(tokens(r.Start)).not.toContain('hover:opacity-80');
});
it('pins the active fill on hover and keeps the ring', () => {
const done = tokens(rows('done').done);
expect(done).toEqual(
expect.arrayContaining([
'ring-2',
'ring-primary',
'hover:bg-primary/10',
'dark:hover:bg-primary/10',
]),
);
expect(done).not.toContain('hover:bg-accent');
});
});
+107 -103
View File
@@ -2,6 +2,7 @@ import {
Bot,
CheckCircle2,
Circle,
CircleAlert,
Code2,
Database,
FileBox,
@@ -17,6 +18,7 @@ import {
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
MessageScroller,
@@ -77,13 +79,13 @@ const NODE_ICONS: Record<string, React.ReactNode> = {
};
const NODE_COLORS: Record<string, string> = {
start: 'text-green-600 dark:text-green-400',
agent: 'text-purple-600 dark:text-purple-400',
end: 'text-gray-600 dark:text-gray-400',
note: 'text-yellow-600 dark:text-yellow-400',
state: 'text-blue-600 dark:text-blue-400',
condition: 'text-orange-600 dark:text-orange-400',
code: 'text-indigo-600 dark:text-indigo-400',
start: 'text-success',
agent: 'text-primary',
end: 'text-destructive',
note: 'text-warning',
state: 'text-info',
condition: 'text-warning',
code: 'text-info',
};
function ExecutionDetails({
@@ -116,17 +118,18 @@ function ExecutionDetails({
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<Workflow className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<Workflow className="text-muted-foreground h-5 w-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="h-auto gap-2 px-0 py-0 hover:bg-transparent"
className="-ml-2.5"
>
<p className="text-base font-semibold">
Execution Details
<span className="ml-1.5 text-sm font-normal text-gray-500 dark:text-gray-400">
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
({completedSteps.length}{' '}
{completedSteps.length === 1 ? 'step' : 'steps'})
</span>
@@ -178,7 +181,7 @@ function ExecutionDetails({
className="bg-muted dark:bg-accent rounded-xl p-3"
>
<div className="flex items-center gap-2 text-sm">
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs font-medium text-gray-500 dark:text-gray-400">
<span className="text-muted-foreground flex h-5 w-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<div
@@ -191,15 +194,15 @@ function ExecutionDetails({
<Circle className="h-3 w-3" />
)}
</div>
<span className="min-w-0 truncate font-medium text-gray-900 dark:text-white">
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<CheckCircle2 className="text-success h-4 w-4" />
)}
{step.status === 'failed' && (
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
<XCircle className="text-destructive h-4 w-4" />
)}
</div>
</div>
@@ -207,23 +210,24 @@ function ExecutionDetails({
<div className="mt-3 space-y-2 text-sm">
{hasOutput && (
<div className="bg-muted rounded-lg p-2">
<span className="font-medium text-gray-600 dark:text-gray-400">
<span className="text-muted-foreground font-medium">
Output:{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap text-gray-900 dark:text-gray-100">
<span className="text-foreground wrap-break-word whitespace-pre-wrap">
{truncateText(formattedOutput, 300)}
</span>
</div>
)}
{step.error && (
<div className="rounded-lg bg-red-50 p-2 dark:bg-red-900/30">
<span className="font-medium text-red-700 dark:text-red-300">
Error:{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap text-red-800 dark:text-red-200">
{step.error}
</span>
</div>
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">Error: </span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
</AlertDescription>
</Alert>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
@@ -232,11 +236,11 @@ function ExecutionDetails({
key={key}
className="bg-muted inline-flex items-center rounded-lg px-2 py-1 text-xs"
>
<span className="max-w-[100px] truncate font-medium text-gray-600 dark:text-gray-400">
<span className="text-muted-foreground max-w-[100px] truncate font-medium">
{key}:
</span>
<span
className="ml-1 max-w-[200px] truncate text-gray-900 dark:text-gray-100"
className="text-foreground ml-1 max-w-[200px] truncate"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
@@ -272,13 +276,14 @@ function RunArtifactsSection({
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<FileBox className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<FileBox className="text-muted-foreground h-5 w-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="h-auto gap-2 px-0 py-0 hover:bg-transparent"
className="-ml-2.5"
>
<p className="text-base font-semibold">Artifacts</p>
<img
@@ -312,7 +317,7 @@ function RunArtifactsSection({
);
}
function WorkflowMiniMap({
export function WorkflowMiniMap({
nodes,
activeNodeId,
executionSteps,
@@ -341,26 +346,6 @@ function WorkflowMiniMap({
return step?.status || 'pending';
};
const getStatusColor = (nodeId: string) => {
const status = getNodeStatus(nodeId);
const isActive = nodeId === activeNodeId;
if (isActive) {
return 'ring-2 ring-purple-500 bg-purple-100 dark:bg-purple-900/50';
}
switch (status) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 border-green-300 dark:border-green-700';
case 'running':
return 'bg-purple-100 dark:bg-purple-900/30 border-purple-300 dark:border-purple-700 animate-pulse';
case 'failed':
return 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700';
default:
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
}
};
const executedOrder = new Map(executionSteps.map((s, i) => [s.nodeId, i]));
const startNode = nodes.find((node) => node.type === 'start');
const visibleNodeIds = new Set(executionSteps.map((step) => step.nodeId));
@@ -392,55 +377,73 @@ function WorkflowMiniMap({
return (
<div className="space-y-1">
{sortedNodes.map((node, index) => (
<div key={node.id} className="relative">
{index < sortedNodes.length - 1 && (
<div className="absolute top-12 left-4 h-3 w-0.5 bg-gray-200 dark:bg-gray-700" />
)}
<Button
type="button"
variant="outline"
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
disabled={!hasStepData(node.id)}
className={cn(
'h-12 w-full justify-start gap-2 px-3 text-xs disabled:opacity-100',
getStatusColor(node.id),
hasStepData(node.id) && 'hover:opacity-80',
{sortedNodes.map((node, index) => {
const status = getNodeStatus(node.id);
const isActive = node.id === activeNodeId;
return (
<div key={node.id} className="relative">
{index < sortedNodes.length - 1 && (
<div className="bg-border absolute top-12 left-4 h-3 w-0.5" />
)}
>
<div
<Button
type="button"
variant="outline"
size="xs"
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
disabled={!hasStepData(node.id)}
/* eslint-disable shadcn/no-restyle --
Preview minimap status row: the fill, border and ring follow the step status (success, primary running + pulse,
destructive, muted pending), pending and running rows stay unfaded while disabled, and clickable rows dim on hover.
No Button variant is status-tinted. See DESIGN.md, Approved exceptions. */
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
NODE_COLORS[node.type] || NODE_COLORS.state,
'h-12 w-full justify-start disabled:opacity-100',
isActive
? 'bg-primary/10 ring-primary hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/10 ring-2'
: status === 'completed'
? 'border-success/50 bg-success/10 hover:bg-success/10 dark:border-success/50 dark:bg-success/10 dark:hover:bg-success/10'
: status === 'running'
? 'border-primary/50 bg-primary/10 hover:bg-primary/10 dark:border-primary/50 dark:bg-primary/10 dark:hover:bg-primary/10 animate-pulse'
: status === 'failed'
? 'border-destructive/50 bg-destructive/10 hover:bg-destructive/10 dark:border-destructive/50 dark:bg-destructive/10 dark:hover:bg-destructive/10'
: 'border-border bg-muted hover:bg-muted dark:border-border dark:bg-muted dark:hover:bg-muted',
hasStepData(node.id) && 'hover:opacity-80',
)}
/* eslint-enable shadcn/no-restyle */
>
{NODE_ICONS[node.type] || <Circle className="h-3 w-3" />}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate font-medium text-gray-700 dark:text-gray-200">
{getNodeDisplayName(node)}
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
NODE_COLORS[node.type] || NODE_COLORS.state,
)}
>
{NODE_ICONS[node.type] || <Circle className="h-3 w-3" />}
</div>
{getNodeSubtitle(node) && (
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
{getNodeSubtitle(node)}
<div className="min-w-0 flex-1 text-left">
<div className="text-foreground truncate font-medium">
{getNodeDisplayName(node)}
</div>
)}
</div>
<div className="shrink-0">
{getNodeStatus(node.id) === 'running' && (
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
)}
{getNodeStatus(node.id) === 'completed' && (
<CheckCircle2 className="h-3 w-3 text-green-500" />
)}
{getNodeStatus(node.id) === 'failed' && (
<XCircle className="h-3 w-3 text-red-500" />
)}
</div>
</Button>
</div>
))}
{getNodeSubtitle(node) && (
<div className="text-muted-foreground truncate text-xs">
{getNodeSubtitle(node)}
</div>
)}
</div>
<div className="shrink-0">
{status === 'running' && (
<Loader2 className="text-primary h-3 w-3 animate-spin" />
)}
{status === 'completed' && (
<CheckCircle2 className="text-success h-3 w-3" />
)}
{status === 'failed' && (
<XCircle className="text-destructive h-3 w-3" />
)}
</div>
</Button>
</div>
);
})}
</div>
);
}
@@ -605,21 +608,19 @@ export default function WorkflowPreview({
<div className="bg-card flex h-full flex-col">
<div className="border-border flex h-[77px] items-center justify-between border-b px-6">
<div className="flex items-center gap-3">
<div className="bg-muted flex items-center justify-center rounded-full p-3 text-gray-600 dark:text-gray-300">
<div className="bg-muted text-muted-foreground flex items-center justify-center rounded-full p-3">
<Play className="h-4 w-4" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Preview
</h2>
<p className="max-w-md truncate text-xs text-gray-500 dark:text-gray-400">
<h2 className="text-foreground text-xl font-bold">Preview</h2>
<p className="text-muted-foreground max-w-md truncate text-xs">
{workflowData.name}
{workflowData.description && ` - ${workflowData.description}`}
</p>
</div>
</div>
{status === 'loading' && (
<span className="text-primary dark:text-primary flex items-center gap-1 text-xs">
<span className="text-primary flex items-center gap-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
Running
</span>
@@ -629,7 +630,7 @@ export default function WorkflowPreview({
<div className="flex min-h-0 flex-1">
<div className="border-border flex w-64 shrink-0 flex-col border-r">
<div className="flex items-center justify-between px-4 py-3">
<h3 className="text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
Workflow
</h3>
</div>
@@ -650,16 +651,19 @@ export default function WorkflowPreview({
{queries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4">
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
<MessageSquare className="size-6 text-gray-600 dark:text-gray-300" />
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-xl font-semibold text-gray-700 dark:text-gray-200">
<p className="text-foreground text-xl font-semibold">
Test the workflow
</p>
</div>
) : (
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport className="scrollbar-thin px-4 pt-4">
<MessageScrollerViewport
/* eslint-disable-next-line shadcn/no-restyle -- MessageScroller padding: the primitive measures Content's padding-block for its scroll math, and the Viewport's top gap must scroll with the messages. See DESIGN.md, Approved exceptions. */
className="scrollbar-thin px-4 pt-4"
>
<MessageScrollerContent className="w-full">
{queries.map((query, index) => {
const querySteps = query.executionSteps || [];
@@ -776,7 +780,7 @@ export default function WorkflowPreview({
</div>
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
{sendBlockedMessage && (
<p className="text-xs text-red-500" role="alert">
<p className="text-destructive text-xs" role="alert">
{sendBlockedMessage}
</p>
)}
@@ -131,7 +131,7 @@ export default function WorkflowRunArtifacts({
if (loading) {
return (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<div className="text-muted-foreground flex items-center gap-2 px-3 py-4 text-sm">
<Spinner size="small" /> Loading artifacts...
</div>
);
@@ -139,7 +139,7 @@ export default function WorkflowRunArtifacts({
if (error) {
return (
<div className="flex items-center justify-between gap-2 px-3 py-3 text-sm text-red-500">
<div className="text-destructive flex items-center justify-between gap-2 px-3 py-3 text-sm">
<span>{error}</span>
<Button type="button" variant="outline" size="sm" onClick={loadList}>
Retry
@@ -150,7 +150,7 @@ export default function WorkflowRunArtifacts({
if (!artifacts || artifacts.length === 0) {
return (
<div className="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">
<div className="text-muted-foreground px-3 py-3 text-sm">
{inProgress
? 'Run in progress — artifacts appear here as nodes produce them.'
: 'No artifacts produced by this run.'}
@@ -166,7 +166,6 @@ export default function WorkflowRunArtifacts({
type="button"
variant="ghost"
size="sm"
className="gap-1 px-2"
onClick={() => setSelectedId(null)}
>
<ChevronLeft className="h-4 w-4" />
@@ -180,7 +179,7 @@ export default function WorkflowRunArtifacts({
</div>
) : detailError ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-red-500">{detailError}</p>
<p className="text-destructive text-sm">{detailError}</p>
</div>
) : detail ? (
<DocumentArtifactView
@@ -201,14 +200,14 @@ export default function WorkflowRunArtifacts({
type="button"
variant="outline"
onClick={() => setSelectedId(artifact.id)}
className="h-auto w-full justify-start gap-3 px-3 py-2 text-left"
className="h-auto w-full justify-start text-left"
>
<FileBox className="h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400" />
<FileBox className="text-muted-foreground h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
<div className="text-foreground truncate text-sm font-medium">
{artifact.title || `Artifact ${artifact.id.slice(0, 8)}`}
</div>
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
<div className="text-muted-foreground truncate text-xs">
{artifact.kind || 'file'}
{artifact.current_version != null
? ` · v${artifact.current_version}`
@@ -60,7 +60,7 @@ export default function NodeDocumentsControl({
return (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
{label}
</label>
<div className="border-border bg-card flex gap-1 rounded-xl border p-1">
@@ -68,14 +68,10 @@ export default function NodeDocumentsControl({
<Button
key={optionMode}
type="button"
variant="ghost"
size="sm"
variant={mode === optionMode ? 'outline' : 'ghost-muted'}
size="xs"
onClick={() => selectMode(optionMode)}
className={`h-auto flex-1 rounded-lg px-2 py-1.5 text-xs font-medium ${
mode === optionMode
? 'bg-primary text-white'
: 'text-gray-600 dark:text-gray-300'
}`}
className="flex-1"
>
{modeLabel}
</Button>
@@ -104,15 +100,13 @@ export default function NodeDocumentsControl({
addRef();
}
}}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Add ref (e.g. A1)"
/>
<Button
type="button"
variant="ghost"
size="sm"
variant="ghost-muted"
onClick={addRef}
className="h-auto shrink-0 gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300"
className="shrink-0"
>
<Plus size={14} />
Add
@@ -1,5 +1,12 @@
import { Braces, Plus, Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type CSSProperties,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Edge, Node } from 'reactflow';
import { Button } from '@/components/ui/button';
@@ -221,7 +228,7 @@ function HighlightedOverlay({ text }: { text: string }) {
{part}
</span>
) : (
<span key={i} className="text-gray-900 dark:text-white">
<span key={i} className="text-foreground">
{part}
</span>
),
@@ -260,7 +267,7 @@ function VariableListWithSearch({
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search variables..."
className="h-auto rounded-none border-0 px-0 py-0 text-sm text-gray-800 shadow-none focus-visible:ring-0 md:text-sm dark:border-0 dark:text-gray-200"
variant="bare"
/>
</div>
@@ -280,15 +287,16 @@ function VariableListWithSearch({
key={`${section}-${v.templatePath}`}
type="button"
variant="ghost"
size="sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(v.templatePath);
}}
className="h-auto w-full justify-start gap-2 rounded-none px-3 py-1.5 text-left text-sm font-normal"
className="w-full justify-start"
>
<Braces className="text-primary h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium text-gray-800 dark:text-gray-200">
<span className="text-foreground truncate font-medium">
{v.label}
</span>
</Button>
@@ -439,7 +447,7 @@ export default function PromptTextArea({
return (
<div>
{label && (
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-foreground mb-2 block text-sm font-medium">
{label}
</label>
)}
@@ -455,9 +463,7 @@ export default function PromptTextArea({
{value ? (
<HighlightedOverlay text={value} />
) : (
<span className="text-gray-400 dark:text-gray-500">
{placeholder}
</span>
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
@@ -481,11 +487,7 @@ export default function PromptTextArea({
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
}
}}
className="focus-visible:ring-ring/50 focus-visible:border-ring relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm caret-black outline-none focus-visible:ring-[3px] dark:caret-white"
style={{
color: 'transparent',
WebkitTextFillColor: 'transparent',
}}
className="focus-visible:ring-ring/50 focus-visible:border-ring relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm text-transparent caret-black outline-none focus-visible:ring-3 dark:caret-white"
rows={rows}
placeholder={placeholder}
spellCheck={false}
@@ -494,11 +496,7 @@ export default function PromptTextArea({
<div className="absolute right-4 bottom-1.5 z-10">
<Popover open={contextOpen} onOpenChange={setContextOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="text-primary hover:bg-primary/10 h-auto gap-1 px-2 py-1 text-xs font-medium"
>
<Button type="button" variant="link" size="xs">
<Plus className="h-3 w-3" />
Add context
</Button>
@@ -506,7 +504,7 @@ export default function PromptTextArea({
<PopoverContent
align="end"
side="top"
className="border-border bg-card w-60 rounded-xl border p-0 shadow-lg"
className="w-60 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<VariableListWithSearch
@@ -520,8 +518,13 @@ export default function PromptTextArea({
{showDropdown && filtered.length > 0 && (
<div
ref={dropdownRef}
className="border-border bg-card absolute z-50 w-64 rounded-xl border shadow-lg"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
className="border-border bg-card absolute top-(--dropdown-top) left-(--dropdown-left) z-50 w-64 rounded-xl border shadow-lg"
style={
{
'--dropdown-top': `${dropdownPos.top}px`,
'--dropdown-left': `${dropdownPos.left}px`,
} as CSSProperties
}
>
<VariableListWithSearch
variables={filtered}
+16 -16
View File
@@ -23,28 +23,28 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
}) => {
let bgColor = 'bg-card';
let borderColor = 'border-border';
let iconBg = 'bg-gray-100 dark:bg-gray-800';
let iconColor = 'text-gray-600 dark:text-gray-400';
let iconBg = 'bg-muted';
let iconColor = 'text-muted-foreground';
if (selected) {
borderColor = 'border-primary ring-2 ring-primary';
}
if (type === 'start') {
iconBg = 'bg-green-100 dark:bg-green-900/30';
iconColor = 'text-green-600 dark:text-green-400';
iconBg = 'bg-success/10';
iconColor = 'text-success';
} else if (type === 'end') {
iconBg = 'bg-red-100 dark:bg-red-900/30';
iconColor = 'text-red-600 dark:text-red-400';
iconBg = 'bg-destructive/10';
iconColor = 'text-destructive';
} else if (type === 'state') {
iconBg = 'bg-gray-100 dark:bg-gray-800';
iconColor = 'text-gray-600 dark:text-gray-400';
iconBg = 'bg-muted';
iconColor = 'text-muted-foreground';
} else if (type === 'condition') {
iconBg = 'bg-orange-100 dark:bg-orange-900/30';
iconColor = 'text-orange-600 dark:text-orange-400';
iconBg = 'bg-warning/10';
iconColor = 'text-warning';
} else if (type === 'code') {
iconBg = 'bg-indigo-100 dark:bg-indigo-900/30';
iconColor = 'text-indigo-600 dark:text-indigo-400';
iconBg = 'bg-info/10';
iconColor = 'text-info';
}
return (
@@ -58,7 +58,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
type="target"
position={Position.Left}
isConnectable={true}
className="hover:bg-primary/90! border-card! -left-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
className="hover:bg-primary/90! border-card! bg-muted-foreground! -left-1! h-3! w-3! rounded-full! border-2! transition-colors!"
/>
)}
@@ -70,13 +70,13 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
</div>
<div className="min-w-0 flex-1 pr-3">
<div
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</div>
{children && (
<div className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">
<div className="text-muted-foreground mt-1 truncate text-xs">
{children}
</div>
)}
@@ -88,7 +88,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
type="source"
position={Position.Right}
isConnectable={true}
className="hover:bg-primary/90! border-card! -right-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
className="hover:bg-primary/90! border-card! bg-muted-foreground! -right-1! h-3! w-3! rounded-full! border-2! transition-colors!"
/>
)}
</div>
@@ -30,19 +30,17 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
<div className="flex flex-col gap-1">
{codeHint ? (
<div
className="truncate font-mono text-xs text-gray-500 dark:text-gray-400"
className="text-muted-foreground truncate font-mono text-xs"
title={code}
>
{codeHint}
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400">
No code yet
</div>
<div className="text-muted-foreground text-xs">No code yet</div>
)}
{config.output_variable && (
<div
className="truncate text-xs text-gray-500 dark:text-gray-400"
className="text-muted-foreground truncate text-xs"
title={`Output: ${config.output_variable}`}
>
Output: {config.output_variable}
@@ -38,7 +38,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
<div
className={`bg-card relative rounded-2xl border shadow-md transition-all ${
selected
? 'border-primary dark:ring-primary scale-105 ring-2 ring-purple-300'
? 'border-primary ring-primary scale-105 ring-2'
: 'border-border hover:shadow-lg'
}`}
style={{ minWidth: 180, maxWidth: 220, height }}
@@ -47,21 +47,21 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
type="target"
position={Position.Left}
isConnectable
className="hover:bg-primary/90! border-card! top-1/2! -left-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
className="hover:bg-primary/90! border-card! bg-muted-foreground! top-1/2! -left-1! h-3! w-3! rounded-full! border-2! transition-colors!"
/>
<div className="flex items-center gap-3 px-3 py-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400">
<div className="bg-warning/10 text-warning flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<GitBranch size={14} />
</div>
<div className="min-w-0 flex-1 pr-2">
<div
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</div>
<div className="text-xs text-gray-500 uppercase">
<div className="text-muted-foreground text-xs uppercase">
{data.config?.mode || 'simple'}
</div>
</div>
@@ -74,12 +74,12 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
className="flex items-center gap-1"
style={{ height: ROW_HEIGHT }}
>
<span className="shrink-0 text-xs font-medium text-orange-600 dark:text-orange-400">
<span className="text-warning shrink-0 text-xs font-medium">
{i === 0 ? 'If' : 'Else if'}
</span>
{c.name && (
<span
className="truncate text-xs text-gray-600 dark:text-gray-400"
className="text-muted-foreground truncate text-xs"
title={c.name}
>
{c.name}
@@ -88,7 +88,9 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
</div>
))}
<div className="flex items-center gap-1" style={{ height: ROW_HEIGHT }}>
<span className="text-xs font-medium text-gray-500">Else</span>
<span className="text-muted-foreground text-xs font-medium">
Else
</span>
</div>
</div>
@@ -100,7 +102,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
id={c.sourceHandle}
isConnectable
style={{ top: getHandleTop(i, totalOutputs) }}
className="hover:bg-primary/90! dark:border-border! -right-1! h-3! w-3! rounded-full! border-2! border-white! bg-orange-400! transition-colors"
className="hover:bg-primary/90! border-card! bg-warning! -right-1! h-3! w-3! rounded-full! border-2! transition-colors"
/>
))}
<Handle
@@ -109,7 +111,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
id="else"
isConnectable
style={{ top: getHandleTop(cases.length, totalOutputs) }}
className="hover:bg-primary/90! border-card! -right-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
className="hover:bg-primary/90! border-card! bg-muted-foreground! -right-1! h-3! w-3! rounded-full! border-2! transition-colors!"
/>
</div>
);
@@ -31,7 +31,7 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
<div className="flex flex-col gap-1">
{operations.length > 0 ? (
<div
className="truncate text-xs text-gray-500"
className="text-muted-foreground truncate text-xs"
title={`${operations.length} operation(s)`}
>
{operations.length} variable{operations.length !== 1 ? 's' : ''}
@@ -39,14 +39,14 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
) : hasLegacy ? (
<>
<div
className="truncate text-xs text-gray-500 uppercase"
className="text-muted-foreground truncate text-xs uppercase"
title={`Variable: ${data.variable}`}
>
{data.variable}
</div>
{data.value && (
<div
className="truncate text-xs text-blue-600 dark:text-blue-400"
className="text-info truncate text-xs"
title={`Value: ${data.value}`}
>
{data.value}
+18 -11
View File
@@ -1,6 +1,8 @@
import { Bot, Flag, Play, StickyNote } from 'lucide-react';
import { memo } from 'react';
import { cn } from '@/lib/utils';
import { BaseNode } from './BaseNode';
import CodeNode from './CodeNode';
import ConditionNode from './ConditionNode';
@@ -19,7 +21,9 @@ export const StartNode = memo(function StartNode({
handles={{ target: false, source: true }}
icon={<Play size={16} />}
>
<div className="text-xs text-gray-500">Entry point of the workflow</div>
<div className="text-muted-foreground text-xs">
Entry point of the workflow
</div>
</BaseNode>
);
});
@@ -37,7 +41,7 @@ export const EndNode = memo(function EndNode({
handles={{ target: true, source: false }}
icon={<Flag size={16} />}
>
<div className="text-xs text-gray-500">Workflow completion</div>
<div className="text-muted-foreground text-xs">Workflow completion</div>
</BaseNode>
);
});
@@ -70,7 +74,7 @@ export const AgentNode = memo(function AgentNode({
<div className="flex flex-col gap-1">
{config.agent_type && (
<div
className="truncate text-xs text-gray-500 uppercase"
className="text-muted-foreground truncate text-xs uppercase"
title={config.agent_type}
>
{config.agent_type}
@@ -86,7 +90,7 @@ export const AgentNode = memo(function AgentNode({
)}
{config.output_variable && (
<div
className="truncate text-xs text-gray-500 dark:text-gray-400"
className="text-muted-foreground truncate text-xs"
title={`Output: ${config.output_variable}`}
>
Output: {config.output_variable}
@@ -113,26 +117,29 @@ export const NoteNode = memo(function NoteNode({
return (
<div
className={`max-w-[250px] rounded-3xl border border-yellow-200 bg-yellow-50 px-5 py-3 shadow-md transition-all dark:border-yellow-800 dark:bg-yellow-900/20 ${
className={cn(
// Opaque tint: the card colour underneath, the warning wash painted
// over it as a flat gradient, so the canvas grid doesn't show through.
'bg-card from-warning/10 to-warning/10 max-w-[250px] rounded-3xl border bg-linear-to-b px-5 py-3 shadow-md transition-all',
selected
? 'scale-105 ring-2 ring-yellow-300 dark:ring-yellow-700'
: 'hover:shadow-lg'
}`}
? 'border-warning ring-warning scale-105 ring-2'
: 'border-warning/50 hover:shadow-lg',
)}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-800/30 dark:text-yellow-500">
<div className="bg-warning/15 text-warning flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
<StickyNote size={18} />
</div>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold text-yellow-800 dark:text-yellow-300"
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</div>
{displayContent && (
<div
className="mt-1 text-xs wrap-break-word text-yellow-700 italic dark:text-yellow-400"
className="text-muted-foreground mt-1 text-xs wrap-break-word italic"
title={data.content}
>
{displayContent}
+5 -4
View File
@@ -53,11 +53,12 @@ export default function ActionButtons({
{showNewChat && (
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
shape="pill"
title={t('actionButtons.openNewChat')}
onClick={newChat}
className="text-muted-foreground hover:text-foreground rounded-full lg:hidden"
className="lg:hidden"
>
<Plus className="size-5" strokeWidth={1.75} aria-label="NewChat" />
</Button>
@@ -67,11 +68,11 @@ export default function ActionButtons({
<>
<Button
type="button"
variant="ghost"
variant="ghost-muted"
size="icon"
shape="pill"
title={t('actionButtons.share')}
onClick={() => setShareModalState(true)}
className="text-muted-foreground hover:text-foreground rounded-full"
>
<Share className="size-5" strokeWidth={1.75} aria-label="share" />
</Button>
+56 -51
View File
@@ -9,6 +9,8 @@ import {
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
import userService from '../api/services/userService';
import { useDarkTheme } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
@@ -16,6 +18,7 @@ import { isDocumentArtifact, type DocumentArtifact } from './artifactViewUtils';
import CopyButton from './CopyButton';
import DocumentArtifactView from './DocumentArtifactView';
import Spinner from './Spinner';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Sheet, SheetContent } from './ui/sheet';
@@ -82,17 +85,13 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-end">
<div className="flex gap-2 text-xs">
<span className="rounded-full bg-green-100 px-2 py-1 text-green-700 dark:bg-green-900/30 dark:text-green-400">
{data.completed_count} done
</span>
<span className="rounded-full bg-blue-100 px-2 py-1 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{data.open_count} open
</span>
<Badge variant="success">{data.completed_count} done</Badge>
<Badge variant="info">{data.open_count} open</Badge>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{data.items.length === 0 ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground text-center text-sm">
No todos yet
</p>
) : (
@@ -100,18 +99,20 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
{data.items.map((item, index) => (
<li
key={`${item.todo_id}-${index}`}
className={`flex items-start gap-3 rounded-lg border p-3 ${
className={cn(
'flex items-start gap-3 rounded-lg border p-3',
item.status === 'completed'
? 'border-green-300 dark:border-green-800'
: 'border-gray-200 dark:border-gray-700'
}`}
? 'border-success/50'
: 'border-border',
)}
>
<span
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${
className={cn(
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2',
item.status === 'completed'
? 'border-green-500 bg-green-500 text-white'
: 'border-gray-300 dark:border-gray-600'
}`}
? 'border-success bg-success text-success-foreground'
: 'border-input',
)}
>
{item.status === 'completed' && (
<svg
@@ -131,15 +132,18 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
</span>
<div className="flex-1">
<p
className={`text-sm ${
className={cn(
'text-sm',
item.status === 'completed'
? 'text-gray-500 line-through dark:text-gray-400'
: 'text-gray-900 dark:text-white'
}`}
? 'text-muted-foreground line-through'
: 'text-foreground',
)}
>
{item.title}
</p>
<p className="mt-1 text-xs text-gray-400">#{item.todo_id}</p>
<p className="text-muted-foreground mt-1 text-xs">
#{item.todo_id}
</p>
</div>
</li>
))}
@@ -157,7 +161,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
<span className="text-muted-foreground text-xs">
{data.line_count} lines
</span>
<CopyButton textToCopy={data.content || ''} />
@@ -165,7 +169,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
</div>
<div className="flex-1 overflow-y-auto p-4">
{data.content ? (
<div className="flex flex-col gap-3 text-sm leading-normal wrap-break-word whitespace-pre-wrap text-gray-800 dark:text-gray-200">
<div className="text-foreground flex flex-col gap-3 text-sm leading-normal wrap-break-word whitespace-pre-wrap">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@@ -185,7 +189,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
return match ? (
<div className="group border-border relative my-2 overflow-hidden rounded-xl border">
<div className="bg-muted flex items-center justify-between px-2 py-1">
<span className="text-foreground dark:text-foreground text-xs font-medium">
<span className="text-foreground text-xs font-medium">
{language}
</span>
<CopyButton
@@ -196,6 +200,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
{...rest}
PreTag="div"
language={language}
/* eslint-disable-next-line shadcn/no-inline-styles -- SyntaxHighlighter's style prop is its Prism theme object (oneLight / vscDarkPlus), picked by theme at runtime; it is not CSS. See DESIGN.md, Approved exceptions. */
style={isDarkTheme ? vscDarkPlus : oneLight}
customStyle={{
margin: 0,
@@ -208,7 +213,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
</div>
) : (
<code
className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal"
className="bg-accent rounded-md px-2 py-1 text-xs font-normal"
{...rest}
>
{children}
@@ -235,7 +240,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline dark:text-blue-400"
className="text-primary hover:underline"
>
{children}
</a>
@@ -255,7 +260,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
},
blockquote({ children }) {
return (
<blockquote className="border-l-4 border-gray-300 pl-4 italic dark:border-gray-600">
<blockquote className="border-border border-l-4 pl-4 italic">
{children}
</blockquote>
);
@@ -266,7 +271,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">Empty note</p>
<p className="text-muted-foreground text-sm">Empty note</p>
)}
</div>
</div>
@@ -446,7 +451,7 @@ export default function ArtifactSidebar({
if (error) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-red-500">{error}</p>
<p className="text-destructive text-sm">{error}</p>
</div>
);
}
@@ -462,9 +467,7 @@ export default function ArtifactSidebar({
if (!artifact) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Artifact not found
</p>
<p className="text-muted-foreground text-sm">Artifact not found</p>
</div>
);
}
@@ -475,7 +478,7 @@ export default function ArtifactSidebar({
return <NoteView data={artifact.data} />;
default:
return (
<pre className="text-xs text-gray-600 dark:text-gray-400">
<pre className="text-muted-foreground text-xs">
{JSON.stringify(artifact, null, 2)}
</pre>
);
@@ -490,16 +493,16 @@ export default function ArtifactSidebar({
{/* Space for top bar / actions */}
<div className="h-14 shrink-0" />
{/* Artifact panel */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-transparent dark:border-gray-700">
<div className="border-border flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border bg-transparent">
<div className="flex w-full items-center justify-between px-4 py-2">
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
<span className="text-muted-foreground text-sm font-medium">
{title}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="rounded-full"
shape="pill"
onClick={onClose}
aria-label="Close"
>
@@ -523,24 +526,26 @@ export default function ArtifactSidebar({
side="right"
showCloseButton={false}
title={title || 'Artifact preview'}
className="dark:bg-card bg-card flex h-full w-80 flex-col gap-0 border-l border-[#9ca3af]/10 p-0 sm:w-96 sm:max-w-none"
className="h-full w-80 p-0 sm:w-96 sm:max-w-none"
>
<div className="flex w-full items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
{title}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full"
onClick={onClose}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-border flex w-full items-center justify-between border-b px-4 py-3">
<span className="text-muted-foreground text-sm font-medium">
{title}
</span>
<Button
type="button"
variant="ghost"
size="icon"
shape="pill"
onClick={onClose}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
</div>
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
</SheetContent>
</Sheet>
);
+60 -57
View File
@@ -3,6 +3,8 @@ import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { cn } from '@/lib/utils';
import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import FileIcon from '../assets/file.svg';
@@ -51,7 +53,7 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
onChange(e.target.value);
};
const lineHeight = 19.93;
const lineHeight = 20;
const contentLines = value.split('\n').length;
const heightOffset = isMobile ? 200 : 300;
@@ -62,24 +64,30 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
const totalLines = Math.max(contentLines, minLinesForDisplay);
return (
<div className={`relative w-full ${className}`}>
<div
className="pointer-events-none absolute top-0 left-0 w-8 pr-2 text-right font-mono text-xs leading-[19.93px] text-gray-500 select-none lg:w-12 lg:pr-3 lg:text-sm dark:text-gray-400"
style={{
height: `${totalLines * lineHeight}px`,
}}
>
<div
className={cn('relative w-full', className)}
style={
{
'--numbered-height': `${totalLines * lineHeight}px`,
} as React.CSSProperties
}
>
<div className="text-muted-foreground pointer-events-none absolute top-0 left-0 h-(--numbered-height) w-8 pr-2 text-right font-mono text-xs leading-5 select-none lg:w-12 lg:pr-3 lg:text-sm">
{Array.from({ length: totalLines }, (_, i) => (
<div
key={i + 1}
className="flex h-[19.93px] items-center justify-end leading-[19.93px]"
className="flex h-5 items-center justify-end leading-5"
>
{i + 1}
</div>
))}
</div>
<textarea
className={`text-foreground focus-visible:ring-ring/50 focus-visible:border-ring w-full resize-none overflow-hidden border-none bg-transparent pl-8 font-['Inter'] text-[13.68px] leading-[19.93px] outline-none focus-visible:ring-[3px] lg:pl-12 dark:text-white ${isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]'} ${!editable ? 'select-none' : ''}`}
className={cn(
'text-foreground focus-visible:ring-ring/50 focus-visible:border-ring h-(--numbered-height) w-full resize-none overflow-hidden border-none bg-transparent pl-8 text-sm leading-5 outline-none focus-visible:ring-3 lg:pl-12',
isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]',
!editable && 'select-none',
)}
value={value}
onChange={editable ? handleChange : undefined}
onDoubleClick={onDoubleClick}
@@ -87,9 +95,6 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
aria-label={ariaLabel}
rows={totalLines}
readOnly={!editable}
style={{
height: `${totalLines * lineHeight}px`,
}}
/>
</div>
);
@@ -309,8 +314,10 @@ const Chunks: React.FC<ChunksProps> = ({
<Button
type="button"
variant="outline"
size="icon-sm"
className="mr-3 h-[29px] w-[29px] rounded-full p-2 text-gray-400 dark:border-0 dark:text-gray-500"
size="icon-xs"
shape="pill"
className="mr-3"
aria-label={t('settings.sources.back')}
onClick={
editingChunk
? () => setEditingChunk(null)
@@ -330,20 +337,22 @@ const Chunks: React.FC<ChunksProps> = ({
{pathParts.length > 0 && (
<>
<span className="mx-1 shrink-0 text-gray-500">/</span>
<span className="text-muted-foreground mx-1 shrink-0">/</span>
{pathParts.map((part, index) => (
<React.Fragment key={index}>
<span
className={`wrap-break-word ${
index < pathParts.length - 1
? 'text-primary font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-foreground'
}`}
>
{part}
</span>
{index < pathParts.length - 1 && (
<span className="mx-1 shrink-0 text-gray-500">/</span>
<span className="text-muted-foreground mx-1 shrink-0">
/
</span>
)}
</React.Fragment>
))}
@@ -359,7 +368,8 @@ const Chunks: React.FC<ChunksProps> = ({
<>
<Button
type="button"
className="h-[38px] min-w-[108px] rounded-full px-4 text-sm font-medium whitespace-nowrap"
shape="pill"
className="min-w-[108px]"
onClick={() => setIsEditing(true)}
>
{t('modals.chunk.edit')}
@@ -367,7 +377,8 @@ const Chunks: React.FC<ChunksProps> = ({
<Button
type="button"
variant="destructive-outline"
className="h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap"
shape="pill"
className="min-w-[108px]"
onClick={() => {
confirmDeleteChunk(editingChunk);
}}
@@ -383,7 +394,8 @@ const Chunks: React.FC<ChunksProps> = ({
onClick={() => {
setIsEditing(false);
}}
className="dark:text-foreground h-[38px] min-w-[108px] cursor-pointer rounded-full px-4 py-1 text-sm font-medium text-nowrap"
shape="pill"
className="min-w-[108px]"
>
{t('modals.chunk.cancel')}
</Button>
@@ -412,13 +424,8 @@ const Chunks: React.FC<ChunksProps> = ({
(editingTitle === (editingChunk?.metadata?.title || '') &&
editingText === (editingChunk?.text || ''))
}
className={`h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap transition-all ${
editingText.trim() &&
(editingTitle !== (editingChunk?.metadata?.title || '') ||
editingText !== (editingChunk?.text || ''))
? 'cursor-pointer'
: 'cursor-not-allowed bg-gray-400 text-white hover:bg-gray-400'
}`}
shape="pill"
className="min-w-[108px]"
>
{t('modals.chunk.save')}
</Button>
@@ -430,7 +437,8 @@ const Chunks: React.FC<ChunksProps> = ({
type="button"
variant="ghost"
onClick={() => setIsAddingChunk(false)}
className="dark:text-foreground h-[38px] min-w-[108px] cursor-pointer rounded-full px-4 py-1 text-sm font-medium text-nowrap"
shape="pill"
className="min-w-[108px]"
>
{t('modals.chunk.cancel')}
</Button>
@@ -443,11 +451,8 @@ const Chunks: React.FC<ChunksProps> = ({
}
}}
disabled={!editingText.trim()}
className={`h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap transition-all ${
editingText.trim()
? 'cursor-pointer'
: 'cursor-not-allowed bg-gray-400 text-white hover:bg-gray-400'
}`}
shape="pill"
className="min-w-[108px]"
>
{t('modals.chunk.add')}
</Button>
@@ -496,30 +501,26 @@ const Chunks: React.FC<ChunksProps> = ({
const renderFileSearch = () => {
return (
<div className="relative" ref={searchDropdownRef}>
<div className="relative flex items-center">
<div className="pointer-events-none absolute left-3">
<Input
type="text"
value={fileSearchQuery}
onChange={(e) => handleFileSearchChange(e.target.value)}
placeholder={t('settings.sources.searchFiles')}
className="h-[38px]"
leftIcon={
<SearchIcon
className="text-muted-foreground size-4"
strokeWidth={1.75}
aria-label="Search"
/>
</div>
<Input
type="text"
value={fileSearchQuery}
onChange={(e) => handleFileSearchChange(e.target.value)}
placeholder={t('settings.sources.searchFiles')}
className={`h-[38px] py-2 pr-4 pl-10 ${
fileSearchQuery ? 'rounded-t-md' : 'rounded-md'
} transition-all duration-200`}
/>
</div>
}
/>
{fileSearchQuery && (
<div className="border-border bg-card dark:border-border dark:bg-card absolute z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-md border border-t-0 shadow-lg">
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-xl border shadow-md">
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto">
{fileSearchResults.length === 0 ? (
<div className="py-2 text-center text-sm text-gray-500 dark:text-gray-400">
<div className="text-muted-foreground py-2 text-center text-sm">
{t('settings.sources.noResults')}
</div>
) : (
@@ -568,7 +569,7 @@ const Chunks: React.FC<ChunksProps> = ({
<>
<div className="mb-3 flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div className="border-border dark:border-border flex h-[38px] w-full flex-1 items-center overflow-hidden rounded-md border">
<div className="dark:text-foreground flex h-full items-center px-4 font-medium whitespace-nowrap text-gray-700">
<div className="text-foreground flex h-full items-center px-4 font-medium whitespace-nowrap">
{totalChunks > 999999
? `${(totalChunks / 1000000).toFixed(2)}M`
: totalChunks > 999
@@ -577,19 +578,21 @@ const Chunks: React.FC<ChunksProps> = ({
{t('settings.sources.chunks')}
</div>
<div className="bg-border dark:bg-border h-full w-px"></div>
<div className="h-full flex-1">
<div className="h-full flex-1 px-3 py-2">
<Input
type="text"
variant="bare"
placeholder={t('settings.sources.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-full rounded-none border-0 px-3 py-2 text-[13.56px] leading-[100%] font-normal shadow-none focus-visible:ring-0 md:text-[13.56px] dark:border-0"
className="h-full"
/>
</div>
</div>
<Button
type="button"
className="h-[38px] w-full min-w-[108px] shrink-0 rounded-full px-4 text-sm font-medium whitespace-normal sm:w-auto"
shape="pill"
className="w-full min-w-[108px] shrink-0 whitespace-normal sm:w-auto"
title={t('settings.sources.addChunk')}
onClick={() => {
setIsAddingChunk(true);
@@ -607,7 +610,7 @@ const Chunks: React.FC<ChunksProps> = ({
) : (
<div className="grid w-full grid-cols-1 justify-items-start gap-4 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]">
{filteredChunks.length === 0 ? (
<div className="col-span-full flex min-h-[50vh] w-full flex-col items-center justify-center text-center text-gray-500 dark:text-gray-400">
<div className="text-muted-foreground col-span-full flex min-h-[50vh] w-full flex-col items-center justify-center text-center">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt={t('settings.sources.noChunksAlt')}
@@ -628,13 +631,13 @@ const Chunks: React.FC<ChunksProps> = ({
>
<div className="w-full">
<div className="border-border bg-muted dark:border-border dark:bg-card flex w-full items-center justify-between border-b px-4 py-3">
<div className="dark:text-muted-foreground text-sm text-[#59636E]">
<div className="text-muted-foreground text-sm">
{formatChunkTokens(chunk.metadata)}{' '}
{t('settings.sources.tokensUnit')}
</div>
</div>
<div className="px-4 pt-3 pb-6">
<p className="text-foreground line-clamp-6 font-['Inter'] text-[13.68px] leading-[19.93px] font-normal">
<p className="text-foreground line-clamp-6 text-sm leading-5 font-normal">
{chunk.text}
</p>
</div>
@@ -661,7 +664,7 @@ const Chunks: React.FC<ChunksProps> = ({
<div className="w-full">
<div className="border-border dark:border-border relative flex w-full flex-col overflow-hidden rounded-md border">
<div className="border-border bg-muted dark:border-border dark:bg-card flex w-full items-center justify-between border-b px-4 py-3">
<div className="dark:text-muted-foreground text-sm text-[#59636E]">
<div className="text-muted-foreground text-sm">
{formatChunkTokens(editingChunk.metadata)}{' '}
{t('settings.sources.tokensUnit')}
</div>
+4 -10
View File
@@ -1,7 +1,5 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { ConfigRequirements } from '../modals/types';
import { Input } from './ui/input';
import { Label } from './ui/label';
@@ -68,7 +66,7 @@ export default function ConfigFields({
<div key={key} className="flex flex-col gap-1.5">
<Label htmlFor={key}>
{spec.label || key}
{spec.required && <span className="text-red-500">*</span>}
{spec.required && <span className="text-destructive">*</span>}
</Label>
<Select
value={value || spec.default || ''}
@@ -78,11 +76,8 @@ export default function ConfigFields({
id={key}
variant="ghost"
size="lg"
className={cn(
'w-full rounded-xl',
hasError &&
'border-destructive aria-invalid:ring-destructive/20',
)}
aria-invalid={hasError || undefined}
className="w-full"
>
<SelectValue placeholder={spec.label || key} />
</SelectTrigger>
@@ -106,7 +101,7 @@ export default function ConfigFields({
<div key={key} className="flex flex-col gap-1.5">
<Label htmlFor={key}>
{spec.label || key}
{spec.required && <span className="text-red-500">*</span>}
{spec.required && <span className="text-destructive">*</span>}
</Label>
<Input
id={key}
@@ -136,7 +131,6 @@ export default function ConfigFields({
spec.type === 'number' && key === 'timeout' ? 300 : undefined
}
aria-invalid={hasError || undefined}
className={cn('rounded-xl', hasError && 'border-destructive')}
/>
{hasError && (
<p className="text-destructive text-xs">{errors[key]}</p>
@@ -0,0 +1,84 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({
useSelector: () => 'token',
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false, () => undefined],
}));
vi.mock('../api/services/userService', () => ({ default: {} }));
import ConnectorAuth from './ConnectorAuth';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('ConnectorAuth', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (props: Partial<Parameters<typeof ConnectorAuth>[0]>) =>
act(() =>
root.render(
<ConnectorAuth
provider="google_drive"
onSuccess={() => undefined}
onError={() => undefined}
label="Connect"
{...props}
/>,
),
);
it('shows the error as a destructive alert with an icon', () => {
render({ errorMessage: 'Session expired' });
const alert = container.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
expect(alert?.className).toContain('text-destructive');
expect(alert?.querySelector('svg')).not.toBeNull();
expect(alert?.textContent).toContain('Session expired');
});
it('renders the connected banner as a polite success alert', () => {
const onDisconnect = vi.fn();
render({ isConnected: true, userEmail: 'a@b.c', onDisconnect });
const banner = container.querySelector('[role="status"]');
expect(banner).not.toBeNull();
expect(banner?.className).toContain('text-success');
expect(banner?.className).toContain('bg-success/10');
expect(banner?.querySelector('svg')).not.toBeNull();
const button = banner?.querySelector('button');
expect(button?.className).toContain('text-primary');
expect(button?.className).not.toContain('text-black');
expect(button?.textContent).toBe(
'modals.uploadDoc.connectors.auth.disconnect',
);
act(() => button?.click());
expect(onDisconnect).toHaveBeenCalledTimes(1);
});
it('renders the auth button as a default brand button', () => {
render({});
const button = container.querySelector('button');
expect(button?.textContent).toContain('Connect');
expect(button?.className).toContain('bg-primary');
expect(button?.className).not.toContain('bg-blue-500');
});
});
+19 -50
View File
@@ -1,11 +1,12 @@
import { CircleCheck, TriangleAlert } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import { useDarkTheme } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import { isTrustedConnectorMessage } from '../utils/connectorAuthUtils';
import { Alert, AlertDescription } from './ui/alert';
import { Button } from './ui/button';
interface ConnectorAuthProps {
@@ -31,7 +32,6 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
const completedRef = useRef(false);
const intervalRef = useRef<number | null>(null);
const authWindowRef = useRef<Window | null>(null);
@@ -208,67 +208,36 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
return (
<>
{errorMessage && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-[#E60000] bg-transparent p-2 dark:border-[#D42626] dark:bg-[#D426261A]">
<svg
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.09974 24.5422H22.9C24.5156 24.5422 25.5228 22.7901 24.715 21.3947L16.8149 7.74526C16.007 6.34989 13.9927 6.34989 13.1848 7.74526L5.28471 21.3947C4.47686 22.7901 5.48405 24.5422 7.09974 24.5422ZM14.9998 17.1981C14.4228 17.1981 13.9507 16.726 13.9507 16.149V14.0507C13.9507 13.4736 14.4228 13.0015 14.9998 13.0015C15.5769 13.0015 16.049 13.4736 16.049 14.0507V16.149C16.049 16.726 15.5769 17.1981 14.9998 17.1981ZM16.049 21.3947H13.9507V19.2964H16.049V21.3947Z"
fill={isDarkTheme ? '#EECF56' : '#E60000'}
/>
</svg>
<span
className="text-sm text-[#E60000] dark:text-red-400"
style={{
fontFamily: 'Inter',
lineHeight: '100%',
}}
>
{errorMessage}
</span>
</div>
<Alert variant="destructive" className="mb-4">
<TriangleAlert aria-hidden="true" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{isConnected ? (
<div className="mb-4">
<div className="flex w-full items-center justify-between rounded-lg bg-[#8FDD51] px-4 py-2 text-sm font-medium text-black">
<div className="flex max-w-[500px] items-center gap-2">
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
/>
</svg>
<span>
{t('modals.uploadDoc.connectors.auth.connectedAs', {
email: userEmail,
})}
</span>
</div>
<Alert variant="success" className="mb-4">
<CircleCheck aria-hidden="true" />
<AlertDescription className="flex items-center justify-between">
<span className="max-w-[500px]">
{t('modals.uploadDoc.connectors.auth.connectedAs', {
email: userEmail,
})}
</span>
{onDisconnect && (
<Button
type="button"
variant="link"
size="sm"
size="xs"
onClick={onDisconnect}
className="h-auto p-0 text-xs text-black underline hover:text-gray-700"
className="-my-1 ml-2 shrink-0"
>
{t('modals.uploadDoc.connectors.auth.disconnect')}
</Button>
)}
</div>
</div>
</AlertDescription>
</Alert>
) : (
<Button
type="button"
onClick={handleAuth}
className="w-full rounded-lg bg-blue-500 px-4 py-3 text-white hover:bg-blue-600"
>
<Button type="button" onClick={handleAuth} className="w-full">
<svg className="h-5 w-5" viewBox="0 0 24 24">
<path
fill="currentColor"
+2 -2
View File
@@ -111,8 +111,8 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
disabled={isSyncing}
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-sm font-medium whitespace-nowrap transition-colors ${
isSyncing
? 'dark:bg-muted dark:text-muted-foreground cursor-not-allowed bg-gray-300 text-gray-600'
: 'bg-primary hover:bg-primary/90 text-white'
? 'bg-muted text-muted-foreground cursor-not-allowed'
: 'bg-primary hover:bg-primary/90 text-primary-foreground'
}`}
title={
isSyncing
@@ -0,0 +1,88 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const copyMock = vi.fn<(text: string) => boolean>(() => true);
vi.mock('copy-to-clipboard', () => ({
default: (text: string) => copyMock(text),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import CopyButton from './CopyButton';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('CopyButton', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
copyMock.mockClear();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function button(): HTMLButtonElement {
const el = container.querySelector('button');
if (!el) throw new Error('no button');
return el;
}
async function click() {
await act(async () => {
button().click();
});
}
it('renders an idle ghost-muted icon button', () => {
act(() => root.render(<CopyButton textToCopy="hello" />));
expect(button().getAttribute('aria-label')).toBe('conversation.copy');
expect(button().dataset.variant).toBe('ghost-muted');
expect(button().dataset.size).toBe('icon-sm');
expect(button().dataset.shape).toBe('pill');
});
it('uses icon-xs when size is xs and xs with text', () => {
act(() => root.render(<CopyButton textToCopy="hello" size="xs" />));
expect(button().dataset.size).toBe('icon-xs');
act(() => root.render(<CopyButton textToCopy="hello" showText />));
expect(button().dataset.size).toBe('xs');
expect(button().textContent).toContain('conversation.copy');
});
it('copies the text and shows the copied state without disabling', async () => {
act(() => root.render(<CopyButton textToCopy="hello" showText />));
await click();
expect(copyMock).toHaveBeenCalledWith('hello');
expect(button().getAttribute('aria-label')).toBe('conversation.copied');
expect(button().dataset.variant).toBe('secondary');
expect(button().hasAttribute('disabled')).toBe(false);
expect(button().textContent).toContain('conversation.copied');
});
it('ignores clicks while copied and resets after the timeout', async () => {
act(() =>
root.render(<CopyButton textToCopy="hello" copiedDuration={1000} />),
);
await click();
await click();
expect(copyMock).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(button().getAttribute('aria-label')).toBe('conversation.copy');
expect(button().dataset.variant).toBe('ghost-muted');
});
});
+15 -49
View File
@@ -1,71 +1,38 @@
import clsx from 'clsx';
import copy from 'copy-to-clipboard';
import { Check, Copy } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import CheckMark from '../assets/checkmark.svg?react';
import CopyIcon from '../assets/copy.svg?react';
import { Button } from './ui/button';
type CopyButtonProps = {
textToCopy: string;
iconSize?: string;
padding?: string;
/** Icon-only button size: `xs` is `icon-xs` (28px), `sm` is `icon-sm` (32px). */
size?: 'xs' | 'sm';
/** Show a "Copy"/"Copied" label next to the icon (renders at Button `xs`). */
showText?: boolean;
copiedDuration?: number;
className?: string;
iconWrapperClassName?: string;
textClassName?: string;
};
const DEFAULT_ICON_SIZE = 'w-4 h-4';
const DEFAULT_PADDING = 'p-2';
const DEFAULT_COPIED_DURATION = 2000;
export default function CopyButton({
textToCopy,
iconSize = DEFAULT_ICON_SIZE,
padding = DEFAULT_PADDING,
size = 'sm',
showText = false,
copiedDuration = DEFAULT_COPIED_DURATION,
className,
iconWrapperClassName,
textClassName,
}: CopyButtonProps) {
const { t } = useTranslation();
const [isCopied, setIsCopied] = useState(false);
const timeoutIdRef = useRef<number | null>(null);
// `copy` is async, so `isCopied` and the disabled prop only catch up after it
// resolves. Guard that window so rapid clicks cannot start a second write.
// `copy` is async, so `isCopied` only catches up after it resolves. Guard
// that window so rapid clicks cannot start a second write. The button is
// never disabled, so the copied confirmation does not fade.
const copyInFlightRef = useRef(false);
const iconWrapperClasses = clsx(
'flex items-center justify-center rounded-full transition-colors duration-150 ease-in-out',
padding,
{
[`bg-transparent hover:bg-muted`]: !isCopied,
'bg-green-100 dark:bg-green-900 hover:bg-green-100 dark:hover:bg-green-900':
isCopied,
},
iconWrapperClassName,
);
const rootButtonClasses = clsx(
'group h-auto rounded-full bg-transparent p-0 hover:bg-transparent disabled:opacity-100',
className,
);
const textSpanClasses = clsx(
'text-xs text-gray-600 dark:text-gray-400 transition-opacity duration-150 ease-in-out',
{ 'opacity-75': isCopied },
textClassName,
);
const IconComponent = isCopied ? CheckMark : CopyIcon;
const iconClasses = clsx(iconSize, {
'stroke-green-600 dark:stroke-green-400': isCopied,
'fill-none text-gray-700 dark:text-gray-300': !isCopied,
});
const IconComponent = isCopied ? Check : Copy;
const buttonTitle = isCopied
? t('conversation.copied')
@@ -111,17 +78,16 @@ export default function CopyButton({
return (
<Button
type="button"
variant="ghost"
variant={isCopied ? 'secondary' : 'ghost-muted'}
size={showText ? 'xs' : size === 'xs' ? 'icon-xs' : 'icon-sm'}
shape="pill"
onClick={handleCopy}
className={rootButtonClasses}
className={className}
title={buttonTitle}
aria-label={buttonTitle}
disabled={isCopied}
>
<div className={iconWrapperClasses}>
<IconComponent className={iconClasses} aria-hidden="true" />
</div>
{showText && <span className={textSpanClasses}>{displayedText}</span>}
<IconComponent aria-hidden="true" />
{showText && <span>{displayedText}</span>}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{isCopied ? t('conversation.copied', 'Copied to clipboard') : ''}
</span>
@@ -86,7 +86,7 @@ function FramePreview({
if (renderError) {
return (
<pre className="overflow-auto p-4 text-xs text-gray-600 dark:text-gray-400">
<pre className="text-muted-foreground overflow-auto p-4 text-xs">
{source}
</pre>
);
@@ -107,7 +107,7 @@ function FramePreview({
// No allow-same-origin: content is fully isolated from the app origin.
sandbox=""
title="Artifact preview"
className="h-full w-full rounded-md border border-gray-200 bg-white dark:border-gray-700"
className="border-border h-full w-full rounded-md border bg-white"
srcDoc={buildPreviewDocument(previewKind, rendered)}
/>
);
@@ -126,16 +126,14 @@ function DownloadCard({
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<div className="flex flex-col items-center gap-3 rounded-xl border border-gray-200 p-8 text-center dark:border-gray-700">
<FileText className="h-12 w-12 text-gray-400" />
<div className="border-border flex flex-col items-center gap-3 rounded-xl border p-8 text-center">
<FileText className="text-muted-foreground h-12 w-12" />
<div>
<p className="text-sm font-medium break-all text-gray-800 dark:text-gray-200">
<p className="text-foreground text-sm font-medium break-all">
{filename}
</p>
{size != null && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatBytes(size)}
</p>
<p className="text-muted-foreground text-xs">{formatBytes(size)}</p>
)}
</div>
<Button
@@ -207,7 +205,7 @@ function BytesPreview({
// No allow-same-origin / no scripts: bytes are isolated from the app origin.
sandbox=""
title="Artifact preview"
className="h-full w-full rounded-md border border-gray-200 bg-white dark:border-gray-700"
className="border-border h-full w-full rounded-md border bg-white"
srcDoc={buildPreviewDocument(
mode === 'iframe-svg' ? 'svg' : 'html',
state.text,
@@ -219,7 +217,7 @@ function BytesPreview({
return <MarkdownPreview content={state.text} />;
}
return (
<pre className="h-full overflow-auto p-4 text-xs whitespace-pre-wrap text-gray-700 dark:text-gray-300">
<pre className="text-foreground h-full overflow-auto p-4 text-xs whitespace-pre-wrap">
{state.text}
</pre>
);
@@ -340,7 +338,7 @@ export default function DocumentArtifactView({
: (selectedRow?.preview_text ?? '');
if (text) {
return (
<pre className="h-full overflow-auto p-4 text-xs whitespace-pre-wrap text-gray-700 dark:text-gray-300">
<pre className="text-foreground h-full overflow-auto p-4 text-xs whitespace-pre-wrap">
{text}
</pre>
);
@@ -379,7 +377,7 @@ export default function DocumentArtifactView({
value={String(selectedVersion)}
onValueChange={(v) => setSelectedVersion(Number(v))}
>
<SelectTrigger className="h-8 w-auto gap-2 rounded-md px-3 text-xs">
<SelectTrigger size="sm" className="w-auto">
<History className="h-3.5 w-3.5" />
<SelectValue />
</SelectTrigger>
@@ -426,7 +424,7 @@ export default function DocumentArtifactView({
)}
</div>
{actionError && <p className="text-xs text-red-500">{actionError}</p>}
{actionError && <p className="text-destructive text-xs">{actionError}</p>}
<div className="min-h-0 flex-1 overflow-hidden">{renderPreview()}</div>
</div>
@@ -0,0 +1,76 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import Pagination from './DocumentPagination';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('DocumentPagination rows-per-page', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (onRowsPerPageChange = vi.fn()) => {
await act(async () => {
root.render(
<Pagination
currentPage={1}
totalPages={3}
rowsPerPage={10}
onPageChange={vi.fn()}
onRowsPerPageChange={onRowsPerPageChange}
/>,
);
});
return onRowsPerPageChange;
};
const trigger = () =>
document.body.querySelector<HTMLButtonElement>(
'[data-slot="select-trigger"]',
);
it('shows the current value in a labelled combobox trigger', async () => {
await render();
const el = trigger();
expect(el).not.toBeNull();
expect(el!.getAttribute('role')).toBe('combobox');
expect(el!.getAttribute('aria-label')).toBe('pagination.rowsPerPage');
expect(el!.getAttribute('data-size')).toBe('sm');
expect(el!.textContent).toContain('10');
});
it('calls onRowsPerPageChange with the chosen number', async () => {
const onChange = await render();
const el = trigger()!;
await act(async () => {
el.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
button: 0,
pointerType: 'mouse',
}),
);
});
const option = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).find((o) => o.textContent === '50');
expect(option).toBeDefined();
await act(async () => option!.click());
expect(onChange).toHaveBeenCalledWith(50);
});
});
+28 -48
View File
@@ -1,10 +1,17 @@
import React, { useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import SingleArrowLeft from '../assets/single-left-arrow.svg';
import SingleArrowRight from '../assets/single-right-arrow.svg';
import DoubleArrowLeft from '../assets/double-arrow-left.svg';
import DoubleArrowRight from '../assets/double-arrow-right.svg';
import { Button } from './ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
interface PaginationProps {
currentPage: number;
@@ -22,11 +29,8 @@ const Pagination: React.FC<PaginationProps> = ({
onRowsPerPageChange,
}) => {
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const rowsPerPageOptions = [5, 10, 20, 50];
const toggleDropdown = () => setIsDropdownOpen((prev) => !prev);
const handlePreviousPage = () => {
if (currentPage > 1) {
onPageChange(currentPage - 1);
@@ -48,63 +52,42 @@ const Pagination: React.FC<PaginationProps> = ({
};
const handleSelectRowsPerPage = (rows: number) => {
setIsDropdownOpen(false);
onRowsPerPageChange(rows);
};
return (
<div className="mt-2 flex items-center justify-end gap-4 border-gray-200 p-2 text-xs">
<div className="mt-2 flex items-center justify-end gap-4 p-2 text-xs">
{/* Rows per page dropdown */}
<div className="relative flex items-center gap-2">
<span className="text-gray-900 dark:text-gray-50">
{t('pagination.rowsPerPage')}:
</span>
<div className="relative">
<Button
type="button"
variant="outline"
size="sm"
onClick={toggleDropdown}
className="dark:bg-card h-auto rounded px-3 py-1 shadow-none"
>
{rowsPerPage}
</Button>
<div
className={`ring-opacity-5 dark:bg-card bg-card absolute right-0 z-50 mt-1 w-28 transform shadow-lg ring-1 ring-black transition-all duration-200 ease-in-out ${
isDropdownOpen
? 'block scale-100 opacity-100'
: 'hidden scale-95 opacity-0'
}`}
>
<span className="text-foreground">{t('pagination.rowsPerPage')}:</span>
<Select
value={String(rowsPerPage)}
onValueChange={(value) => handleSelectRowsPerPage(Number(value))}
>
<SelectTrigger size="sm" aria-label={t('pagination.rowsPerPage')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{rowsPerPageOptions.map((option) => (
<div
key={option}
onClick={() => handleSelectRowsPerPage(option)}
className={`hover:bg-accent dark:hover:bg-accent cursor-pointer px-4 py-2 text-xs ${
rowsPerPage === option
? 'dark:text-foreground bg-gray-100 dark:bg-neutral-700'
: 'bg-card dark:text-foreground'
}`}
>
<SelectItem key={option} value={String(option)}>
{option}
</div>
</SelectItem>
))}
</div>
</div>
</SelectContent>
</Select>
</div>
{/* Pagination controls */}
<div className="text-gray-900 dark:text-gray-50">
<div className="text-foreground">
{t('pagination.pageOf', { currentPage, totalPages })}
</div>
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-50">
<div className="text-foreground flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
size="icon-xs"
onClick={handleFirstPage}
disabled={currentPage === 1}
className="h-auto rounded px-2 py-1 shadow-none"
>
<img
src={DoubleArrowLeft}
@@ -115,10 +98,9 @@ const Pagination: React.FC<PaginationProps> = ({
<Button
type="button"
variant="outline"
size="sm"
size="icon-xs"
onClick={handlePreviousPage}
disabled={currentPage === 1}
className="h-auto rounded px-2 py-1 shadow-none"
>
<img
src={SingleArrowLeft}
@@ -129,10 +111,9 @@ const Pagination: React.FC<PaginationProps> = ({
<Button
type="button"
variant="outline"
size="sm"
size="icon-xs"
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="h-auto rounded px-2 py-1 shadow-none"
>
<img
src={SingleArrowRight}
@@ -143,10 +124,9 @@ const Pagination: React.FC<PaginationProps> = ({
<Button
type="button"
variant="outline"
size="sm"
size="icon-xs"
onClick={handleLastPage}
disabled={currentPage === totalPages}
className="h-auto rounded px-2 py-1 shadow-none"
>
<img
src={DoubleArrowRight}
+109
View File
@@ -0,0 +1,109 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const validateConnectorSession = vi.fn();
const getConnectorFiles = vi.fn();
vi.mock('../api/services/userService', () => ({
default: {
validateConnectorSession: (...args: unknown[]) =>
validateConnectorSession(...args),
getConnectorFiles: (...args: unknown[]) => getConnectorFiles(...args),
disconnectConnector: vi.fn(),
},
}));
vi.mock('../utils/providerUtils', () => ({
getSessionToken: () => 'session-token',
setSessionToken: vi.fn(),
removeSessionToken: vi.fn(),
}));
vi.mock('../components/ConnectorAuth', () => ({ default: () => null }));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { FilePicker } from './FilePicker';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('FilePicker', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
validateConnectorSession.mockReset();
getConnectorFiles.mockReset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('shows an expired session as a destructive alert', async () => {
validateConnectorSession.mockResolvedValue({ ok: false });
await act(async () => {
root.render(
<FilePicker
provider="google_drive"
token={null}
onSelectionChange={() => undefined}
/>,
);
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Session expired');
expect(alert?.querySelector('svg')).not.toBeNull();
});
async function renderSharePoint() {
validateConnectorSession.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
user_email: 'lena@meridian.example',
allows_shared_content: true,
}),
});
getConnectorFiles.mockResolvedValue({
json: async () => ({ success: true, files: [], next_page_token: null }),
});
await act(async () => {
root.render(
<FilePicker
provider="share_point"
token={null}
onSelectionChange={() => undefined}
/>,
);
});
}
it('renders the drive switch as underline tabs with the active one marked', async () => {
await renderSharePoint();
const tabs = Array.from(
container.querySelectorAll('[data-slot="button"][data-variant="tab"]'),
);
expect(tabs.map((tab) => tab.textContent)).toEqual([
'filePicker.myFiles',
'filePicker.sharedWithMe',
]);
expect(tabs[0].getAttribute('data-active')).toBe('true');
expect(tabs[1].getAttribute('data-active')).not.toBe('true');
});
it('shows the folder trail as a breadcrumb with the current folder as the page', async () => {
await renderSharePoint();
const trail = container.querySelector('nav[aria-label="breadcrumb"]');
expect(trail).not.toBeNull();
const page = trail?.querySelector('[data-slot="breadcrumb-page"]');
expect(page?.textContent).toBe('My Files');
expect(page?.getAttribute('title')).toBe('My Files');
expect(trail?.querySelector('button[disabled]')).toBeNull();
});
});
+73 -53
View File
@@ -1,5 +1,11 @@
import { Search as SearchIcon } from 'lucide-react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Search as SearchIcon, TriangleAlert } from 'lucide-react';
import React, {
Fragment,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import { useTranslation } from 'react-i18next';
import userService from '../api/services/userService';
import { formatBytes } from '../utils/stringUtils';
@@ -13,6 +19,15 @@ import ConnectorAuth from '../components/ConnectorAuth';
import FileIcon from '../assets/file.svg';
import FolderIcon from '../assets/folder.svg';
import CheckIcon from '../assets/checkmark.svg';
import { Alert, AlertDescription } from './ui/alert';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from './ui/breadcrumb';
import { Button } from './ui/button';
import { Input } from './ui/input';
import {
@@ -379,7 +394,10 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
return (
<div className="">
{authError && (
<div className="mb-4 text-center text-sm text-red-500">{authError}</div>
<Alert variant="destructive" className="mb-4">
<TriangleAlert aria-hidden="true" />
<AlertDescription>{authError}</AlertDescription>
</Alert>
)}
<ConnectorAuth
@@ -429,59 +447,61 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
/>
{isConnected && (
<div className="border-border dark:border-border mt-3 overflow-hidden rounded-lg border">
<div className="border-border dark:border-border rounded-t-lg">
<div className="border-border mt-3 overflow-hidden rounded-lg border">
<div className="border-border rounded-t-lg">
{provider === 'share_point' && allowsSharedContent && (
<div className="border-border dark:border-border flex border-b">
<div className="border-border flex border-b">
<Button
type="button"
variant="ghost"
variant="tab"
data-active={activeTab === 'my_files'}
onClick={() => handleTabChange('my_files')}
className={`h-auto rounded-none px-4 py-2 text-sm font-medium ${
activeTab === 'my_files'
? 'border-b-2 border-[#A076F6] text-[#A076F6] hover:bg-transparent hover:text-[#A076F6]'
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
{t('filePicker.myFiles')}
</Button>
<Button
type="button"
variant="ghost"
variant="tab"
data-active={activeTab === 'shared'}
onClick={() => handleTabChange('shared')}
className={`h-auto rounded-none px-4 py-2 text-sm font-medium ${
activeTab === 'shared'
? 'border-b-2 border-[#A076F6] text-[#A076F6] hover:bg-transparent hover:text-[#A076F6]'
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
{t('filePicker.sharedWithMe')}
</Button>
</div>
)}
<div className="dark:bg-muted rounded-t-lg bg-[#EEE6FF78] px-4 pt-4">
<div className="mb-2 flex items-center gap-1">
{folderPath.map((path, index) => (
<div
key={path.id || 'root'}
className="flex items-center gap-1"
>
{index > 0 && <span className="text-gray-400">/</span>}
<Button
type="button"
variant="link"
size="sm"
onClick={() => navigateBack(index)}
className="h-auto p-0 text-sm text-[#A076F6] underline-offset-2 hover:text-[#8A5FD4]"
disabled={index === folderPath.length - 1}
>
{path.name}
</Button>
</div>
))}
</div>
<div className="bg-muted rounded-t-lg px-4 pt-4">
<Breadcrumb className="mb-2 min-w-0">
<BreadcrumbList className="flex-nowrap">
{folderPath.map((path, index) => (
<Fragment key={path.id || 'root'}>
{index > 0 && <BreadcrumbSeparator />}
{index === folderPath.length - 1 ? (
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage
title={path.name}
className="max-w-[32ch]"
>
{path.name}
</BreadcrumbPage>
</BreadcrumbItem>
) : (
<BreadcrumbItem>
<BreadcrumbLink asChild>
<button
type="button"
onClick={() => navigateBack(index)}
>
{path.name}
</button>
</BreadcrumbLink>
</BreadcrumbItem>
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
<div className="text-muted-foreground mb-3 text-sm">
Select Files from {getProviderConfig(provider).displayName}
</div>
@@ -491,7 +511,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
label={t('filePicker.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
labelBgClassName="bg-[#EEE6FF78] dark:bg-muted"
labelSurface="muted"
leftIcon={
<SearchIcon
className="text-muted-foreground size-4"
@@ -502,18 +522,18 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
</div>
{/* Selected Files Message */}
<div className="pb-3 text-sm text-gray-600 dark:text-gray-400">
<div className="text-muted-foreground pb-3 text-sm">
{t('filePicker.itemsSelected', {
count: selectedFiles.length + selectedFolders.length,
})}
</div>
</div>
<div className="border-border dark:border-border h-72 border-t">
<div className="border-border scrollbar-overlay h-72 border-t">
<TableContainer
ref={scrollContainerRef}
height="288px"
className="scrollbar-overlay md:w-4xl lg:w-5xl"
className="md:w-4xl lg:w-5xl"
bordered={false}
>
{
@@ -538,16 +558,16 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={`skeleton-${i}`}>
<TableCell width="40px" align="center">
<div className="mx-auto h-5 w-5 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="bg-muted mx-auto h-5 w-5 animate-pulse rounded" />
</TableCell>
<TableCell>
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="bg-muted h-4 w-48 animate-pulse rounded" />
</TableCell>
<TableCell>
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
</TableCell>
<TableCell>
<div className="h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="bg-muted h-4 w-16 animate-pulse rounded" />
</TableCell>
</TableRow>
))
@@ -564,7 +584,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
>
<TableCell width="40px" align="center">
<div
className="border-border dark:border-border mx-auto flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border p-[0.5px] text-sm"
className="border-border mx-auto flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border text-sm"
onClick={(e) => {
e.stopPropagation();
handleFileSelect(file.id, isFolder(file));
@@ -611,16 +631,16 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={`load-more-skeleton-${i}`}>
<TableCell width="40px" align="center">
<div className="mx-auto h-5 w-5 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="bg-muted mx-auto h-5 w-5 animate-pulse rounded" />
</TableCell>
<TableCell>
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="bg-muted h-4 w-48 animate-pulse rounded" />
</TableCell>
<TableCell>
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
</TableCell>
<TableCell>
<div className="h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="bg-muted h-4 w-16 animate-pulse rounded" />
</TableCell>
</TableRow>
))}
+111
View File
@@ -0,0 +1,111 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { FileUpload } from './FileUpload';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
function makeFile(name: string, type: string, bytes = 10): File {
return new File([new Uint8Array(bytes)], name, { type });
}
async function selectFiles(container: HTMLElement, files: File[]) {
const input = container.querySelector('input[type="file"]');
if (!input) throw new Error('no file input');
Object.defineProperty(input, 'files', { value: files, configurable: true });
await act(async () => {
input.dispatchEvent(new Event('change', { bubbles: true }));
});
// react-dropzone resolves the selected files asynchronously.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe('FileUpload', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('renders on the shared Dropzone with the upload prompt', async () => {
await act(async () => {
root.render(<FileUpload onUpload={vi.fn()} />);
});
const zone = container.querySelector('[data-slot="dropzone"]');
expect(zone).not.toBeNull();
expect(zone?.getAttribute('data-size')).toBe('default');
expect(container.textContent).toContain(
'components.fileUpload.clickToUpload',
);
expect(container.textContent).toContain('components.fileUpload.fileTypes');
});
it('passes the compact size through', async () => {
await act(async () => {
root.render(<FileUpload onUpload={vi.fn()} size="compact" />);
});
expect(
container
.querySelector('[data-slot="dropzone"]')
?.getAttribute('data-size'),
).toBe('compact');
});
it('renders highlighted upload text segments', async () => {
await act(async () => {
root.render(
<FileUpload
onUpload={vi.fn()}
uploadText={[
{ text: 'Click to upload', highlight: true },
{ text: ' or drag and drop' },
]}
/>,
);
});
const highlighted = Array.from(container.querySelectorAll('span')).find(
(s) => s.textContent === 'Click to upload',
);
expect(highlighted?.className).toContain('text-primary');
});
it('calls onUpload with a valid file', async () => {
const onUpload = vi.fn();
await act(async () => {
root.render(<FileUpload onUpload={onUpload} />);
});
const file = makeFile('logo.png', 'image/png');
await selectFiles(container, [file]);
expect(onUpload).toHaveBeenCalledWith([file]);
});
it('shows the validator error and does not upload', async () => {
const onUpload = vi.fn();
await act(async () => {
root.render(
<FileUpload
onUpload={onUpload}
validator={() => ({ isValid: false, error: 'Bad image' })}
/>,
);
});
await selectFiles(container, [makeFile('logo.png', 'image/png')]);
expect(onUpload).not.toHaveBeenCalled();
const error = container.querySelector('.text-destructive');
expect(error?.textContent).toContain('Bad image');
});
});
+98 -101
View File
@@ -1,10 +1,16 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDropzone } from 'react-dropzone';
import { twMerge } from 'tailwind-merge';
import type { FileRejection } from 'react-dropzone';
import { ImageUp } from 'lucide-react';
import { Dropzone } from '@/components/ui/dropzone';
import Cross from '../assets/cross.svg';
import ImagesIcon from '../assets/images.svg';
type UploadTextSegment = {
text: string;
/** Renders the segment in the brand colour; others are muted. */
highlight?: boolean;
};
interface FileUploadProps {
onUpload: (files: File[]) => void;
@@ -15,14 +21,14 @@ interface FileUploadProps {
accept?: Record<string, string[]>; // e.g. { 'image/*': ['.png', '.jpg'] }
showPreview?: boolean;
previewSize?: number;
/** `compact` is a one-row target for forms and panels. */
size?: 'default' | 'compact';
children?: React.ReactNode;
/** Layout classes only; the Dropzone owns colours, border and radius. */
className?: string;
activeClassName?: string;
acceptClassName?: string;
rejectClassName?: string;
uploadText?: string | { text: string; colorClass?: string }[];
uploadText?: string | UploadTextSegment[];
dragActiveText?: string;
fileTypeText?: string;
sizeLimitText?: string;
@@ -31,6 +37,13 @@ interface FileUploadProps {
validator?: (file: File) => { isValid: boolean; error?: string };
}
/**
* Image/file picker built on `ui/dropzone`. Adds size and custom validation,
* an optional image preview with a remove button, and translated prompts.
*
* @param props - See `FileUploadProps`.
* @returns The dropzone with its preview and validation errors.
*/
export const FileUpload = ({
onUpload,
onRemove,
@@ -40,11 +53,9 @@ export const FileUpload = ({
accept = { 'image/*': ['.jpeg', '.png', '.jpg'] },
showPreview = false,
previewSize = 80,
size = 'default',
children,
className = 'border-2 border-dashed rounded-3xl p-6 text-center cursor-pointer transition-colors border-border dark:border-border',
activeClassName = 'border-blue-500 bg-blue-50',
acceptClassName = 'border-green-500 dark:border-green-500 bg-green-50 dark:bg-green-50/10',
rejectClassName = 'border-red-500 bg-red-50 dark:bg-red-500/10 dark:border-red-500',
className,
uploadText,
dragActiveText,
fileTypeText,
@@ -83,12 +94,12 @@ export const FileUpload = ({
};
const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: any[]) => {
(acceptedFiles: File[], fileRejections: FileRejection[]) => {
setErrors([]);
if (fileRejections.length > 0) {
const newErrors = fileRejections
.map(({ errors }) => errors.map((e: any) => e.message))
.map(({ errors }) => errors.map((e) => e.message))
.flat();
setErrors(newErrors);
return;
@@ -117,40 +128,22 @@ export const FileUpload = ({
[onUpload, multiple, maxSize, validator],
);
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
} = useDropzone({
onDrop,
multiple,
maxFiles,
maxSize,
accept,
disabled,
});
const currentClassName = twMerge(
'border-2 border-dashed rounded-3xl p-8 text-center cursor-pointer transition-colors border-border dark:border-border',
className,
isDragActive && activeClassName,
isDragAccept && acceptClassName,
isDragReject && rejectClassName,
disabled && 'opacity-50 cursor-not-allowed',
);
const handleRemove = () => {
setPreview(null);
setCurrentFile(null);
if (onRemove && currentFile) onRemove(currentFile);
};
// Callers size the preview box at runtime; a custom property keeps the
// dimension out of the style's width/height and in the classes.
const previewSizeStyle = {
'--preview-size': `${previewSize}px`,
} as React.CSSProperties;
const renderPreview = () => (
<div
className="relative"
style={{ width: previewSize, height: previewSize }}
className="relative size-(--preview-size) shrink-0"
style={previewSizeStyle}
>
<img
src={preview ?? undefined}
@@ -170,71 +163,75 @@ export const FileUpload = ({
</div>
);
const renderUploadText = () => {
if (Array.isArray(uploadText)) {
return (
<p className="text-sm font-semibold">
{uploadText.map((segment, i) => (
<span key={i} className={segment.colorClass || ''}>
{segment.text}
</span>
))}
</p>
);
}
return (
<p className="text-sm font-semibold">
{uploadText || t('components.fileUpload.clickToUpload')}
</p>
);
};
const defaultContent = (
<div className="flex flex-col items-center gap-2">
{showPreview && preview ? (
renderPreview()
) : (
<div
style={{ width: previewSize, height: previewSize }}
className="flex items-center justify-center"
const uploadPrompt = Array.isArray(uploadText)
? uploadText.map((segment, i) => (
<span
key={i}
className={
segment.highlight ? 'text-primary' : 'text-muted-foreground'
}
>
<img src={ImagesIcon} className="h-10 w-10" />
</div>
)}
<div className="text-center">
<div className="text-sm font-medium">
{isDragActive ? (
<p className="text-sm font-semibold">
{dragActiveText || t('components.fileUpload.dropFiles')}
</p>
) : (
renderUploadText()
)}
</div>
<p className="mt-1 text-xs text-[#A3A3A3]">
{fileTypeText || t('components.fileUpload.fileTypes')}{' '}
{maxSize / 1024 / 1024}
{sizeLimitText || t('components.fileUpload.sizeLimitUnit')}
</p>
</div>
</div>
{segment.text}
</span>
))
: uploadText || t('components.fileUpload.clickToUpload');
// The Dropzone owns the drag state; its data attribute swaps the prompt.
const title = (
<>
<span className="in-data-[drag-active]:hidden">{uploadPrompt}</span>
<span className="hidden in-data-[drag-active]:inline">
{dragActiveText || t('components.fileUpload.dropFiles')}
</span>
</>
);
const description = (
<>
{fileTypeText || t('components.fileUpload.fileTypes')}{' '}
{maxSize / 1024 / 1024}
{sizeLimitText || t('components.fileUpload.sizeLimitUnit')}
</>
);
// With a preview, the image takes the icon's place; the text block mirrors
// the Dropzone's own title/description layout.
const previewContent =
showPreview && preview ? (
<>
{renderPreview()}
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</span>
</>
) : undefined;
const error =
errors.length > 0
? errors.map((message, i) => (
<span key={i} className="block truncate">
{message}
</span>
))
: undefined;
return (
<div className="relative">
<div {...getRootProps({ className: currentClassName })}>
<input {...getInputProps()} />
{children || defaultContent}
{errors.length > 0 && (
<div className="absolute right-0 left-0 mt-[2px] px-4 text-xs text-red-600">
{errors.map((error, i) => (
<p key={i} className="truncate">
{error}
</p>
))}
</div>
)}
</div>
</div>
<Dropzone
onDrop={onDrop}
accept={accept}
multiple={multiple}
maxFiles={maxFiles}
maxSize={maxSize}
disabled={disabled}
size={size}
title={title}
description={description}
icon={<ImageUp />}
error={error}
className={className}
>
{children ?? previewContent}
</Dropzone>
);
};
+13 -15
View File
@@ -123,8 +123,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
try {
const clientId: string = envVar('VITE_GOOGLE_CLIENT_ID');
const developerKey: string =
envVar('VITE_GOOGLE_PICKER_API_KEY') ?? '';
const developerKey: string = envVar('VITE_GOOGLE_PICKER_API_KEY') ?? '';
// Derive appId from clientId (extract numeric part before first dash)
const appId = clientId ? clientId.split('-')[0] : null;
@@ -265,7 +264,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
/>
{isConnected && (
<div className="border-border dark:border-border rounded-lg border">
<div className="border-border rounded-lg border">
<div className="p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium">
@@ -275,7 +274,6 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
type="button"
size="sm"
onClick={() => handleOpenPicker()}
className="bg-[#A076F6] hover:bg-[#8A5FD4]"
disabled={isLoading}
>
{isLoading
@@ -287,7 +285,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
</div>
{selectedFiles.length === 0 && selectedFolders.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground text-sm">
{t(
'modals.uploadDoc.connectors.googleDrive.noFilesSelected',
)}
@@ -296,13 +294,13 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
<div className="max-h-60 overflow-y-auto">
{selectedFolders.length > 0 && (
<div className="mb-2">
<h4 className="mb-1 text-xs font-medium text-gray-500">
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
{t('modals.uploadDoc.connectors.googleDrive.folders')}
</h4>
{selectedFolders.map((folder) => (
<div
key={folder.id}
className="flex items-center border-b border-gray-200 p-2 dark:border-gray-700"
className="border-border flex items-center border-b p-2"
>
<img
src={folder.iconUrl}
@@ -316,8 +314,8 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
</span>
<Button
type="button"
variant="link"
size="sm"
variant="ghost-destructive"
size="xs"
onClick={() => {
const newSelectedFolders =
selectedFolders.filter(
@@ -329,7 +327,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
newSelectedFolders.map((f) => f.id),
);
}}
className="ml-2 h-auto p-0 text-sm text-red-500 hover:text-red-700 hover:no-underline"
className="ml-2"
>
{t(
'modals.uploadDoc.connectors.googleDrive.remove',
@@ -342,13 +340,13 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
{selectedFiles.length > 0 && (
<div>
<h4 className="mb-1 text-xs font-medium text-gray-500">
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
{t('modals.uploadDoc.connectors.googleDrive.files')}
</h4>
{selectedFiles.map((file) => (
<div
key={file.id}
className="flex items-center border-b border-gray-200 p-2 dark:border-gray-700"
className="border-border flex items-center border-b p-2"
>
<img
src={file.iconUrl}
@@ -362,8 +360,8 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
</span>
<Button
type="button"
variant="link"
size="sm"
variant="ghost-destructive"
size="xs"
onClick={() => {
const newSelectedFiles = selectedFiles.filter(
(f) => f.id !== file.id,
@@ -374,7 +372,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
selectedFolders.map((f) => f.id),
);
}}
className="ml-2 h-auto p-0 text-sm text-red-500 hover:text-red-700 hover:no-underline"
className="ml-2"
>
{t(
'modals.uploadDoc.connectors.googleDrive.remove',
+3 -2
View File
@@ -162,8 +162,9 @@ const GraphView: React.FC<GraphViewProps> = ({
<Button
type="button"
variant="outline"
size="icon-sm"
className="text-muted-foreground mr-3 h-[29px] w-[29px] rounded-full p-2 dark:border-0"
size="icon-xs"
shape="pill"
className="mr-3"
onClick={onBackToDocuments}
aria-label={t('settings.sources.backToAll')}
>
+2 -2
View File
@@ -41,10 +41,10 @@ const Help = () => {
<div className="relative inline-block text-sm" ref={dropdownRef}>
<Button
type="button"
variant="ghost"
variant="sidebar-item"
ref={buttonRef}
onClick={toggleDropdown}
className="hover:bg-sidebar-accent mx-4 my-auto w-full justify-start gap-2.5 rounded-3xl pr-0 pl-3"
className="mx-4 my-auto w-full"
>
<img
src={Info}
+4 -4
View File
@@ -9,7 +9,7 @@ import remarkGfm from 'remark-gfm';
export default function MarkdownPreview({ content }: { content: string }) {
return (
<div className="h-full overflow-auto p-4">
<div className="flex flex-col gap-3 text-sm leading-normal break-words whitespace-pre-wrap text-gray-800 dark:text-gray-200">
<div className="text-foreground flex flex-col gap-3 text-sm leading-normal break-words whitespace-pre-wrap">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@@ -19,7 +19,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline dark:text-blue-400"
className="text-primary hover:underline"
>
{children}
</a>
@@ -51,7 +51,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
code({ children, ...rest }) {
return (
<code
className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal"
className="bg-accent rounded-md px-2 py-1 text-xs font-normal"
{...rest}
>
{children}
@@ -60,7 +60,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
},
blockquote({ children }) {
return (
<blockquote className="border-l-4 border-gray-300 pl-4 italic dark:border-gray-600">
<blockquote className="border-border border-l-4 pl-4 italic">
{children}
</blockquote>
);
@@ -48,7 +48,42 @@ describe('MermaidRenderer', () => {
);
});
expect(container.querySelector('pre.mermaid')).not.toBeNull();
// The diagram host is the only <pre> while the code view is closed.
expect(container.querySelectorAll('pre')).toHaveLength(1);
expect(container.querySelector('pre')?.id).toMatch(/^mermaid-/);
expect(container.querySelector('svg[data-rendered="true"]')).not.toBeNull();
});
it('opens the Download menu with a menu item per format', async () => {
renderMermaidDiagramMock.mockResolvedValue({ svg: '<svg></svg>' });
await act(async () => {
root.render(
<MermaidRenderer code={'flowchart LR\nA --> B'} isLoading={false} />,
);
});
const trigger = container.querySelector<HTMLButtonElement>(
'button[title="mermaid.downloadOptions"]',
);
expect(trigger).not.toBeNull();
expect(trigger?.getAttribute('aria-haspopup')).toBe('menu');
expect(document.querySelector('[role="menu"]')).toBeNull();
await act(async () => {
trigger?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(trigger?.getAttribute('aria-expanded')).toBe('true');
const items = Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).map((item) => item.textContent);
expect(items).toEqual([
'Download as SVG',
'Download as PNG',
'Download as MMD',
]);
});
});
+50 -71
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { CircleAlert } from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneLight,
@@ -11,7 +12,14 @@ import { selectStatus } from '../conversation/conversationSlice';
import { useDarkTheme } from '../hooks';
import CopyButton from './CopyButton';
import { renderMermaidDiagram } from './mermaidSecurity';
import { Alert, AlertDescription } from './ui/alert';
import { Button } from './ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { MermaidRendererProps } from './types';
const MermaidRenderer: React.FC<MermaidRendererProps> = ({
@@ -27,8 +35,6 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
const status = useSelector(selectStatus);
const [error, setError] = useState<string | null>(null);
const [showCode, setShowCode] = useState<boolean>(false);
const [showDownloadMenu, setShowDownloadMenu] = useState<boolean>(false);
const downloadMenuRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLPreElement>(null);
const [hoverPosition, setHoverPosition] = useState<{
@@ -126,22 +132,6 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
};
}, [code, isDarkTheme, isCurrentlyLoading]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
downloadMenuRef.current &&
!downloadMenuRef.current.contains(event.target as Node)
) {
setShowDownloadMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showDownloadMenu]);
const downloadSvg = (): void => {
const element = document.getElementById(diagramId.current);
if (!element) return;
@@ -274,61 +264,43 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
const errorRender = !isCurrentlyLoading && error;
return (
<div className="w-inherit group border-border bg-card relative overflow-hidden rounded-xl border">
<div className="group border-border bg-card relative overflow-hidden rounded-xl border">
<div className="bg-muted flex items-center justify-between px-2 py-1">
<span className="text-foreground dark:text-foreground text-xs font-medium">
mermaid
</span>
<span className="text-foreground text-xs font-medium">mermaid</span>
<div className="flex items-center gap-2">
<CopyButton textToCopy={String(code).replace(/\n$/, '')} />
{showDiagramOptions && (
<div className="relative" ref={downloadMenuRef}>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
className="h-auto gap-1 rounded-sm bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
title={t('mermaid.downloadOptions')}
>
Download <span className="ml-1">▼</span>
</Button>
{showDownloadMenu && (
<div className="border-border bg-card absolute right-0 z-10 mt-1 w-40 rounded-sm border shadow-lg">
<ul>
{downloadOptions.map((option, index) => (
<li key={index}>
<Button
type="button"
variant="ghost"
onClick={() => {
option.action();
setShowDownloadMenu(false);
}}
className="h-auto w-full justify-start rounded-none px-4 py-2 text-left text-xs font-normal"
>
{option.label}
</Button>
</li>
))}
</ul>
</div>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost-muted"
size="xs"
title={t('mermaid.downloadOptions')}
>
Download <span className="ml-1">▼</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{downloadOptions.map((option) => (
<DropdownMenuItem
key={option.label}
onSelect={() => option.action()}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{showDiagramOptions && (
<Button
type="button"
variant="secondary"
size="sm"
variant={showCode ? 'secondary' : 'ghost-muted'}
size="xs"
onClick={() => setShowCode(!showCode)}
className={`h-auto rounded px-2 py-1 text-xs ${
showCode
? 'bg-blue-200 hover:bg-blue-200/90 dark:bg-blue-800 dark:hover:bg-blue-800/90'
: 'bg-gray-100 hover:bg-gray-100/90 dark:bg-gray-700 dark:hover:bg-gray-700/90'
}`}
title={t('mermaid.viewCode')}
>
Code
@@ -339,16 +311,17 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
{isCurrentlyLoading ? (
<div className="bg-card flex items-center justify-center p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
<div className="text-muted-foreground text-sm">
Loading diagram...
</div>
</div>
) : errorRender ? (
<div className="m-2 rounded-sm border-2 border-red-400 dark:border-red-700">
<div className="overflow-auto bg-red-100 px-4 py-2 text-sm wrap-break-word whitespace-normal text-red-800 dark:bg-red-900/30 dark:text-red-300">
<Alert variant="destructive" className="m-2 w-auto">
<CircleAlert />
<AlertDescription className="overflow-auto wrap-break-word whitespace-normal">
{error}
</div>
</div>
</AlertDescription>
</Alert>
) : (
<>
<div
@@ -373,11 +346,14 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() =>
setZoomFactor((prev) => Math.max(1, prev - 0.5))
}
className="h-auto px-1 py-0 text-white hover:bg-gray-600 hover:text-white"
title={t('mermaid.decreaseZoom')}
/* eslint-disable-next-line shadcn/no-restyle --
zoom controls sit on the bg-black/70 overlay; ghost's accent hover would paint a light square on it */
className="hover:bg-white/20 hover:text-white dark:hover:bg-white/20"
>
-
</Button>
@@ -393,11 +369,14 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() =>
setZoomFactor((prev) => Math.min(6, prev + 0.5))
}
className="h-auto px-1 py-0 text-white hover:bg-gray-600 hover:text-white"
title={t('mermaid.increaseZoom')}
/* eslint-disable-next-line shadcn/no-restyle --
zoom controls sit on the bg-black/70 overlay; ghost's accent hover would paint a light square on it */
className="hover:bg-white/20 hover:text-white dark:hover:bg-white/20"
>
+
</Button>
@@ -406,7 +385,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
)}
<pre
ref={diagramRef}
className="mermaid w-full select-none"
className="w-full select-none"
id={diagramId.current}
key={`mermaid-${diagramId.current}`}
style={{
@@ -424,7 +403,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
{showCode && (
<div className="border-border border-t">
<div className="bg-muted p-2">
<span className="text-foreground dark:text-foreground text-xs font-medium">
<span className="text-foreground text-xs font-medium">
Mermaid Code
</span>
</div>
+17 -10
View File
@@ -1,4 +1,5 @@
import { envVar } from '@/env';
import { cn } from '@/lib/utils';
import {
useCallback,
useEffect,
@@ -1712,7 +1713,7 @@ export default function MessageInput({
)}
{sendArmed && sendReadiness.state === 'blocked' && (
<div
className="px-2 pb-1 text-xs text-[#B42318] sm:px-3"
className="text-destructive px-2 pb-1 text-xs sm:px-3"
role="alert"
>
{t('conversation.attachments.sendBlockedByFailed', {
@@ -1721,7 +1722,7 @@ export default function MessageInput({
</div>
)}
{voiceError && (
<div className="px-2 pb-1 text-xs text-[#B42318] sm:px-3">
<div className="text-destructive px-2 pb-1 text-xs sm:px-3">
{voiceError}
</div>
)}
@@ -1742,7 +1743,7 @@ export default function MessageInput({
}
tabIndex={1}
placeholder={t('inputPlaceholder')}
className="inputbox-style dark:text-foreground dark:placeholder:text-muted-foreground/50 w-full scrollbar-thin overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3"
className="inputbox-style dark:text-foreground placeholder:text-muted-foreground w-full scrollbar-thin overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 focus:outline-hidden sm:px-3"
onKeyDown={handleKeyDown}
onPaste={handlePaste}
aria-label={t('inputPlaceholder')}
@@ -1791,9 +1792,10 @@ export default function MessageInput({
type="button"
variant="default"
size="icon"
shape="pill"
onClick={handleCancel}
aria-label={t('cancel')}
className="bg-primary ml-auto h-7 w-7 shrink-0 rounded-full text-white sm:h-9 sm:w-9"
className="ml-auto h-7 w-7 shrink-0 sm:h-9 sm:w-9"
disabled={!loading}
>
<div className="flex h-3 w-3 items-center justify-center rounded-sm bg-white sm:h-3.5 sm:w-3.5" />
@@ -1803,13 +1805,18 @@ export default function MessageInput({
type="button"
variant="default"
size="icon"
shape="pill"
onClick={handleSubmit}
aria-label={t('send')}
className={`ml-auto h-7 w-7 shrink-0 rounded-full transition-colors duration-300 ease-in-out sm:h-9 sm:w-9 ${
canSubmit
? 'bg-primary text-white'
: 'bg-muted text-muted-foreground dark:bg-accent dark:text-muted-foreground'
}`}
className={cn(
'ml-auto h-7 w-7 shrink-0 sm:h-9 sm:w-9',
!canSubmit &&
/* eslint-disable-next-line shadcn/no-restyle --
The empty composer's send button is a grey circle, not a
faded brand one: no variant is neutral while disabled, and
secondary is the brand-tinted pressed state. */
'bg-muted text-muted-foreground dark:bg-accent',
)}
disabled={!canSubmit}
>
<SendArrow
@@ -1839,7 +1846,7 @@ export default function MessageInput({
<span className="text-muted-foreground dark:text-muted-foreground px-2 text-2xl font-bold">
{t('modals.uploadDoc.drag.title')}
</span>
<span className="text-s text-muted-foreground dark:text-muted-foreground w-48 p-2 text-center">
<span className="text-muted-foreground dark:text-muted-foreground w-48 p-2 text-center text-sm">
{t('modals.uploadDoc.drag.description')}
</span>
</div>,
@@ -0,0 +1,81 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const media = { isMobile: false, isTablet: false, isDesktop: true };
vi.mock('../hooks', () => ({
useMediaQuery: () => media,
useDarkTheme: () => [false, () => undefined],
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
import { MultiSelectPopover } from './MultiSelectPopover';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const items = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta' },
];
describe('MultiSelectPopover', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
media.isMobile = false;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = () =>
act(() => {
root.render(
<MultiSelectPopover
open
trigger={<button type="button">Open</button>}
items={items}
selectedIds={['b']}
onToggle={() => undefined}
className="max-h-40"
/>,
);
});
it('puts className on the desktop popover', () => {
render();
const content = document.querySelector('[data-slot="popover-content"]');
expect(content?.className).toContain('max-h-40');
});
it('puts className on the mobile sheet', () => {
media.isMobile = true;
render();
const content = document.querySelector('[data-slot="sheet-content"]');
expect(content?.className).toContain('max-h-40');
});
it('marks chosen rows with CommandItem checked and a primary tick', () => {
render();
const rows = document.querySelectorAll('[data-slot="command-item"]');
expect(rows).toHaveLength(2);
expect(rows[0].hasAttribute('data-checked')).toBe(false);
expect(rows[1].getAttribute('data-checked')).toBe('true');
rows.forEach((row) => expect(row.querySelector('img')).toBeNull());
const tick = rows[1].querySelector('svg');
expect(tick).not.toBeNull();
expect(tick?.getAttribute('class')).toContain('text-primary');
expect(rows[0].querySelector('svg')).toBeNull();
});
});
+24 -30
View File
@@ -1,7 +1,7 @@
import { Check } from 'lucide-react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import CheckmarkIcon from '../assets/checkmark.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import { useDarkTheme, useMediaQuery } from '../hooks';
@@ -42,7 +42,8 @@ export type MultiSelectPopoverProps = {
footer?: React.ReactNode;
align?: 'start' | 'center' | 'end';
side?: 'top' | 'right' | 'bottom' | 'left';
contentClassName?: string;
/** Extra layout classes for the popup (the desktop popover or the mobile sheet). */
className?: string;
emptyMessage?: string;
selectedFirst?: boolean;
title?: string;
@@ -80,7 +81,7 @@ export function MultiSelectPopover({
footer,
align = 'start',
side = 'bottom',
contentClassName,
className,
emptyMessage,
selectedFirst = false,
title,
@@ -128,14 +129,15 @@ export function MultiSelectPopover({
onSelect={() => {
if (!item.disabled) onToggle(item.id);
}}
className="dark:border-border data-[selected=true]:bg-accent flex cursor-pointer items-center justify-between gap-2 rounded-none border-b border-[#D9D9D9] px-3 py-3 last:border-b-0"
checked={isSelected}
className="cursor-pointer justify-between"
aria-selected={isSelected}
>
<div className="mr-3 flex grow items-center overflow-hidden">
{renderIcon(item.icon)}
<div className="overflow-hidden">
<p
className="overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900 dark:text-white"
className="text-foreground overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
title={item.label}
>
{item.label}
@@ -150,17 +152,11 @@ export function MultiSelectPopover({
</div>
</div>
<div
className="border-border bg-card flex h-4 w-4 shrink-0 items-center justify-center rounded-xs border-2 p-[0.5px]"
className="border-border bg-card flex h-4 w-4 shrink-0 items-center justify-center rounded-xs border-2"
aria-hidden="true"
>
{isSelected && (
<img
src={CheckmarkIcon}
alt=""
width={10}
height={10}
aria-hidden="true"
/>
<Check className="text-primary size-2.5" aria-hidden="true" />
)}
</div>
</CommandItem>
@@ -175,19 +171,17 @@ export function MultiSelectPopover({
aria-hidden="true"
className="mb-3 h-16 w-16"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground text-sm">
{emptyMessage || t('settings.tools.noToolsFound', 'No results found')}
</p>
</div>
);
const commandBody = (
<Command shouldFilter={searchable} className="bg-transparent">
<Command shouldFilter={searchable}>
{title && (
<div className="shrink-0 px-4 pt-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{title}
</h3>
<h3 className="text-foreground text-lg font-medium">{title}</h3>
</div>
)}
{searchable && (
@@ -197,11 +191,11 @@ export function MultiSelectPopover({
)}
{loading ? (
<div className="text-foreground flex items-center justify-center py-8 dark:text-white">
<div className="text-foreground flex items-center justify-center py-8">
<Spinner size="small" />
</div>
) : (
<div className="dark:border-border mx-4 my-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-[#D9D9D9]">
<div className="border-border mx-4 my-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
<CommandList className="max-h-none min-h-0 flex-1 overflow-y-auto">
<CommandEmpty>{renderEmptyState()}</CommandEmpty>
{hasGroups ? (
@@ -212,23 +206,20 @@ export function MultiSelectPopover({
<CommandGroup
key={`group-${groupKey || 'ungrouped'}`}
heading={groupKey || undefined}
className="[&_[cmdk-group-heading]]:bg-muted/50 [&_[cmdk-group-heading]]:dark:bg-card [&_[cmdk-group-heading]]:text-muted-foreground p-0 [&_[cmdk-group-heading]]:sticky [&_[cmdk-group-heading]]:top-0 [&_[cmdk-group-heading]]:z-10 [&_[cmdk-group-heading]]:border-b [&_[cmdk-group-heading]]:border-[#D9D9D9] [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:dark:border-[#2E2E2E]"
>
{groupItems.map(renderItem)}
</CommandGroup>
);
})
) : (
<CommandGroup className="p-0">
{orderedItems.map(renderItem)}
</CommandGroup>
<CommandGroup>{orderedItems.map(renderItem)}</CommandGroup>
)}
</CommandList>
</div>
)}
{footer && (
<div className="border-border dark:border-border shrink-0 border-t px-4 py-4">
<div className="border-border shrink-0 border-t px-4 py-4">
{footer}
</div>
)}
@@ -244,15 +235,18 @@ export function MultiSelectPopover({
showCloseButton={false}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'border-border bg-background dark:border-border dark:bg-card flex max-h-[85vh] flex-col gap-0 overflow-hidden rounded-t-2xl p-0 pb-[env(safe-area-inset-bottom)]',
contentClassName,
/* eslint-disable-next-line shadcn/no-restyle, shadcn/no-arbitrary-values --
bottom-sheet shape: rounded top, no gap, card fill in dark to match Command's
bg-popover, and pb-[env(safe-area-inset-bottom)] to clear the iPhone home indicator. */
'dark:bg-card flex max-h-[85vh] flex-col gap-0 overflow-hidden rounded-t-2xl p-0 pb-[env(safe-area-inset-bottom)]',
className,
)}
>
<SheetTitle className="sr-only">
{title || effectivePlaceholder}
</SheetTitle>
<div
className="mx-auto mt-2 mb-1 h-1.5 w-12 shrink-0 rounded-full bg-gray-300 dark:bg-gray-600"
className="bg-border mx-auto mt-2 mb-1 h-1.5 w-12 shrink-0 rounded-full"
aria-hidden="true"
/>
{commandBody}
@@ -268,8 +262,8 @@ export function MultiSelectPopover({
align={align}
side={side}
className={cn(
'border-border bg-background dark:border-border dark:bg-card flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden rounded-lg p-0 shadow-md',
contentClassName,
'flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden p-0',
className,
)}
>
{commandBody}
+76 -100
View File
@@ -25,114 +25,90 @@ export default function Notification({
}: NotificationProps) {
const { t } = useTranslation();
return (
<>
<style>{`
@keyframes twinkle {
0%, 100% {
opacity: 0.3;
transform: scale(1) translateY(0);
}
50% {
opacity: 1;
transform: scale(1.2) translateY(-2px);
}
}
.star {
animation: twinkle var(--duration) ease-in-out infinite;
animation-delay: var(--delay);
}
`}</style>
<a
className="group absolute right-2 bottom-24 z-20 flex w-3/4 items-center justify-center gap-2 overflow-hidden rounded-lg px-2 py-4 sm:right-4 sm:bottom-6 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
style={{
background:
'linear-gradient(90deg, #390086 0%, #6222B7 100%), linear-gradient(90deg, rgba(57, 0, 134, 0) 0%, #6222B7 53.02%, rgba(57, 0, 134, 0) 100%)',
}}
href={notificationLink}
target="_blank"
aria-label={t('notification.ariaLabel')}
rel="noreferrer"
>
{/* Animated stars background */}
<div className="pointer-events-none absolute inset-0">
{stars.map((star) => (
<svg
key={star.id}
className="star absolute"
style={
{
width: `${star.size * 4}px`,
height: `${star.size * 4}px`,
left: `${star.left}%`,
top: `${star.top}%`,
opacity: star.opacity,
filter: `drop-shadow(0 0 ${star.size}px rgba(255, 255, 255, 0.5))`,
'--duration': `${star.animationDuration}s`,
'--delay': `${star.animationDelay}s`,
} as React.CSSProperties & {
'--duration': string;
'--delay': string;
}
}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* 4-pointed Christmas star */}
<path
d="M12 0L13.5 10.5L24 12L13.5 13.5L12 24L10.5 13.5L0 12L10.5 10.5L12 0Z"
fill="white"
/>
</svg>
))}
</div>
<p className="relative z-10 text-xs leading-6 font-semibold text-white xl:text-sm xl:leading-7">
{notificationText}
</p>
<span className="relative z-10 flex items-center">
<a
className="notification-banner group absolute right-2 bottom-24 z-20 flex w-3/4 items-center justify-center gap-2 overflow-hidden rounded-lg px-2 py-4 sm:right-4 sm:bottom-6 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
href={notificationLink}
target="_blank"
aria-label={t('notification.ariaLabel')}
rel="noreferrer"
>
{/* Animated stars background. The banner is always the purple gradient,
so its imagery is white in both themes. */}
<div className="pointer-events-none absolute inset-0 text-white">
{stars.map((star) => (
<svg
width="18"
height="13"
viewBox="0 0 18 13"
key={star.id}
className="notification-star absolute"
style={
{
'--star-size': `${star.size * 4}px`,
'--star-left': `${star.left}%`,
'--star-top': `${star.top}%`,
'--star-opacity': String(star.opacity),
'--star-glow': `${star.size}px`,
'--star-duration': `${star.animationDuration}s`,
'--star-delay': `${star.animationDelay}s`,
} as React.CSSProperties
}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="overflow-visible"
>
{/* Arrow tail - grows leftward from arrow head's back point on hover */}
<rect
x="4"
y="5.75"
width="8"
height="1.5"
fill="white"
className="scale-x-0 transition-transform duration-300 ease-out group-hover:scale-x-100"
style={{ transformOrigin: '12px 6.5px' }}
/>
{/* Arrow head - pushed forward by the tail on hover */}
{/* 4-pointed Christmas star */}
<path
d="M13.0303 7.03033C13.3232 6.73744 13.3232 6.26256 13.0303 5.96967L8.25736 1.1967C7.96447 0.903806 7.48959 0.903806 7.1967 1.1967C6.90381 1.48959 6.90381 1.96447 7.1967 2.25736L11.4393 6.5L7.1967 10.7426C6.90381 11.0355 6.90381 11.5104 7.1967 11.8033C7.48959 12.0962 7.96447 12.0962 8.25736 11.8033L13.0303 7.03033Z"
fill="white"
className="transition-transform duration-300 ease-out group-hover:translate-x-1"
d="M12 0L13.5 10.5L24 12L13.5 13.5L12 24L10.5 13.5L0 12L10.5 10.5L12 0Z"
fill="currentColor"
/>
</svg>
</span>
))}
</div>
<Button
type="button"
variant="ghost"
className="absolute top-2 right-2 z-30 h-4 w-4 rounded-none bg-transparent p-0 hover:bg-transparent hover:opacity-70"
aria-label={t('notification.closeAriaLabel')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCloseNotification();
}}
<p className="relative z-10 text-xs leading-6 font-semibold text-white xl:text-sm xl:leading-7">
{notificationText}
</p>
<span className="relative z-10 flex items-center text-white">
<svg
width="18"
height="13"
viewBox="0 0 18 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="overflow-visible"
>
<img className="w-full" src={close} alt="Close notification" />
</Button>
</a>
</>
{/* Arrow tail - grows leftward from arrow head's back point on hover */}
<rect
x="4"
y="5.75"
width="8"
height="1.5"
fill="currentColor"
className="origin-[12px_6.5px] scale-x-0 transition-transform duration-300 ease-out group-hover:scale-x-100"
/>
{/* Arrow head - pushed forward by the tail on hover */}
<path
d="M13.0303 7.03033C13.3232 6.73744 13.3232 6.26256 13.0303 5.96967L8.25736 1.1967C7.96447 0.903806 7.48959 0.903806 7.1967 1.1967C6.90381 1.48959 6.90381 1.96447 7.1967 2.25736L11.4393 6.5L7.1967 10.7426C6.90381 11.0355 6.90381 11.5104 7.1967 11.8033C7.48959 12.0962 7.96447 12.0962 8.25736 11.8033L13.0303 7.03033Z"
fill="currentColor"
className="transition-transform duration-300 ease-out group-hover:translate-x-1"
/>
</svg>
</span>
<Button
type="button"
variant="ghost"
/* eslint-disable-next-line shadcn/no-restyle -- a 16px bare close on
the promo gradient: no size fits, and ghost's accent hover fill
would paint a grey square on the purple. */
className="absolute top-2 right-2 z-30 h-4 w-4 rounded-none bg-transparent p-0 hover:bg-transparent hover:opacity-70"
aria-label={t('notification.closeAriaLabel')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCloseNotification();
}}
>
<img className="w-full" src={close} alt="Close notification" />
</Button>
</a>
);
}
+13 -11
View File
@@ -17,19 +17,21 @@ export default function ProfileButton() {
if (authType !== 'oidc' || (!userName && !userEmail)) return null;
const initial = (userName || userEmail || '?').charAt(0).toUpperCase();
const renderAvatar = (size: string, text: string) =>
// `default` is size-8/text-sm, `xl` is size-10/text-base; the size variant
// also brings the flex centring and the overflow-hidden that clips both the
// photo and the initials to the circle.
const renderAvatar = (size: 'default' | 'xl') =>
userPicture ? (
<Avatar
size={size}
shape="circle"
src={userPicture}
alt={userName || userEmail || 'User avatar'}
className={size}
imgClassName={`${size} rounded-full object-cover`}
imgClassName="size-full object-cover"
/>
) : (
<Avatar className={size}>
<span
className={`bg-primary text-primary-foreground flex ${size} items-center justify-center rounded-full ${text} font-medium`}
>
<Avatar size={size} shape="circle">
<span className="bg-primary text-primary-foreground flex size-full items-center justify-center font-medium">
{initial}
</span>
</Avatar>
@@ -43,15 +45,15 @@ export default function ProfileButton() {
aria-label={t('auth.account')}
className="ring-offset-background focus-visible:ring-ring hover:ring-primary/40 rounded-full transition outline-none hover:ring-2 hover:ring-offset-2 focus-visible:ring-2 focus-visible:ring-offset-2"
>
{renderAvatar('size-8', 'text-sm')}
{renderAvatar('default')}
</button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-64 p-0">
<div className="flex items-center gap-3 p-4">
{renderAvatar('size-10', 'text-base')}
{renderAvatar('xl')}
<span className="flex min-w-0 flex-col">
{userName && (
<p className="text-foreground truncate text-sm font-medium dark:text-white">
<p className="text-foreground truncate text-sm font-medium">
{userName}
</p>
)}
@@ -68,7 +70,7 @@ export default function ProfileButton() {
type="button"
onClick={logout}
data-testid="oidc-signout"
className="text-foreground hover:bg-sidebar-accent flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm dark:text-white"
className="text-foreground hover:bg-sidebar-accent flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm"
>
<LogOut
className="text-muted-foreground size-4 shrink-0"
@@ -29,9 +29,10 @@ export default function SourcesPopoverFooter({
</Link>
<Button
type="button"
variant="outline"
variant="outline-primary"
shape="pill"
onClick={onUploadClick}
className="border-primary text-primary hover:bg-primary/90 h-auto w-auto self-start rounded-full border bg-transparent px-4 py-2 text-sm font-medium shadow-none transition-colors duration-200 hover:text-white"
className="w-auto self-start"
>
{t('settings.sources.uploadNew')}
</Button>
+7 -25
View File
@@ -1,33 +1,15 @@
import React from 'react';
import { Spinner as UiSpinner } from './ui/spinner';
type SpinnerProps = {
size?: 'small' | 'medium' | 'large';
};
const SIZE_MAP: Record<NonNullable<SpinnerProps['size']>, string> = {
small: '20px',
medium: '30px',
large: '40px',
};
const SIZE_MAP = { small: 'sm', medium: 'default', large: 'lg' } as const;
/**
* Compatibility wrapper over `ui/spinner`. New code should import
* `Spinner` from `@/components/ui/spinner` and use `sm | default | lg`.
*/
export default function Spinner({ size = 'medium' }: SpinnerProps) {
const spinnerSize = SIZE_MAP[size];
const spinnerStyle: React.CSSProperties = {
width: spinnerSize,
height: spinnerSize,
aspectRatio: '1',
borderRadius: '50%',
background: `
radial-gradient(farthest-side, currentColor 94%, transparent) top/8px 8px no-repeat,
conic-gradient(transparent 30%, currentColor)
`,
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 8px), #000 0)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 8px), #000 0)',
animation: 'spinner-rotate 1s infinite linear',
color: 'currentColor',
};
return <div className="loader" role="status" style={spinnerStyle} />;
return <UiSpinner size={SIZE_MAP[size]} />;
}
@@ -174,11 +174,10 @@ export default function SpeakButton({ text }: { text: string }) {
return (
<Button
type="button"
variant="ghost"
variant={isSpeaking || isLoading ? 'secondary' : 'ghost'}
size="icon"
className={`cursor-pointer rounded-full ${
isSpeaking || isLoading ? 'bg-accent' : ''
}`}
shape="pill"
className="cursor-pointer"
onClick={handleSpeakClick}
aria-label={
isLoading
@@ -192,9 +191,9 @@ export default function SpeakButton({ text }: { text: string }) {
{isLoading ? (
<Spinner size="small" />
) : isSpeaking ? (
<Stopspeech className="fill-none" />
<Stopspeech />
) : (
<Speaker className="fill-none" />
<Speaker />
)}
</Button>
);
+1 -7
View File
@@ -37,12 +37,6 @@ export default function ToolIcon({ name, className, title }: ToolIconProps) {
? { role: 'img' as const, 'aria-label': title }
: { 'aria-hidden': true as const };
return (
<Icon
className={cn(
'h-6 w-6 text-neutral-700 dark:text-neutral-200',
className,
)}
{...a11y}
/>
<Icon className={cn('text-foreground h-6 w-6', className)} {...a11y} />
);
}
@@ -0,0 +1,121 @@
import { configureStore } from '@reduxjs/toolkit';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Provider } from 'react-redux';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import uploadReducer, {
addUploadTask,
type UploadTask,
} from '../upload/uploadSlice';
import UploadToast from './UploadToast';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const makeStore = () => configureStore({ reducer: { upload: uploadReducer } });
const task = (over: Partial<UploadTask>): UploadTask => ({
id: 't1',
fileName: 'a.pdf',
progress: 40,
status: 'training',
...over,
});
describe('UploadToast', () => {
let container: HTMLDivElement;
let root: Root;
let store: ReturnType<typeof makeStore>;
beforeEach(() => {
localStorage.clear();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
store = makeStore();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async () => {
await act(async () => {
root.render(
<Provider store={store}>
<UploadToast />
</Provider>,
);
});
};
it('renders nothing without tasks', async () => {
await render();
expect(container.innerHTML).toBe('');
});
it('renders one card of rows with no rail of its own', async () => {
store.dispatch(
addUploadTask(task({ id: 't1', fileName: 'a.pdf', stage: 'embedding' })),
);
store.dispatch(
addUploadTask(task({ id: 't2', fileName: 'b.md', status: 'completed' })),
);
await render();
expect(container.querySelectorAll('[data-slot="toast"]')).toHaveLength(1);
expect(container.querySelector('[data-slot="toast-viewport"]')).toBeNull();
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('.fixed')).toBeNull();
const rows = container.querySelectorAll('[data-slot="toast-item"]');
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('b.md');
expect(rows[0].querySelector('[data-status="success"]')).not.toBeNull();
expect(rows[1].textContent).toContain(
'modals.uploadDoc.progress.embedding',
);
expect(rows[1].querySelector('[role="progressbar"]')).not.toBeNull();
expect(
container.querySelector('[data-slot="toast-title"]')?.textContent,
).toBe('modals.uploadDoc.progress.upload');
});
it('tints the header and explains a failure', async () => {
store.dispatch(
addUploadTask(task({ status: 'failed', errorMessage: 'Too big' })),
);
await render();
expect(
container
.querySelector('[data-slot="toast-header"]')
?.getAttribute('data-variant'),
).toBe('destructive');
const row = container.querySelector('[data-slot="toast-item"]');
expect(row?.querySelector('[data-status="destructive"]')).not.toBeNull();
const message = container.querySelector('[data-slot="toast-message"]');
expect(message?.textContent).toBe('Too big');
expect(message?.className).toContain('text-destructive');
});
it('collapses the rows and dismisses every task', async () => {
store.dispatch(addUploadTask(task({})));
await render();
const collapse = container.querySelector(
'button[aria-label="modals.uploadDoc.progress.collapseDetails"]',
) as HTMLButtonElement;
await act(async () => collapse.click());
expect(
container.querySelector(
'button[aria-label="modals.uploadDoc.progress.expandDetails"]',
),
).not.toBeNull();
const close = container.querySelector(
'button[aria-label="modals.uploadDoc.progress.dismiss"]',
) as HTMLButtonElement;
await act(async () => close.click());
expect(container.innerHTML).toBe('');
});
});
+132 -152
View File
@@ -1,18 +1,26 @@
import { X } from 'lucide-react';
import { ChevronDown, X } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { selectWorkflowPreviewOpen } from '../agents/workflow/workflowPreviewSlice';
import CheckCircleFilled from '../assets/check-circle-filled.svg';
import ChevronDown from '../assets/chevron-down.svg';
import WarnIcon from '../assets/warn.svg';
import { cn } from '@/lib/utils';
import {
dismissUploadTask,
selectUploadTasks,
type UploadTask,
} from '../upload/uploadSlice';
import { Button } from './ui/button';
import {
Toast,
ToastActions,
ToastContent,
ToastHeader,
ToastItem,
ToastMessage,
ToastStatus,
ToastTitle,
} from './ui/toast';
const PROGRESS_RADIUS = 10;
const PROGRESS_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RADIUS;
@@ -30,6 +38,11 @@ const IN_PROGRESS_STATUSES = new Set<UploadTask['status']>([
* newest task overall if all are terminal). Per-task progress lives
* on each row.
*
* Renders only its ``Toast`` card: App.tsx mounts the shared
* ``ToastViewport`` and places this card at the bottom of the stack
* (and moves the whole stack left while the workflow Preview drawer is
* open).
*
* Dismissal: the header X dismisses every visible task at once
* (mirrors the GDrive panel close — keeps the surface tidy without
* per-row controls). The chevron collapses the row list.
@@ -40,10 +53,6 @@ export default function UploadToast() {
const { t } = useTranslation();
const dispatch = useDispatch();
const uploadTasks = useSelector(selectUploadTasks);
// The workflow Preview drawer occupies the right edge; shift the toast to
// the bottom-left while it's open so it stays visible without covering the
// drawer's attach/send controls.
const previewOpen = useSelector(selectWorkflowPreviewOpen);
const visibleTasks = uploadTasks.filter((task) => !task.dismissed);
if (visibleTasks.length === 0) return null;
@@ -64,80 +73,62 @@ export default function UploadToast() {
};
return (
<div
className={`fixed bottom-4 z-50 flex max-w-md flex-col gap-2 ${
previewOpen ? 'left-4' : 'right-4'
}`}
onMouseDown={(e) => e.stopPropagation()}
role="status"
aria-live="polite"
aria-atomic="false"
>
<Toast>
<ToastHeader
variant={primaryTask.status === 'failed' ? 'destructive' : 'default'}
>
<ToastTitle>{headerLabel}</ToastTitle>
<ToastActions>
<Button
type="button"
variant="ghost-muted"
size="icon-sm"
onClick={() => setCollapsed((prev) => !prev)}
aria-label={
collapsed
? t('modals.uploadDoc.progress.expandDetails')
: t('modals.uploadDoc.progress.collapseDetails')
}
>
<ChevronDown
className={cn(
'h-4 w-4 transition-transform duration-200',
collapsed && 'rotate-180',
)}
/>
</Button>
<Button
type="button"
variant="ghost-muted"
size="icon-sm"
onClick={dismissAll}
aria-label={t('modals.uploadDoc.progress.dismiss')}
>
<X className="h-4 w-4" />
</Button>
</ToastActions>
</ToastHeader>
<div
className={`border-border bg-card shadow-toast w-[271px] overflow-hidden rounded-2xl border transition-all duration-300`}
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out',
collapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]',
)}
>
<div
className={`flex items-center justify-between px-4 py-3 ${
primaryTask.status !== 'failed'
? 'bg-accent/50 dark:bg-muted'
: 'bg-destructive/10 dark:bg-destructive/10'
}`}
className={cn(
'min-h-0 overflow-hidden transition-opacity duration-300',
collapsed ? 'opacity-0' : 'opacity-100',
)}
>
<h3 className="dark:text-foreground text-sm leading-[16.5px] font-medium text-black">
{headerLabel}
</h3>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed((prev) => !prev)}
aria-label={
collapsed
? t('modals.uploadDoc.progress.expandDetails')
: t('modals.uploadDoc.progress.collapseDetails')
}
className="text-black opacity-70 hover:bg-transparent hover:opacity-100 dark:text-white dark:hover:bg-transparent"
>
<img
src={ChevronDown}
alt=""
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${
collapsed ? 'rotate-180' : ''
}`}
/>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={dismissAll}
className="text-black opacity-70 hover:bg-transparent hover:opacity-100 dark:text-white dark:hover:bg-transparent"
aria-label={t('modals.uploadDoc.progress.dismiss')}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: collapsed ? '0fr' : '1fr' }}
>
<div
className={`min-h-0 overflow-hidden transition-opacity duration-300 ${
collapsed ? 'opacity-0' : 'opacity-100'
}`}
>
<ul className="max-h-72 overflow-y-auto">
{visibleTasks.map((task) => (
<UploadRow key={task.id} task={task} t={t} />
))}
</ul>
</div>
<ToastContent scrollable>
{visibleTasks.map((task) => (
<UploadRow key={task.id} task={task} t={t} />
))}
</ToastContent>
</div>
</div>
</div>
</Toast>
);
}
@@ -154,91 +145,80 @@ function UploadRow({
const progressOffset = PROGRESS_CIRCUMFERENCE * (1 - rawProgress / 100);
return (
<li className="border-border/50 border-b last:border-b-0">
<div className="flex items-center justify-between px-5 py-3">
<div className="flex min-w-0 flex-col">
<p
className="dark:text-muted-foreground max-w-[200px] truncate text-sm leading-[16.5px] font-normal text-black"
title={task.fileName}
<>
<ToastItem
label={<span title={task.fileName}>{task.fileName}</span>}
meta={
task.status === 'training' && task.stage
? t(`modals.uploadDoc.progress.${task.stage}`)
: undefined
}
>
{showProgress && (
// Determinate ring: ToastStatus "pending" is an indeterminate
// spinner, and each row reports its own percentage.
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="text-primary h-6 w-6 shrink-0"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={formattedProgress}
aria-label={t('modals.uploadDoc.progress.uploadProgress', {
progress: formattedProgress,
})}
>
{task.fileName}
</p>
{task.status === 'training' && task.stage && (
<span className="text-muted-foreground mt-0.5 text-xs leading-[14px]">
{t(`modals.uploadDoc.progress.${task.stage}`)}
</span>
)}
</div>
<div className="flex items-center gap-2">
{showProgress && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="text-primary h-6 w-6 shrink-0"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={formattedProgress}
aria-label={t('modals.uploadDoc.progress.uploadProgress', {
progress: formattedProgress,
})}
>
<circle
className="text-muted dark:text-muted-foreground/30"
stroke="currentColor"
strokeWidth="2"
cx="12"
cy="12"
r={PROGRESS_RADIUS}
fill="none"
/>
<circle
className="text-primary"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={PROGRESS_CIRCUMFERENCE}
strokeDashoffset={progressOffset}
cx="12"
cy="12"
r={PROGRESS_RADIUS}
fill="none"
transform="rotate(-90 12 12)"
/>
</svg>
)}
{task.status === 'completed' && (
<img
src={CheckCircleFilled}
alt=""
className="h-6 w-6 shrink-0"
aria-hidden="true"
<circle
className="text-muted dark:text-muted-foreground/30"
stroke="currentColor"
strokeWidth="2"
cx="12"
cy="12"
r={PROGRESS_RADIUS}
fill="none"
/>
)}
{task.status === 'failed' && (
<img
src={WarnIcon}
alt=""
className="h-6 w-6 shrink-0"
aria-hidden="true"
<circle
className="text-primary"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={PROGRESS_CIRCUMFERENCE}
strokeDashoffset={progressOffset}
cx="12"
cy="12"
r={PROGRESS_RADIUS}
fill="none"
transform="rotate(-90 12 12)"
/>
)}
</div>
</div>
</svg>
)}
{task.status === 'completed' && (
<ToastStatus
status="success"
label={t('modals.uploadDoc.progress.completed')}
/>
)}
{task.status === 'failed' && (
<ToastStatus
status="destructive"
label={t('modals.uploadDoc.progress.failed')}
/>
)}
</ToastItem>
{task.status === 'failed' &&
(task.tokenLimitReached || task.errorMessage) && (
<span className="block px-5 pb-3 text-xs text-red-500">
<ToastMessage variant="destructive">
{task.tokenLimitReached
? t('modals.uploadDoc.progress.tokenLimit')
: task.errorMessage}
</span>
</ToastMessage>
)}
</li>
</>
);
}
+4 -3
View File
@@ -57,7 +57,7 @@ const markdownComponents = {
</a>
),
code: ({ children }: { children?: React.ReactNode }) => (
<code className="dark:bg-accent rounded-md bg-gray-200 px-1.5 py-0.5 text-xs">
<code className="bg-accent rounded-md px-1.5 py-0.5 text-xs">
{children}
</code>
),
@@ -213,8 +213,9 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
<Button
type="button"
variant="outline"
size="icon-sm"
className="text-muted-foreground mr-3 h-[29px] w-[29px] rounded-full p-2 dark:border-0"
size="icon-xs"
shape="pill"
className="mr-3"
onClick={onBackToDocuments}
aria-label={t('settings.sources.backToAll')}
>
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import ClipIcon from '../../assets/clip.svg';
import { ATTACHMENT_FILE_ACCEPT_ATTR } from '../../constants/fileUpload';
import { Button } from '../ui/button';
type AttachFileButtonProps = {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
@@ -11,29 +12,28 @@ export default function AttachFileButton({ onChange }: AttachFileButtonProps) {
const { t } = useTranslation();
return (
// translate="no": Chrome's page translator rewrites text nodes inside
// this label into <font> wrappers and React then loses the control — a
// user with auto-translate on rage-clicked a dead Attach button until
// they reloaded. Keep the composer's controls out of the translator.
<label
translate="no"
className="xs:px-3 xs:py-1.5 dark:border-border border-border hover:bg-muted dark:hover:bg-muted flex cursor-pointer items-center rounded-full border px-2 py-1 transition-colors"
>
<img
src={ClipIcon}
alt="Attach"
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
/>
<span className="xs:text-xs dark:text-foreground text-muted-foreground text-xs font-medium sm:text-sm">
{t('conversation.attachments.attach')}
</span>
<input
type="file"
className="hidden"
multiple
accept={ATTACHMENT_FILE_ACCEPT_ATTR}
onChange={onChange}
/>
</label>
<Button asChild variant="outline" size="sm" shape="pill">
{/* translate="no": Chrome's page translator rewrites text nodes inside
this label into <font> wrappers and React then loses the control — a
user with auto-translate on rage-clicked a dead Attach button until
they reloaded. Keep the composer's controls out of the translator. */}
<label translate="no" className="cursor-pointer justify-start">
<img
src={ClipIcon}
alt="Attach"
className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4 dark:invert"
/>
<span className="dark:text-foreground text-muted-foreground text-xs sm:text-sm">
{t('conversation.attachments.attach')}
</span>
<input
type="file"
className="hidden"
multiple
accept={ATTACHMENT_FILE_ACCEPT_ATTR}
onChange={onChange}
/>
</label>
</Button>
);
}
@@ -58,7 +58,7 @@ export default function AttachmentChipList({
attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
} ${
draggingId === attachment.id
? 'ring-dashed opacity-60 ring-2 ring-purple-200'
? 'ring-primary/30 opacity-60 ring-2'
: ''
}`}
title={
@@ -98,7 +98,7 @@ export default function AttachmentChipList({
fill="none"
/>
<circle
className="text-[#ECECF1]"
className="text-primary-foreground"
cx="12"
cy="12"
r="10"
@@ -123,8 +123,9 @@ export default function AttachmentChipList({
<Button
type="button"
variant="ghost"
size="icon-sm"
className="ml-1.5 h-auto w-auto rounded-full p-1"
size="icon-xs"
shape="pill"
className="ml-1.5"
onClick={() => {
onRemove(attachment.id);
}}
@@ -142,7 +143,7 @@ export default function AttachmentChipList({
{failures.length > 0 && (
<div
className="flex flex-col gap-0.5 px-2 pb-1 text-xs text-[#B42318] sm:px-3"
className="text-destructive flex flex-col gap-0.5 px-2 pb-1 text-xs sm:px-3"
role="alert"
>
{failures.map((attachment) => (
@@ -155,7 +156,7 @@ export default function AttachmentChipList({
{unreadable.length > 0 && (
<div
className="flex flex-col gap-0.5 px-2 pb-1 text-xs text-[#B54708] sm:px-3 dark:text-[#FDB022]"
className="text-warning flex flex-col gap-0.5 px-2 pb-1 text-xs sm:px-3"
role="status"
>
{unreadable.map((attachment) => (
@@ -29,39 +29,33 @@ export default function MicButton({
}: MicButtonProps) {
const voiceButtonLabel = getVoiceButtonLabel(recordingState);
const voiceButtonText = getVoiceButtonText(recordingState);
const isRecording = recordingState === 'recording';
return (
<Button
type="button"
variant="outline"
variant={isRecording ? 'destructive-outline' : 'outline'}
size="sm"
shape="pill"
onClick={onClick}
aria-label={voiceButtonLabel}
title={voiceButtonLabel}
disabled={loading || recordingState === 'transcribing'}
className={`xs:px-3 xs:py-1.5 dark:border-border flex h-auto items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors ${
recordingState === 'recording'
? 'border-[#B42318] bg-[#FEE4E2] text-[#B42318] dark:bg-[#4A2323]'
: 'border-border dark:hover:bg-accent hover:bg-gray-100'
} ${
loading || recordingState === 'transcribing'
? 'cursor-not-allowed opacity-60'
: ''
}`}
className="justify-start"
>
{recordingState === 'transcribing' ? (
<LoaderCircle className="mr-1 h-3.5 w-3.5 animate-spin sm:mr-1.5 sm:h-4 sm:w-4" />
) : recordingState === 'recording' ? (
<Square className="mr-1 h-3.5 w-3.5 fill-current sm:mr-1.5 sm:h-4 sm:w-4" />
<LoaderCircle className="size-3.5 animate-spin sm:size-4" />
) : isRecording ? (
<Square className="size-3.5 fill-current sm:size-4" />
) : (
<Mic className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4" />
<Mic className="size-3.5 sm:size-4" />
)}
<span
className={`xs:text-xs dark:text-foreground text-xs font-medium sm:text-sm ${
recordingState === 'recording'
? 'text-[#B42318]'
: 'text-muted-foreground'
}`}
className={
isRecording
? 'text-xs sm:text-sm'
: 'text-muted-foreground dark:text-foreground text-xs sm:text-sm'
}
>
{voiceButtonText}
</span>
@@ -51,7 +51,8 @@ export default function SourcesTrigger({
type="button"
variant="outline"
size="sm"
className="xs:px-3 xs:py-1.5 dark:border-border border-border hover:bg-accent dark:hover:bg-muted flex h-auto max-w-[130px] items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors sm:max-w-[150px]"
shape="pill"
className="max-w-[130px] justify-start sm:max-w-[150px]"
title={
selectedDocs && selectedDocs.length > 0
? selectedDocs.map((doc) => doc.name).join(', ')
@@ -61,9 +62,9 @@ export default function SourcesTrigger({
<img
src={SourceIcon}
alt="Sources"
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4 dark:invert"
/>
<span className="xs:text-xs dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs font-medium sm:text-sm">
<span className="dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs sm:text-sm">
{selectedDocs && selectedDocs.length > 0
? selectedDocs.length === 1
? selectedDocs[0].name
@@ -57,14 +57,15 @@ export default function ToolsTrigger({
type="button"
variant="outline"
size="sm"
className="xs:px-3 xs:py-1.5 xs:max-w-[150px] dark:border-border border-border hover:bg-muted dark:hover:bg-muted flex h-auto max-w-[130px] items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors"
shape="pill"
className="max-w-[130px] justify-start"
>
<img
src={ToolIcon}
alt="Tools"
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4 dark:invert"
/>
<span className="xs:text-xs dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs font-medium sm:text-sm">
<span className="dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs sm:text-sm">
{t('settings.tools.label')}
</span>
</Button>
+10 -9
View File
@@ -443,8 +443,9 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => e.stopPropagation()}
className="dark:hover:bg-muted h-[35px] w-6 shrink-0 p-0 hover:bg-[#EBEBEB]"
className="h-[35px] w-6 shrink-0"
aria-label={t('settings.sources.menuAlt')}
>
<img
@@ -629,11 +630,12 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
}
}}
placeholder={t('settings.sources.searchFiles')}
className={`h-[38px] px-4 py-2 ${searchQuery ? 'rounded-t-3xl' : 'rounded-3xl'}`}
shape="pill"
className="h-[38px]"
/>
{searchQuery && (
<div className="border-border bg-card dark:border-border dark:bg-card absolute top-full right-0 left-0 z-20 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-xl border border-t-0 shadow-lg transition-all duration-200">
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-xl border shadow-md transition-all duration-200">
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto overscroll-contain">
{searchResults.length === 0 ? (
<div className="text-muted-foreground py-2 text-center text-sm">
@@ -676,8 +678,9 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
<Button
type="button"
variant="outline"
size="icon-sm"
className="text-muted-foreground mr-3 h-[29px] w-[29px] rounded-full p-2 dark:border-0"
size="icon-xs"
shape="pill"
className="mr-3"
onClick={handleBackNavigation}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
@@ -692,9 +695,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
<span className="text-muted-foreground mx-1 shrink-0">/</span>
{currentPath.map((dir, index) => (
<React.Fragment key={index}>
<span className="dark:text-foreground wrap-break-word text-gray-700">
{dir}
</span>
<span className="text-foreground wrap-break-word">{dir}</span>
{index < currentPath.length - 1 && (
<span className="text-muted-foreground mx-1 shrink-0">
/
@@ -707,7 +708,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
{selectedFile && (
<>
<span className="text-muted-foreground mx-1 shrink-0">/</span>
<span className="dark:text-foreground wrap-break-word text-gray-700">
<span className="text-foreground wrap-break-word">
{selectedFile.name}
</span>
</>
@@ -0,0 +1,49 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from './accordion';
function renderAccordion(): string {
return renderToStaticMarkup(
<Accordion type="single" collapsible>
<AccordionItem value="audit">
<AccordionTrigger>Recent activity</AccordionTrigger>
<AccordionContent>Rows</AccordionContent>
</AccordionItem>
</Accordion>,
);
}
/** The class attribute of the element carrying the given data-slot. */
function slotClasses(html: string, slot: string): string {
const tag = new RegExp(`<[^>]*data-slot="${slot}"[^>]*>`).exec(html)?.[0];
return /class="([^"]*)"/.exec(tag ?? '')?.[1] ?? '';
}
describe('AccordionTrigger', () => {
it('carries its own inset and section-heading type', () => {
const classes = slotClasses(renderAccordion(), 'accordion-trigger');
expect(classes).toContain('px-4');
expect(classes).toContain('py-3');
expect(classes).toContain('text-sm');
expect(classes).toContain('font-medium');
});
it('draws the focus ring inside the item, which clips outside it', () => {
const classes = slotClasses(renderAccordion(), 'accordion-trigger');
expect(classes).toContain('focus-visible:ring-3');
expect(classes).toContain('focus-visible:ring-inset');
expect(classes).not.toContain('focus-visible:ring-2');
});
it('keeps the chevron in muted-foreground in dark mode', () => {
const html = renderAccordion();
expect(html).toContain('text-muted-foreground');
expect(html).not.toContain('dark:invert');
});
});
+2 -2
View File
@@ -33,14 +33,14 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:ring-ring/50 flex flex-1 items-center justify-between gap-4 text-left transition-all outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
'focus-visible:ring-ring/50 flex flex-1 items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium transition-all outline-none focus-visible:ring-3 focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon
className="text-muted-foreground h-5 w-5 shrink-0 transition-transform duration-200 dark:invert"
className="text-muted-foreground h-5 w-5 shrink-0 transition-transform duration-200"
aria-hidden="true"
/>
</AccordionPrimitive.Trigger>
+55
View File
@@ -0,0 +1,55 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Alert, AlertDescription } from './alert';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (element: React.ReactElement) => {
await act(async () => root.render(element));
};
const alertRole = () => container.firstElementChild!.getAttribute('role');
describe('Alert', () => {
it.each(['default', 'destructive', 'warning', 'info'] as const)(
'announces %s assertively',
async (variant) => {
await render(
<Alert variant={variant}>
<AlertDescription>Notice</AlertDescription>
</Alert>,
);
expect(alertRole()).toBe('alert');
},
);
it('announces success politely', async () => {
await render(
<Alert variant="success">
<AlertDescription>Done</AlertDescription>
</Alert>,
);
expect(alertRole()).toBe('status');
});
it('lets the caller override the role', async () => {
await render(<Alert variant="destructive" role="status" />);
expect(alertRole()).toBe('status');
});
});
+8 -2
View File
@@ -10,7 +10,12 @@ const alertVariants = cva(
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
'border-destructive/50 bg-destructive/10 text-destructive [&>svg]:text-destructive',
success:
'border-success/50 bg-success/10 text-success [&>svg]:text-success',
warning:
'border-warning/50 bg-warning/10 text-warning [&>svg]:text-warning',
info: 'border-info/50 bg-info/10 text-info [&>svg]:text-info',
},
},
defaultVariants: {
@@ -25,7 +30,8 @@ const Alert = React.forwardRef<
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
// A success notice confirms rather than interrupts, so it is polite.
role={variant === 'success' ? 'status' : 'alert'}
className={cn(alertVariants({ variant }), className)}
{...props}
/>
@@ -0,0 +1,34 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { Avatar, avatarVariants } from './avatar';
describe('Avatar variants', () => {
it('adds no sizing or radius unless asked, so image avatars are unchanged', () => {
const html = renderToStaticMarkup(<Avatar src="/a.png" />);
expect(html).toContain('class="shrink-0"');
});
it('sizes and shapes the initials box', () => {
const classes = avatarVariants({
size: 'lg',
shape: 'circle',
variant: 'primary',
});
expect(classes).toContain('size-9');
expect(classes).toContain('rounded-full');
expect(classes).toContain('bg-primary/10');
expect(classes).toContain('items-center');
});
it('renders children instead of the image when given', () => {
const html = renderToStaticMarkup(
<Avatar size="sm" shape="square" variant="muted">
LK
</Avatar>,
);
expect(html).toContain('LK');
expect(html).not.toContain('<img');
expect(html).toContain('rounded-md');
});
});
+43 -3
View File
@@ -1,10 +1,41 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import Robot from '../../assets/robot.svg';
import { cn } from '@/lib/utils';
type AvatarProps = {
const avatarVariants = cva('shrink-0', {
variants: {
// `none` keeps the historical behaviour where the image sets the size.
size: {
none: '',
sm: 'flex size-7 items-center justify-center overflow-hidden text-xs',
default:
'flex size-8 items-center justify-center overflow-hidden text-sm',
lg: 'flex size-9 items-center justify-center overflow-hidden text-sm',
xl: 'flex size-10 items-center justify-center overflow-hidden text-base',
},
shape: {
none: '',
circle: 'rounded-full',
square: 'rounded-md',
},
// Background for initials or icon fallbacks rendered via `children`.
variant: {
default: '',
primary: 'bg-primary/10 text-primary font-medium dark:bg-primary/20',
muted: 'bg-muted-foreground/15 text-foreground font-medium',
},
},
defaultVariants: {
size: 'none',
shape: 'none',
variant: 'default',
},
});
type AvatarProps = VariantProps<typeof avatarVariants> & {
src?: string | null;
alt?: string;
fallbackSrc?: string;
@@ -20,6 +51,9 @@ function Avatar({
className,
imgClassName,
children,
size = 'none',
shape = 'none',
variant = 'default',
}: AvatarProps) {
const resolvedSrc = src && src.trim() !== '' ? src : fallbackSrc;
const [currentSrc, setCurrentSrc] = useState(resolvedSrc);
@@ -32,7 +66,13 @@ function Avatar({
}, [src, fallbackSrc]);
return (
<div data-slot="avatar" className={cn('shrink-0', className)}>
<div
data-slot="avatar"
data-size={size}
data-shape={shape}
data-variant={variant}
className={cn(avatarVariants({ size, shape, variant }), className)}
>
{children ?? (
<img
src={currentSrc}
@@ -49,4 +89,4 @@ function Avatar({
);
}
export { Avatar };
export { Avatar, avatarVariants };
+39
View File
@@ -0,0 +1,39 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { Badge, badgeVariants } from './badge';
describe('Badge', () => {
it('is a soft brand pill by default', () => {
const html = renderToStaticMarkup(<Badge>Beta</Badge>);
expect(html).toContain('rounded-full');
expect(html).toContain('bg-primary/10');
expect(html).toContain('data-variant="default"');
});
it.each([
['success', 'text-success'],
['warning', 'text-warning'],
['destructive', 'text-destructive'],
['info', 'text-info'],
['neutral', 'text-muted-foreground'],
] as const)('variant %s uses its semantic token', (variant, cls) => {
expect(badgeVariants({ variant })).toContain(cls);
});
it('never uses raw palette colours', () => {
for (const variant of [
'default',
'neutral',
'success',
'warning',
'destructive',
'info',
'outline',
] as const) {
expect(badgeVariants({ variant })).not.toMatch(
/\b(bg|text|border)-(red|green|amber|yellow|blue|gray|purple)-\d+/,
);
}
});
});
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more