This commit is contained in:
Pavel committed 2026-09-26 12:18:05 +04:00
1 parent 0dd379c271
commit f66dc50eed
289 files changed
+17148 -8081

No files matched your search

+4
View File
@@ -207,6 +207,10 @@ vale .
- If shared state must be added, use Redux rather than introducing a new global state library.
- Avoid broad UI refactors unless the task explicitly asks for them.
- Do not re-create components if we already have some in the app.
- Follow `frontend/DESIGN.md`: compose `components/ui/` parts and pick their look with props, use theme tokens, and
keep typography, spacing, radius and motion on its roles.
- Every user-visible string, attributes included (`aria-label`, `label`, `placeholder`, `title`, `alt`), is a `t()`
key in all seven locales under `frontend/src/locale/` (`de en es jp ru zh zh-TW`). Admin pages stay English.
#### Icons
+576 -118
View File
@@ -14,7 +14,13 @@ 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.
disable comment. Conditional classes go through `cn(...)`, never a template
literal (prettier sorts the classes inside `cn`, and `cn` merges conflicts).
Every user-visible string, attributes included (`aria-label`, IconButton
`label`, `placeholder`, `title`, `alt`, `hint`), is a `t()` key in all seven
locales (`de en es jp ru zh zh-TW`); admin pages are English by design.
Interpolated user text (a name, a timezone, a date) passes `interpolation: {
escapeValue: false }`, or `/` renders as `/`.
## Colour tokens
@@ -30,7 +36,7 @@ through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and
| `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` |
| `border`, `input`, `ring` | dividers and field borders (fields use `border-border`), 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` |
@@ -48,6 +54,12 @@ 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.
The dark `primary` (#8855f1) is set so white text on it reaches 4.55:1 on
every default Button. As text on the dark surfaces it is below 4.5:1 (3.45:1
on background, 3.06:1 on card), so links and `text-primary` in dark pass only
as large or UI text; a fix for that needs its own link token. `ring`,
`secondary` and `sidebar-primary` keep the lighter #976af3.
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.
@@ -60,6 +72,27 @@ 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.
The status set is `success | warning | destructive | info`. `default` is the
component's own base tone (brand on a Badge or Button, quiet on an Alert or
Toast). `neutral` is the grey pill or box.
One tint scale, by purpose:
- Wash `/5`: a whole surface that is selected or receiving a drop (Card
`selected`, Dropzone drag-active and drag-reject). The border carries the
state; the wash only warms the surface. Never on a chip or a text fill.
- Brand soft fill: `bg-secondary text-secondary-foreground` (Badge `default`,
Avatar `primary`, the OptionCard icon square), never `bg-primary/10`.
- Status soft fill: `bg-<role>/10` in both themes (Badge, Alert, ToastHeader,
a danger-zone panel); no `dark:` twin.
- Status border: `border-<role>/50`.
- Tinted hover on a row that is already accent: `/15` light, `/20` dark
(`ghost-on-accent`, `ghost-destructive-on-accent`, a destructive menu item).
- Neutral hover: solid `bg-accent` in both themes (ghost buttons, combobox,
SelectTrigger, Card `interactive`, Dropzone, every list-row highlight).
- Quiet panel inside a page or card: `bg-muted`, not `bg-muted/40` or `/60`.
- Dividers: `border-border`, not `border-border/60`.
Patterns:
- Status pill: `<Badge variant="success">`. Status box: `<Alert variant="warning">`.
@@ -76,7 +109,17 @@ Patterns:
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'}`.
`variant={active ? 'secondary' : 'ghost-muted'}`. That is for one thing
switched on or off. Picking one value of several (a 7d / 30d / 90d range,
a schedule's frequency, a filter row) is a `ToggleGroup`, whose on item has
the `outline` look with no hue.
- A brand chip, a small action that opens something an answer produced (an
artifact chip under an answer, a citation pill in its text), is also
`variant="secondary" shape="pill"`: the default size with a lucide icon
first for artifacts, `size="xs"` for citations. It is the same tint as a
pressed toggle; the difference is that a chip never toggles and always
opens or scrolls to something. The citation pill keeps `h-5 min-w-5`
(20px) so it sits in a line of text.
- 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.
@@ -84,6 +127,10 @@ Patterns:
in `currentColor`.
- `white`, `black`, `transparent`, `current` and `inherit` are allowed; use
them only for overlays and imagery, not for text or surfaces.
- Swapping a state's colour for its same-hue token (amber to `warning`, red
to `destructive`) is fine without review; changing the hue of a state the
user sees (an error, a recording state, a selected tab) is a design
decision. Non-state UI follows the token even when its hue shifts.
## Components and their variants
@@ -109,13 +156,39 @@ Patterns:
`foreground/15` (`destructive/15`) tint, which shows on accent where
ghost's accent square would not.
- Icons are `lucide-react` at the default stroke (2). Don't pass
`strokeWidth`, and don't use an `<img>` for a glyph lucide has.
`strokeWidth`, and don't use an `<img>` for a glyph lucide has. The one
exception is a tiny check mark inside a status circle (the custom-model
test result, the artifact and research step ticks), drawn at 2.5 to 3 so
it stays legible at 10 to 12px; raw progress-ring `<svg>`s are not icons.
- Icon size is one `size-N` class (never `h-N w-N`, never lucide's `size`
prop); colour goes on the icon (`text-muted-foreground`), spacing on the
parent (`gap-*`, not `mr-2` on the icon). Use current lucide names
(`TriangleAlert`, `CircleAlert`, `CircleCheck`, `CircleX`, `Trash2`,
`CodeXml`, `Search`), no `as` aliases. Inside a Button the size comes from
the Button: 16px, 14px at `xs`, 20px at `size="icon"`; write a `size-N`
only to differ from it, since any `size-` class switches the default off.
- 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.
- A link inside running text (an artifact link in an answer, a link in
markdown, a hint's "Learn more") is `variant="link" size="inline"`, with
`asChild` around the `<a>`: no height or padding, underlined on hover, the
shared focus ring. Standalone links ("Learn more" with `ExternalLink`, "Go to
Tools" with `ArrowRight`) are the same, the icon a child. Toggles and crumbs
keep a normal size. The base is `text-sm font-medium`, so a link in a 12px
hint passes `text-xs font-normal` (an approved exception), and a link that
must keep the colour of what it sits in (a status Alert, a dark overlay, a
source card's URL row) passes `text-current`. Never style a raw `<a>` as a
link.
- Icon-only buttons are `IconButton` (below), never a `Button` with a
`title`.
- Roles for dangerous and dismissive actions: a delete on a page (a "Danger
zone" card's Delete agent or Revoke, Delete all) is `destructive-outline`;
the submit of a confirm dialog is `destructive` (`ModalActions destructive`,
`ConfirmationModal variant="destructive"`); Cancel is `ghost` at the size and
shape of the button beside it, in a modal footer, a form header or an inline
editor. A Cancel inside a line of text (the composer's queued send) is
`link inline`.
- 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
@@ -125,16 +198,24 @@ Patterns:
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.
`Input` and `SelectTrigger` take `size="field"` too (the same 42px as
Input `default` and SelectTrigger `lg`), so a form column has one name for
one height; prefer `field` in new form rows. With `shape="pill"` its text starts 21px in, like
the Input and Select pills beside it. The agent form's pickers are
- A button or picker that sits in a row of fields is `size="field"`: 38px.
`Input` and `SelectTrigger` take `size="field"` too (the same 38px as
Input `default`), so a form column has one name for one height. Page
actions beside a page's search field (Add Source, Add Tool, Test
retrieval, Sync) are `size="field" shape="pill"` too, with no min-width
or hand 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.
with `asChild` around a `<Link>` for navigation rows, the label in a
`truncate` span. A row that also holds buttons (an agent's pin, a
conversation's menu, rename's Save / Cancel) puts the link and the buttons
side by side in a `group relative` wrapper, never buttons inside the link;
the link keeps its fill while the pointer is on a sibling with
`group-hover:bg-sidebar-accent`. The section sidebar's "Back to app" row is
the same variant.
- 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
@@ -165,7 +246,10 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
- Drop `text-white` on default and destructive buttons; the foreground token
already provides it. Drop `disabled:cursor-not-allowed` on buttons; the
base disables pointer events. Fields are the other way round (see Focus,
elevation and stacking: "Disabled").
elevation and stacking: "Disabled"). A disabled primary button just fades
(`disabled:opacity-50`); never hand-roll a grey disabled state.
- A text button that removes something (Remove on a row) is
`ghost-destructive size="xs"`: grey at rest, red on hover.
- A button that is busy (saving, creating, testing) takes `loading`: it
disables itself, sets `aria-busy`, and draws a 16px spinner over the label,
which stays in the layout (invisible) so the width doesn't jump. Keep the
@@ -173,25 +257,88 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
"Saving…", or pin a fixed width to stop the jump. The one exception is a
button whose busy state says something the user needs: a progress figure
(a connector's Sync shows "42%") or a mode (the composer's Voice button shows
"Transcribing"). It keeps its busy label with a spinning mark where its
icon was: Sync uses `Spinner size="sm"` at icon size (`size-4`); Voice still
spins lucide's `LoaderCircle`.
"Transcribing"). It keeps its busy label with a `Spinner size="xs"` (the
16px icon step, with a `label` so it doesn't announce "Loading") where its
icon was.
### 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) |
| `tone` | `default`, `destructive` (the status soft fill and border, `border-destructive/50 bg-destructive/10`, over any variant) |
| `padding` | `none`, `sm` (p-3, row boxes and code blocks), `default` (p-4), `lg` (p-6, tiles, chart panels and stat tiles) |
| `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.
`gap-*` on `Card`. Children are spaced by Card's `gap-3`, so they carry no
`mt-*`. Replaces every hand-rolled
`rounded-(md|lg|xl|2xl|3xl|4xl) border bg-(card|muted) p-*` box:
- Tiles (sources, tools, custom models, agents, folders) are `filled`, `lg`
(a folder row `default`), `interactive` only when a click navigates (a
draft agent is not). The Add tool picker tiles sit on the modal's card, so
they are `outline interactive lg` with `asChild` around a `<button>`.
- Chart panels are `subtle lg` with fixed heights passed as layout; a chart
panel inside a modal is `outline` (the modal is already `bg-card`).
- Row boxes inside a form or panel (a guardrail check, the schedule's
timezone box, the discovered MCP tools) are `padding="sm"`.
- A danger zone (Delete agent, Revoke a device) and a failing stat are
`tone="destructive"`; the title beside it is `SectionHeader
tone="destructive"`. Muted text fails AA on the red fill, so the tone turns
every `text-muted-foreground` inside it to `foreground`; don't pass a
lighter colour back. Icon buttons inside a destructive-tone row are
`ghost-destructive-on-accent`, whose red tint shows on the fill where
ghost's grey square would not.
- A card on the page background that must not look raised (the shared
agent card) is `subtle lg`. Cards never take a shadow.
Stat tiles are `components/StatCard.tsx`, never a hand-rolled Card: `label`,
`value` (24px bold `tabular-nums`), `sub` (a 12px muted line or link),
`hint` (a native `title` with the help cursor), `variant` (`subtle` on a
page, `outline` inside a modal), `tone="destructive"` for a failing figure,
`valueTone` (`destructive | warning | info | muted`) to colour the figure by
meaning, and `loading` for a figure-sized Skeleton.
Code blocks (a run's output, an error trace, a token or command to copy) are
a `<pre className="font-mono text-xs whitespace-pre-wrap wrap-break-word">`
in a Card picked by the surface underneath: `filled padding="sm"` (a muted
well) on a card or background surface, `subtle padding="sm"` on a muted one
(the tool-approval card, an expanded log row). A copy row is the same Card
with `className="flex-row items-start gap-2"` and the `CopyButton` inside.
Inside an Alert or a trace's tool panel, and in a full-pane viewer, the
`<pre>` takes the recipe with no Card, so boxes don't nest. Put scroll caps
(`max-h-* overflow-y-auto`) on the Card; never `break-all`.
Tile text: the name is `CardTitle` (14px semibold from Card's `text-sm`; pass
`as="h2"` on a page that goes from its title straight to a tile grid, but
never a heading inside a clickable tile, whose children a button flattens), the
description `CardDescription size="xs"` (12px muted, `leading-relaxed`), and
meta lines (a date, a token count, a model id or host) go in `CardFooter` at
regular weight. `font-medium` is for list rows (`ListRow`), not tiles.
### SectionHeader (`ui/section-header.tsx`)
`<SectionHeader as title description actions size tone>`. `size`: `default`
(18px `text-lg font-semibold`, a section title on a page or panel), `sm` (the
eyebrow, `text-muted-foreground text-xs font-semibold uppercase
tracking-wider`, a label set in caps above a group or a table head), `xs`
(14px `text-sm font-semibold`, a sub-heading inside a panel, drawer or modal).
`tone="destructive"` for a danger zone only. `as` is the level in the outline
(`h2` default). The spacing below belongs to the parent's `gap-*`, never an
`mb-*` on the heading. Not for dialog and sheet titles (their own title) or a
page byline (a muted `text-sm` paragraph).
A title with controls beside it (Add, a filter Select, Import spec) passes
them as `actions`, never a hand-rolled `flex justify-between` row around the
header: the row centres the title on the buttons and wraps them under it on
a phone (`flex-wrap items-center gap-x-4 gap-y-2`). Panel and chart titles
are `size="xs"` with `as="h3"`; a title row that also holds a legend or a
"Resets" line keeps its wrapper and swaps only the heading. Disclosure
headers (`Button variant="section-toggle"`) and the Agents breadcrumb are not
SectionHeaders.
### OptionCard (`ui/option-card.tsx`)
@@ -209,30 +356,36 @@ modal) leave it out and stay plain buttons. Only layout classes on it.
`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
pill, including schedule and run status pills, trace chips and statuses,
token scope chips, "Disabled" and tool chips. A grey chip is `neutral`, never
a `bg-muted` pill. The admin role is `default` wherever it shows (Teams, Admin
→ Users); every other role is `neutral`. Chips that show code (token scopes) pass `font-mono`, and
stat chips `tabular-nums`, as approved exceptions. HTTP method pills take their variant from
`getMethodBadgeVariant` (`utils/httpMethodColors.ts`): GET `success`, POST
`info`, PUT `warning`, DELETE `destructive`, PATCH `default`, anything else
`neutral`. `MultiSelect` (`ui/multi-select.tsx`) shows its first two picks as
`default` Badges with a remove X, then "+N more", on a `Button
variant="combobox" size="field"` trigger that grows past 42px when the chips
wrap.
variant="combobox" size="field"` trigger that grows past 38px when the chips
wrap, with SelectTrigger's turning chevron; each row in its list shows a
`Checkbox size="sm"`.
### Input (`ui/input.tsx`)
| Prop | Values |
| --------- | ----------------------------------------------------------------------------------------------------- |
| `size` | `sm` (h-8), `default` (h-10.5, the historical 42px), `lg` (h-12), `field` (h-10.5, the form-row name) |
| `shape` | `default`, `pill` |
| `variant` | `default`, `bare` (no border, padding, radius, shadow or ring), `filled` (card fill) |
| Prop | Values |
| --------- | ------------------------------------------------------------------------------------ |
| `size` | `sm` (h-8), `default` (h-9.5, 38px), `lg` (h-12), `field` (h-9.5, the form-row name) |
| `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
allowed on Input and Textarea. `<Input label>` is the shorthand for a
one-field `FormField`: the same floating label on the border, the same
`labelSurface` (`card` default, forms in cards and modals; `background`,
fields straight on a page; `muted`, fields on a muted panel). Never pass a
background class for it. A placeholder under a floating label is an example:
it stays hidden while the label rests inside and shows on focus. 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
default-size pill (38px, 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
@@ -245,11 +398,16 @@ instead of showing the panel through; never pass `bg-card` for it.
### SelectTrigger (`ui/select.tsx`)
`size`: `sm` (32px), `default` (36px), `lg` (42px), `field` (42px, the
form-row name); `variant`: `default`, `ghost`; `shape`: `default`, `pill`.
Pills pad `px-5` at every size but `sm` (`px-3`), so their text starts 21px
in like the Input and Button field pills. The large rounded selects
(`rounded-3xl px-5 py-3`) are `size="lg" shape="pill"`.
`size`: `sm` (32px), `default` (36px), `field` (38px, the form-row
height); `variant`: `default`, `ghost`; `shape`: `default`, `pill`. Pills
pad `px-5` at every size but `sm` (`px-3`), so their text starts 21px in
like the Input and Button field pills. A select in a form is
`size="field"`, labelled by `FormField`. SelectTrigger is `w-fit`; pass
`w-full` in a form column. Fields that take typing or sit beside one are 16px
below `md` (iOS zooms on focus under 16px) and 14px from `md`: Input,
Textarea, SelectTrigger `default | field`, Button `combobox` `default | field |
lg` and CommandInput. The `sm` sizes stay 14px. A highlighted list row is
`bg-accent` in Select, Command and DropdownMenu alike.
### Textarea (`ui/textarea.tsx`)
@@ -263,23 +421,39 @@ variable highlighter and the chunk editor behind its line-number gutter.
### FormField (`ui/form-field.tsx`)
A labelled field is `<FormField label required hint error disabled>` with
the field as its only child. It stacks `Label`, the field, a muted `text-xs`
hint and a red `text-xs` error (`role="alert"`) 6px apart (`gap-1.5`), puts
the required star 4px after the label (`aria-hidden`; the field gets
`aria-required`, not native `required`), and dims the label while
`disabled`. `Input`, `Textarea`, `SelectTrigger` (even nested in `Select`),
`MultiSelect` and `Checkbox` read their `id`, `aria-invalid`, `aria-describedby`,
Every boxed form control is labelled by a floating label:
`<FormField label required hint error disabled labelSurface>` with the field
as its only child. The label sits on the field's border (12px muted,
`labelSurface` `card` | `background` | `muted` to match the surface behind
the field, so the notch hides the line). It rests inside an empty, unfocused
`Input` or `Textarea` and moves up on focus; on a `SelectTrigger`, combobox,
`MultiSelect`, number field or `Dropzone` it stays on the border. It turns
red (`text-destructive`) with an `error`, and dims while `disabled`. Under
the field come a muted `text-xs` hint and a red `text-xs` error
(`role="alert"`), 6px apart. The required star follows the label
(`aria-hidden`; the field gets `aria-required`, not native `required`). A
placeholder is an example: hidden while the label rests, shown on focus.
Stack floating fields with `gap-5` so each label clears the field above.
`Input`, `Textarea`, `SelectTrigger` (even nested in `Select`), `MultiSelect`,
`Dropzone` and `Checkbox` read their `id`, `aria-invalid`, `aria-describedby`,
`aria-required` and `disabled` from it, so don't set those by hand; an id
the field already has wins. Any other control: pass `id` to FormField and
the same id to the control. One FormField holds one control: a second
field inside it (an "Add ref" box under a picker) needs its own `id`, or it
takes the field's. Popovers reset the wiring (`FormFieldBoundary`), so a
picker's search box is safe. There is one size: labels are `text-sm`
everywhere, also in dense panels (Guardrails). `className` is layout only.
Floating-label Inputs (`<Input label>`) label themselves and don't go in a
FormField. Never hand-build a `<label className="mb-2 block …">` or a
`div.flex-col` + `Label` + `<p>` stack.
the same id to the control. One FormField holds one control: a second field
inside it needs its own `id`, or it takes the field's. Popovers reset the
wiring (`FormFieldBoundary`), so a picker's search box is safe.
`className` is layout only. `<Input label>` is the one-field shorthand; never
put it inside a FormField (two labels).
`float={false}` puts the label above instead (14px medium, 6px up), for a
FormField with no single box to sit on: a list of checkboxes, several
controls in a row, a loading or error line in place of the field. Controls
whose label sits beside them (Switch, Checkbox, radio) use `SettingRow` or
an inline `Label`, not FormField. An editing surface that fills its area
(the chat composer, the chunk and wiki editors, editing a sent question) and
repeated rows in a list (workflow expressions) have no visible label and
must have an `aria-label`. Never hand-build a label above a field, a
hand-positioned floating label, or a `div.flex-col` + `Label` + `<p>` stack.
### SettingRow (`ui/setting-row.tsx`)
@@ -303,19 +477,79 @@ native boxes take the OS accent and ignore dark mode. `size`: `default`
Name it with a `Label htmlFor` or `aria-label`. For an on/off setting with a
description use a Switch in a SettingRow instead.
### Tooltip (`ui/tooltip.tsx`)
### Tooltip (`ui/tooltip.tsx`) and IconButton (`ui/icon-button.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.
Every icon-only button is an `IconButton`: `<IconButton label icon hint?
side? variant size />`. `label` is the accessible name and the tooltip text;
`hint` replaces the tooltip text when it says more than the name ("Undo
(Ctrl+Z)"); `icon` is a lucide icon, rendered `aria-hidden`, or pass
`children` for a swapping or custom glyph. It never sets `title`. Tooltip
side: `bottom` for buttons in a header or toolbar at the top of a page, panel
or dialog; `right` on the collapsed sidebar rail, where a top tooltip would
cover the button above; the default `top` everywhere else (under an answer,
in the composer, in rows). Toast close and collapse buttons are plain `Button`s with
an `aria-label` and no tooltip.
Tooltips open after 400ms. One `TooltipProvider` is mounted in `main.tsx`,
so moving along a row of icon buttons opens each at once after the first
(Radix's skip-delay); a `Tooltip` outside it (tests, a portal root) adds its
own provider. For a hint on something that is not an icon button, compose
`Tooltip` + `TooltipTrigger asChild` + `TooltipContent`. `title=` stays only
on truncating text (`span`, `p`, `div`), where it shows the full name.
### ToggleGroup (`ui/toggle-group.tsx`)
The segmented control for picking one value of several (`type="single"`) or
several of several (`type="multiple"`).
Items are pills: the on item is the `outline` look (`bg-background`, border,
`shadow-xs`), the others `ghost-muted`. `size` is `sm` (32px, the default) or
`xs` (28px, inside a muted track: a wrapper `<div className="bg-muted
rounded-full p-1">` around the group, whose own className takes layout only). A single group is a `radiogroup` with one Tab
stop and arrow keys; it sends `""` when the on item is clicked again, so
ignore that in `onValueChange` (`(v) => v && setRange(v)`). Route links in a
row (the Agents filter pills) are not a ToggleGroup: they are `Button asChild
variant={active ? 'outline' : 'ghost-muted'} size="sm" shape="pill"` around
each `Link`, with `aria-current="page"` on the current one.
### Separator (`ui/separator.tsx`)
A 1px `bg-border` rule, horizontal or `orientation="vertical"`, decorative
(`role="none"`) unless `decorative={false}`. Pass margins and width only.
Replaces every `<hr>` and every `border-b` or `h-px` div that is only a line.
### 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. `Skeleton` is a pulsing muted
block sized with layout classes and rounded as needed. Never put one inside
an element that pulses itself; the two animations multiply.
`Spinner size="xs | sm | default | lg"` (16, 20, 28, 40px) draws in
`currentColor`, so colour it with a `text-*` token on it or its parent. `xs`
is the icon-sized step: a busy Button, a step's status in a Preview or
research row. Never shrink a larger size with `className="size-*"`, and don't
use lucide `LoaderCircle` (formerly `Loader2`) or a hand-drawn SVG as a loader. `Skeleton` is a pulsing
muted block sized with layout classes; its default radius (`rounded-sm`, 6px)
is the bar radius, so pass none (`rounded-full` for an avatar or switch
stand-in). Bars inside a muted surface (a `Card variant="filled"` tile) take
`surface="muted"` (`bg-muted-foreground/20`), since `bg-muted` would vanish
there. A tile's loading mirror is the same `Card` as the tile (variant,
padding, height) with Skeleton bars laid out like its content; the Card
itself never pulses, only the bars do. Never put a Skeleton inside an
element that pulses itself; the two animations multiply.
### LoadingState and EmptyState (`ui/loading-state.tsx`, `ui/empty-state.tsx`)
A page, panel or dialog that is still loading shows `LoadingState`, never a
hand-centred `Spinner`. `fill`: `parent` (`h-full`, needs a parent with a
height: the artifact panel, a drawer body), `screen` (`h-screen`, the app and
admin guards) or `block` (`py-10`, a page section or dialog body with no
height of its own). `label` puts a muted caption under the ring (a long job:
Convert to wiki, Enable GraphRAG); it is also the ring's name. Spinners
inside a control (a busy Button, a picker's `sm` ring) stay `Spinner`.
Nothing to show is `EmptyState`: `size` `default | sm | xs` (128 / 96 / 64px
art, page / panel / popover), `illustration` `no-files | none` (a "no
results" line is `size="xs" illustration="none"`), `title`, `description`
(plain `muted-foreground`), `action`. A page or panel whose fetch failed is
`EmptyState tone="destructive" illustration="none"` with a `Retry` action
(`t('retry')`): a red `CircleAlert`, a red title, `role="alert"`. Never a bare
`text-destructive` paragraph.
### Progress (`ui/progress.tsx`)
@@ -325,9 +559,10 @@ width-percent divs in quota, indexing and guardrail views.
### Avatar (`ui/avatar.tsx`)
`size`: `none` (image decides, the default), `sm`, `default`, `lg`, `xl`;
`size`: `none` (image decides, the default), `xs` (28px), `sm` (32px),
`default` (36px), `lg` (40px): Button's names at Button's heights;
`shape`: `none`, `circle`, `square`; `variant`: `default`, `primary` (brand
initials), `muted` (grey initials). Initials boxes pass the letters as
initials on `secondary`), `muted` (grey initials). Initials boxes pass the letters as
children.
### Dropzone (`ui/dropzone.tsx`)
@@ -335,15 +570,20 @@ children.
`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.
state through `data-drag-active` and `data-drag-reject` (the `/5` wash);
it hovers to solid `accent`. Every drop
target renders it, including `components/FileUpload.tsx` (which keeps its
preview and validation logic) and the Import agent / Import API
specification dialogs; don't hand-roll a `border-2 border-dashed` target
around `useDropzone`.
### 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
Feedback on anything the user did outside a modal (uploads, runs,
approvals, team events, a page action's result) is a toast in the
bottom-right stack; a result inside an open modal is an `Alert` there (see
"Where a message lives"), since the toast stack paints under the modal's
overlay. 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
@@ -371,15 +611,53 @@ 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.
### A clickable card that holds a link
A card that opens something and also holds a link (a source card under an
answer, with its URL) is a plain `relative` container: a `<button
type="button">` inside it covers the card with `after:absolute after:inset-0`
(the card's radius on `after:`), and the link is a sibling after it with
`relative z-10`, so both are real controls, one tab stop each, and neither
sits inside the other. The container draws the focus ring for the button
(`has-[>button:focus-visible]:ring-3 …ring-ring/50`). Never put a link inside
a `role="button"` or a `<button>`.
### Where a message lives: FormField, Alert or Toast
- A message about one control is `FormField error` (it also turns the
floating label red).
- A result the user must read before closing the modal (a failed share, a
failed import, a Test connection result) is an `Alert` in the modal body.
- A fire-and-forget result, or any result on a page rather than in a modal,
is a toast (`showActionToast`).
- A page or panel that failed to load its own content is
`EmptyState tone="destructive"` in place of that content, with `Retry`.
- A notice the user must read before acting (an expiring token, a policy that
forces a setting, models without a price) is an `Alert`; one that is only
informative and should not be announced passes `role="note"`.
- Status text of a sentence or more is an `Alert` with a lucide icon, never a
coloured paragraph. A long notice about an old run inside a collapsed panel
is `Alert role="status"`, not `alert`.
- An action's error on a full-page form (saving an agent) is an `Alert
variant="destructive"` above the form, not text in or beside the button.
- A list of errors the user must read on a canvas or page (the workflow's
publish validation) is a destructive `Alert` floating at `z-20` on an opaque
`bg-card rounded-xl shadow-md` wrapper that stays until closed; a toast
would truncate each error to one line and dismiss itself.
### 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
Inline notice inside a form, modal or panel (see above). It is never a
hand-rolled `rounded-lg bg-<role>/10` box.
`variant`: `default`, `neutral` (the same quiet box, by name: a guardrail
"not evaluated" outcome), `success`, `warning`, `info`, `destructive`. 14px
corners (`rounded-xl`), like a popover. 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
text-<role>`; `default` sits on `bg-background`. Icon first (a lucide icon,
no classes: the Alert sizes it to 16px and colours it with the text), then
`AlertTitle` and `AlertDescription`. The icon has its own column and sits
centred on the text block, beside a single line, a wrapped paragraph or a
title with its description. 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.
@@ -392,7 +670,59 @@ an ellipsis. Pages pass only its width (`w-[16ch]`, `max-w-[32ch]`) and a
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.
the focus ring. The current crumb is never a disabled button. The path row above a
source's file tree and chunk viewer is `components/tree/PathHeader`
(back button, chevron crumbs, the last capped at `max-w-[32ch]`, actions on
the right); don't hand-roll a `/`-separated path.
### ListRow and DescriptionList (`ui/list-row.tsx`, `ui/description-list.tsx`)
An identity row (avatar or icon square, a truncating title, one muted meta
line, a trailing control) is `ListRow` inside `ListRows` (`divide-y
divide-border`, no box of its own; wrap it in `Card padding="none"` or a
bordered list for one). Rows are `px-4 py-3`, the title `text-sm
font-medium`. `interactive` (with `asChild` around a `<Link>` or `<button>`)
hovers to `bg-accent` and draws an inset focus ring. An icon square in
`leading` is a plain `bg-muted text-muted-foreground size-8 rounded-md` span.
Key/value rows are `DescriptionList` + `DescriptionItem`, never a hand-rolled
`flex` of label and value. `layout="columns"` (default) is an 8rem label
column beside the values (drawers, detail panels); `layout="justified"`
right-aligns the values (a phone card; `columns={2}` for a stats dialog).
`size` `sm | xs`; `mono` on an item for ids and URLs. Values wrap
(`break-words`), they never run past the column.
### Pagination (`ui/pagination.tsx`)
The pager under a table, tile grid or list: "Page N of M" and four chevron
`IconButton`s. `pageSize` adds the Rows per page select; `summary` puts a
count on the left ("1,024 users"). `labels="text"` swaps the chevrons for
Previous / Next buttons. Don't hand-roll a Previous / Next row.
### Page chrome: SectionShell, PageToolbar, SearchInput
Every section page (settings, admin, agents, teams) is wrapped in
`navigation/SectionShell` (`width` `default` 6xl, `wide` 7xl for admin,
`narrow` 5xl; `pills` for the phone destination pills); it draws the title and
starts the content 32px below it, so pages carry no root `mt-*`. The block
under the title is `components/PageToolbar`: `intro`, then the page `search`
(left, `max-w-md`) and the page `action` (right, `Button size="field"
shape="pill"`), then `divider` (a `Separator`). A page search is
`components/SearchInput`: the 38px pill with a search icon and a floating
`label`. The chunk viewer's and file tree's searches stay `CommandInput` in
their frame, because their results are `CommandItem`s.
### Grids
Two recipes, no component. Tiles (sources, tools, custom models, agents):
`grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`; tiles
take the column width, never a fixed `w-[300px]`. Team cards stop at three
columns (`lg:grid-cols-3`): their header row holds an initial, the name, a
role badge and a chevron. Stat rows: `grid grid-cols-2 gap-4 md:grid-cols-4`
(five tiles: `md:grid-cols-3 lg:grid-cols-5`; a dialog: `grid-cols-3`). A
grid inside a Modal keeps its own column counts, because breakpoints follow
the window, not the dialog.
Skeleton mirrors follow the grid they stand in for.
### Accordion (`ui/accordion.tsx`)
@@ -407,12 +737,29 @@ rounded box) on a wrapper around `Accordion`.
rejects imports of it elsewhere. App code uses `Modal` (sizes `sm` to
`full`, `mobileVariant="sheet"`) or `CommandDialog`.
The width comes from `size` only: `sm` 384, `md` 512 (the default), `lg` 672
(a form or a one-column list: Move to folder, Upload), `xl` 896 (a grid of
tiles or a wide editor: Add tool, Test retrieval, the prompt editor), `full`.
Never add a width or height class. The dialog caps itself at `85dvh` and
its body is the one scroller, so the header and footer stay put; don't cap
the body with `contentClassName`. `contentClassName="overflow-visible"`
(`!overflow-visible`) is only for a body whose popover must escape it (the
prompt editor, Share conversation, Move to folder). `className` is placement
only.
A modal's body is `flex flex-col gap-5` when it stacks floating fields,
with `gap-6` between labelled groups (a `SectionHeader size="xs"` and its
fields); FormField supplies the 6px to its hint. The body carries no padding:
Modal's scroll area already insets it (`px-1 pt-3 pb-0.5`) so focus rings
aren't clipped. Never `space-y-*` on a body.
A modal's heading is its `title` (20px, `text-xl leading-tight
font-semibold`) and `description` (muted `text-sm`, 8px under the title).
Don't pass `hideTitle` to draw your own `<h2>`; `hideTitle` is only for
dialogs whose top line is not a title (MoveToFolderModal's breadcrumb,
Upload's step headings, ScheduleFormModal's editable name, the search
palette).
dialogs whose top line is not a title (Upload's step headings,
ScheduleFormModal's editable name, the search palette). A step heading
under a Back button uses the title's classes (`text-xl leading-tight
font-semibold`), not a larger size.
Buttons go in `footer`, never in `children`. The footer stacks full width on
phones (primary on top) and sits in a right-aligned row from `sm` up. The
@@ -430,13 +777,17 @@ 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`). `CommandDialog` sits on
`DialogContent`, which has Modal's surface (`bg-card rounded-2xl
shadow-modal`, no border) and the same blurred overlay.
shadow-modal`, no border, `p-8`, a 20px title, a `gap-3` footer, a left-aligned
header, and the `flex-col max-h-[85dvh]` column whose body the caller makes the
scroller) and the same blurred overlay. Modal, DialogContent and every Sheet share one scrim,
`overlayScrim` in `lib/utils.ts` (`bg-black/25 backdrop-blur-xs
dark:bg-black/50`).
Every phone bottom sheet has one shape: `bg-card`, 16px top corners, no top
border, at most 90% of the viewport, and bottom padding that clears the iPhone
home indicator. `SheetContent side="bottom"` gives it with `pb-safe-0` (the
bare inset); pass `handle` for the grab bar, and `showCloseButton={false}`
when the handle is the only dismiss affordance besides the overlay.
bare inset); pass `handle` for the grab bar, which also hides the X (the handle and
the overlay dismiss it; pass `showCloseButton` to keep one).
`Modal mobileVariant="sheet"` shares the shape and the `SheetHandle`, with
`pb-safe` (the inset, at least 1rem) under its footer. `pb-safe` and
`pb-safe-0` are the `index.css` utilities for `env(safe-area-inset-bottom)`;
@@ -462,13 +813,25 @@ this menu from `DropdownMenu`, and don't declare a local option type.
### Table, Label, dialog text
Every table is `ui/table`; never a raw `<table>` or a table utility class (the
chat's markdown tables are the one exception). The header row is dense by
default (`px-2 py-1 text-sm font-normal text-foreground`, 28px, on
`TableHead`'s sticky `bg-muted` strip). A `TableRow` hovers (`bg-accent`,
pointer) only when it has an `onClick`; a read-only row does not. Use
`TableContainer` for the bordered, scrolling frame; a table that already sits
in a frame renders `Table` alone. `Table` keeps a 600px minimum so wide
tables scroll sideways; a narrow one inside a panel passes
`minWidth="min-w-0"`. A fixed icon column is `width="50px" align="center"`.
`TableCell` and `TableHeader` accept typography and alignment classes and
`text-muted-foreground`. `Label`, `DialogTitle`, `DialogDescription`,
`text-muted-foreground` (RunLog's headers pass the eyebrow). `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.
`PopoverContent`, `SheetContent`, `SheetHeader`, `SheetFooter`,
`DropdownMenuContent`, `DropdownMenuSubContent`, `SelectContent`,
`TooltipContent` and `Modal` accept `p-0` for edge-to-edge content; Modal
merges it after its own `p-8`, so no `!` is needed. A modal that needs a
full-bleed band under its title keeps the padding and bleeds the band out
with negative margin (Move to folder's breadcrumb band). Everything else on these components is layout only.
`MessageScrollerViewport` and `MessageScrollerContent` accept layout and
spacing: the primitive measures Content's padding-block for its scroll and
spacer math, and the Viewport's top gap must scroll with the messages, so
@@ -483,6 +846,79 @@ values (`h-[calc(100dvh-64px)]`, `max-w-[520px]`) and motion values
padding, colours and radii in brackets are not; pick the nearest scale step
or add a token.
### Typography roles
- **Page title**: `SectionPageHeader` (24px bold); the only other text at
that size is a stat figure (`StatCard`).
- **Title** of a dialog, sheet, drawer header or detail page: `text-xl
leading-tight font-semibold`. `DialogTitle` and `SheetTitle` default to it;
`font-bold` is never a title weight. A picker popover's header is a
sub-heading, not a title.
- **Section title**: `SectionHeader` (18px). **Sub-heading** inside a panel,
drawer or modal: `SectionHeader size="xs"` (14px semibold). **Eyebrow**
(anything set in caps): `SectionHeader size="sm"`; never `uppercase` on a
value (a variable name, a mode, an agent type).
- **Tile text**: `CardTitle` + `CardDescription size="xs"` (see Card).
- **Hint** under a control: FormField's `hint` (12px muted, 6px under the
field); a control with no FormField writes the same line as
`text-muted-foreground mt-1.5 text-xs`. Hints are muted, never a status
colour; a warning that needs attention is an `Alert`. **Description** under
a heading: `text-sm text-muted-foreground`.
- **Markdown**: every renderer spreads `markdownHeadings` from
`lib/markdown.tsx` (h1 20px, h2 18px, h3 16px, semibold, `mt-4|3 mb-2`);
don't declare a local heading map.
- **Mono**: ids, keys and code snippets are `font-mono text-xs`; code fields
(textareas) follow the field size; code blocks follow the Card recipe
(see Card). A preview of text a person or a model wrote (a trace's query
or output) stays proportional at the same size; only what the app
serialised (arguments, results, attributes) is mono. Every stat figure is
`tabular-nums`.
### Rhythm
32px between the page title and the content (`SectionShell`); 24px (`gap-6`)
between sections inside a panel or drawer; 20px (`gap-5`) between floating
fields; 8px (`gap-2`) in a button row, 12px (`gap-3`) in a modal footer. A
two-up field grid is `grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-2`. Stack
with `flex flex-col gap-*`, not `space-y-*` (a child's own margin adds to a
gap, so drop it).
### Radius by role
`rounded-xs` 2px, `rounded-sm` 6px, `rounded-md` 8px, `rounded-lg` 10px,
`rounded-xl` 14px, `rounded-2xl` 18px (from `--radius`); bare `rounded` is a
fixed 4px, so don't use it. Skeleton bars: the default. Tinted icon squares:
`rounded-md` at `size-7|8`, `rounded-xl` at `size-12|14`, `rounded-2xl` at
`size-20`. `rounded-full` only for avatars, status dots and the workflow
palette pills.
## Motion
Transition only the property that changes: `transition-colors` by default,
`transition-transform duration-200` for chevrons,
`transition-[grid-template-rows,opacity] duration-300 ease-out` for
collapsibles, `duration-300 ease-in-out` on the named property for the shell
(sidebar, main column, top buttons); a shadow or ring change is
`transition-shadow`, several at once a bare `transition`. No `transition-all`
(the floating labels in `form-field.tsx` and `input.tsx`, which move and
resize, are the exception) and no `hover:scale`: hover is a fill, border or
text-colour change.
New entrances are `animate-in fade-in duration-200 motion-reduce:animate-none`
(`SectionRail`). Two chat timings are decided exceptions: the disclosure fade
`animate-in fade-in duration-160 ease-out` and the answer bubble `animate-in
fade-in slide-in-from-bottom-1.5 duration-260 ease-out`, both with
`motion-reduce:animate-none`. `ui/` overlays keep their own enter and exit.
## Breakpoints
Phone / desktop is `lg` (1024px) in classes and `isDesktop` in JS
(`useMediaQuery`); `sm` and `md` only reflow content. Wide two-column layouts
that need more room (Analytics' chart rows, the agent form beside its preview)
go two-up at `xl`. No custom breakpoints (`min-[…]`, `max-[…]`,
`[@media(…)]`). The workflow builder needs `lg`; below it `MobileBlocker`
shows.
## Focus, elevation and stacking
- **Focus ring**: `focus-visible:ring-3 focus-visible:ring-ring/50` plus
@@ -501,14 +937,25 @@ or add a token.
- **Elevation**: cards have no shadow; fields and outline buttons
`shadow-xs`; popovers, menus and select lists `shadow-md`; sheets
`shadow-lg`; modals (Modal and DialogContent) `shadow-modal`; toasts
`shadow-toast`. The one exception is the Switch thumb, `shadow-lg`: a white
knob on a white card needs the lift to read as raised. Nothing else uses
a shadow class; accordions are panels and have none.
`shadow-toast`. The one exception in `ui/` is the Switch thumb, `bg-white
shadow-lg` in both themes: the knob is white on any track, and a white knob
on a white card needs the lift to read as raised. The off track is
`bg-input`. Accordions are
panels and have none. Outside `ui/` the only shadows are: the workflow
canvas nodes (`shadow-md`, `hover:shadow-lg`: a node lifts off the canvas
while you drag it); the workflow builder's node-settings panel and its
publish-error panel, which float over the canvas at popover elevation
(`shadow-md`, `z-20`); the chunk viewer's and file tree's search result
lists (`shadow-md`, in-page `z-20`, their results are `CommandItem`s inside
the search frame); and the Hero model picker's menu (an Approved
exception). Don't add new ones: a floating panel is a `Popover`,
`DropdownMenu` or `Modal`, which carry their own shadow and stacking; never
an `absolute` div with a shadow and a hand-picked `z-*`.
- **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.
handles); `z-50` overlays, modals, sheets and toasts; `z-200` every
portalled floating list (popover, menu, select, tooltip), so it opens above
a Modal without an override. Do not invent values in between.
## Inline styles
@@ -536,15 +983,30 @@ list stays reviewable.
| `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. |
| `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. |
| `agents/schedules/ScheduleFormModal.tsx` | `shadcn/no-restyle` | The schedule's name is the dialog's editable title: a `bare` Input with title type (`text-xl font-semibold`), so the dialog passes `hideTitle`. One disable. |
| `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. |
| `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, in both themes. Two disables. |
| `Hero.tsx` | `shadcn/no-restyle` | The landing page's demo cards are `Button outline lg pill`, but each is a two-line pill (a title over a clamped 12px query), so it undoes lg's height, the base's one-row layout, weight and nowrap: `h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal`. One disable (phase 7, 36a). |
| `Navigation.tsx`, `conversation/ConversationTile.tsx` | `shadcn/no-restyle` | A sidebar row whose link has sibling buttons (an agent's pin, a conversation's menu and rename Save / Cancel) keeps its fill while the pointer is on a sibling or the menu is open (`group-hover:bg-sidebar-accent`, `bg-sidebar-accent`), and `pr-10` keeps the label clear of the buttons. ConversationTile's `cn(...)` needs a `/* eslint-disable */` … `/* eslint-enable */` pair. |
| `admin/Usage.tsx` | `shadcn/no-restyle` | The Top users id is a `link inline` Button inside a mono table cell; it keeps the cell's type and wraps (`font-mono text-xs font-normal whitespace-normal text-left`). One disable. |
| `agents/workflow/WorkflowBuilder.tsx` | `shadcn/no-restyle` | The "Learn more" links in the Set state and Condition nodes' 12px hints keep the sentence's size and weight (`text-xs font-normal` on `link inline`). Two disables. |
| `components/MessageInput.tsx` | `shadcn/no-restyle` | The queued-send Cancel is a `link inline` inside the composer's 12px status line, so it takes the line's size (`text-xs`). One disable, beside the send button's. |
| `settings/PersonalAccessTokens.tsx` | `shadcn/no-restyle` | Token scope chips are identifiers, so the `neutral` Badge is set in mono (`font-mono`). One disable. |
| `settings/traces/TraceChips.tsx` | `shadcn/no-restyle` | Trace stat chips (durations, counts) use tabular figures so they don't jitter between rows (`tabular-nums` on Badge). One disable. |
| `modals/MCPServerModal.tsx` | `shadcn/no-restyle` | The authorization link inside the test-result Alert keeps the Alert's status colour (`text-current` on `link inline`). One disable (phase 11, 63g). |
| `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom readout between − and + is a `link inline` Button on the `bg-black/70` overlay; it keeps the overlay's white 12px regular text (`text-xs font-normal text-current`). One disable, beside the two zoom-button ones above (phase 11, 63g). |
| `conversation/SharedConversation.tsx` | `shadcn/no-restyle` | The "DocsGPT" link sits in the `/share/:id` page's regular-weight byline (`font-normal`). One disable (phase 11, 63g). |
| `conversation/ConversationBubble.tsx` | `shadcn/no-restyle` | A source card's URL row is a `link inline` around an `<a>`: foreground at rest, primary on hover, regular weight, truncating (`text-current font-normal hover:text-primary underline-offset-2 max-w-full justify-start`). One disable (phase 11, 63g). |
| `admin/Overview.tsx` | `shadcn/no-restyle` | "View in Audit" under the denied-sign-ins tile keeps the tile's destructive tone at hint size (`text-destructive text-xs font-normal`). One disable (phase 11, 63g). |
| `settings/PairDeviceModal.tsx` | `shadcn/no-restyle` | The install link in Pair a remote machine sits in a 12px hint (`text-xs font-normal`, `self-start` in its column). One disable (phase 11, 63g). |
| `agents/workflow/WorkflowBuilder.tsx` | `shadcn/no-restyle` | The publish-error Alert floats over the canvas with its close button in the top-right corner, so it pads `pr-10` to keep a long title clear of the button. One disable. |
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
## Removed classes
The lint reports classes Tailwind cannot generate. In this codebase they are
bugs, not missing configuration:
The lint reports classes Tailwind cannot generate; `no-unknown-classes` is at
`error`, so none remain. For the record, they were 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
@@ -569,8 +1031,9 @@ 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.
Every `shadcn/*` rule is at `error`, and `lint-staged` runs the lint on
staged files, so a violation blocks the commit. Work one file at a time 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
@@ -594,23 +1057,22 @@ time, smallest first, and keep every step green.
`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.
- `npm run lint` also reports 6 pre-existing Prettier errors (union-type
line breaks in `cronBuilder.ts`, `types/schedule.ts`, `types/workflow.ts`,
`api/client.ts`, `models/misc.ts`, `models/types.ts`) and about 330
TypeScript warnings unrelated to design; `npm run lint-fix` clears the
Prettier ones.
- Port 5173 may be held by another checkout; check the Vite banner shows
this repo's path before trusting what you see.
- `components/SkeletonLoader.tsx` renders `Skeleton` in its table, logs,
default and files layouts. Its analysis, dropdown, chunk-card and
connected-state layouts, and the four tile mirrors, pulse their own
surface, so they wait for the Card work. `components/FileUpload.tsx`
keeps its logic but should render `Dropzone`. (`agents/schedules/StatusBadge.tsx`
is a status→variant map over `Badge`, not a wrapper; keep it.)
default and files layouts and its four tile mirrors (Card + bars). Its
analysis, dropdown, chunk-card and connected-state layouts still pulse their
own surface; move them onto Card + `Skeleton` the same way when you touch
them. (`agents/schedules/StatusBadge.tsx` is a status→variant map over
`Badge`, not a wrapper; keep it.)
## Working with the lint
@@ -618,10 +1080,6 @@ Known traps:
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
All `shadcn/*` rules are `error`. 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.
+62 -14
View File
@@ -85,10 +85,9 @@ export default [
},
],
// 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.
// approved exceptions are documented in DESIGN.md.
'shadcn/no-restyle': [
'warn',
'error',
{
allow: ['layout'],
contracts: [
@@ -120,7 +119,7 @@ export default [
],
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.',
'"{{className}}" is not allowed on <Input>: use size (default, sm, lg, field), shape (default, pill) and variant (default, bare, filled); alignment classes and font-mono are allowed. Add a variant in {{file}} only if the design explicitly calls for one.',
},
},
{
@@ -128,7 +127,7 @@ export default [
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <SelectTrigger>: use size (sm, default, lg), variant (default, ghost) and shape (default, pill) from {{file}}.',
'"{{className}}" is not allowed on <SelectTrigger>: use size (sm, default, field), variant (default, ghost) and shape (default, pill) from {{file}}.',
},
},
{
@@ -136,7 +135,7 @@ export default [
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}}.',
'"{{className}}" is not allowed on <Avatar>: use size (xs, sm, default, lg), shape (circle, square) and variant (primary, muted) from {{file}}.',
},
},
{
@@ -144,7 +143,7 @@ export default [
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}}.',
'"{{className}}" is not allowed on <Card>: use variant (outline, filled, subtle), tone (destructive), padding (none, sm, default, lg) and interactive/selected from {{file}}.',
},
},
{
@@ -160,7 +159,55 @@ export default [
],
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.',
'"{{className}}" is not allowed on <Textarea>: use size (sm, default, lg), resize (none, vertical, both) and variant (default, filled) from {{file}}; min-height and width are layout and allowed.',
},
},
{
pattern: '^Badge$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Badge>: use a variant ({{variants}}) from {{file}}.',
},
},
{
pattern: '^(Alert|AlertTitle|AlertDescription)$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <{{component}}>: use Alert variant (default, destructive, success, warning, info) from {{file}}.',
},
},
{
pattern: '^Checkbox$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Checkbox>: use size from {{file}}.',
},
},
{
pattern: '^Dropzone$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Dropzone>: use size (default, compact); colours, border and radius follow the drag state in {{file}}.',
},
},
{
pattern: '^(Tabs|TabsList|TabsTrigger|TabsContent)$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <{{component}}>: use the variants in {{file}}.',
},
},
{
pattern: '^Command(Input|List|Item|Group|Empty)?$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <{{component}}>: use the parts and the checked prop from {{file}}.',
},
},
{
@@ -218,28 +265,29 @@ export default [
},
{
// Modal wraps DialogContent and merges className after its p-8.
pattern: '^((Dialog|Popover|Sheet|DropdownMenu)Content|Modal)$',
pattern:
'^((Dialog|Popover|Sheet|DropdownMenu|DropdownMenuSub|Select|Tooltip)Content|Sheet(Header|Footer)|Modal)$',
allow: ['layout', 'p-0'],
},
],
},
],
'shadcn/no-raw-colors': [
'warn',
'error',
{
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',
'error',
// 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',
'shadcn/no-inline-styles': 'error',
'shadcn/no-unknown-classes': 'error',
'shadcn/require-static-classes': 'error',
},
settings: {
react: {
-6
View File
@@ -8,10 +8,6 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@reduxjs/toolkit": "^2.12.0",
"@shadcn/react": "^0.3.1",
"chart.js": "^4.5.1",
@@ -20,13 +16,11 @@
"cmdk": "^1.1.1",
"copy-to-clipboard": "^4.0.2",
"d3-force": "^3.0.0",
"date-fns": "^4.4.0",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lodash": "^4.18.1",
"lucide-react": "^1.45.0",
"mermaid": "^12.0.0",
"prop-types": "^15.8.1",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-chartjs-2": "^5.3.1",
-6
View File
@@ -25,10 +25,6 @@
]
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@reduxjs/toolkit": "^2.12.0",
"@shadcn/react": "^0.3.1",
"chart.js": "^4.5.1",
@@ -37,13 +33,11 @@
"cmdk": "^1.1.1",
"copy-to-clipboard": "^4.0.2",
"d3-force": "^3.0.0",
"date-fns": "^4.4.0",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lodash": "^4.18.1",
"lucide-react": "^1.45.0",
"mermaid": "^12.0.0",
"prop-types": "^15.8.1",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-chartjs-2": "^5.3.1",
+1
View File
@@ -5,4 +5,5 @@ module.exports = {
singleQuote: true,
printWidth: 80,
plugins: ['prettier-plugin-tailwindcss'],
tailwindFunctions: ['cn'],
};
+9 -14
View File
@@ -17,7 +17,7 @@ import DocsGPTMarkWhite from './assets/logo-w.svg';
import ActionButtons from './components/ActionButtons';
import AdminRoute from './components/AdminRoute';
import ErrorBoundary from './components/ErrorBoundary';
import { Spinner } from '@/components/ui/spinner';
import { LoadingState } from '@/components/ui/loading-state';
import { Button } from './components/ui/button';
import { ToastViewport } from './components/ui/toast';
import UploadToast from './components/UploadToast';
@@ -70,7 +70,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
alt="DocsGPT"
className="h-14 w-auto"
/>
<p className="text-foreground max-w-md px-6 text-center text-sm dark:text-white">
<p className="text-foreground max-w-md px-6 text-center text-sm">
{message}
</p>
<Button
@@ -85,18 +85,14 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
);
}
if (isAuthLoading) {
return (
<div className="flex h-screen items-center justify-center">
<Spinner />
</div>
);
return <LoadingState fill="screen" />;
}
return <EventStreamProvider>{children}</EventStreamProvider>;
}
function MainLayout() {
const { isMobile, isTablet } = useMediaQuery();
const [navOpen, setNavOpen] = useState(!(isMobile || isTablet));
const { isMobile } = useMediaQuery();
const [navOpen, setNavOpen] = useState(!isMobile);
const location = useLocation();
// Settings and admin pages keep the profile menu but drop the chat actions:
// the conversation now survives the trip, so "share" would target a chat
@@ -113,11 +109,10 @@ function MainLayout() {
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<ActionButtons showNewChat={!inSection} showShare={!inSection} />
<div
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
!(isMobile || isTablet)
? `${navOpen ? 'lg:ml-72' : 'lg:ml-14'}`
: 'ml-0 lg:ml-16'
}`}
className={cn(
'h-[calc(100dvh-64px)] overflow-auto transition-[margin] duration-300 ease-in-out lg:h-screen',
!isMobile ? (navOpen ? 'lg:ml-72' : 'lg:ml-14') : 'ml-0 lg:ml-16',
)}
>
{/* Contain route render crashes so navigation stays usable;
keyed by path so the boundary resets when the user leaves. */}
+21 -7
View File
@@ -5,6 +5,8 @@ import { useDispatch, useSelector } from 'react-redux';
import modelService from './api/services/modelService';
import DocsGPTLogo from './assets/full-logo-b.svg';
import DocsGPTLogoWhite from './assets/full-logo-w.svg';
import { Button } from './components/ui/button';
import { cn } from './lib/utils';
import {
Select,
SelectContent,
@@ -25,6 +27,7 @@ import {
import type { Model } from './models/types';
function HeroModelSelect() {
const { t } = useTranslation();
const dispatch = useDispatch();
const selectedModel = useSelector(selectSelectedModel);
const availableModels = useSelector(selectAvailableModels);
@@ -97,10 +100,10 @@ function HeroModelSelect() {
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"
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 md:text-base"
size="field"
>
<SelectValue placeholder="Select Model" />
<SelectValue placeholder={t('conversation.selectModel')} />
</SelectTrigger>
<SelectContent
/* eslint-disable-next-line shadcn/no-restyle --
@@ -121,7 +124,7 @@ function HeroModelSelect() {
))
) : (
<div className="text-muted-foreground px-5 py-3 text-base">
No models available
{t('agents.form.modelsPopup.noOptionsMessage')}
</div>
)}
</SelectContent>
@@ -171,10 +174,21 @@ export default function Hero({
(demo: { header: string; query: string }, key: number) =>
demo.header &&
demo.query && (
<button
<Button
key={key}
type="button"
variant="outline"
size="lg"
shape="pill"
onClick={() => handleQuestion({ question: demo.query })}
className={`border-border text-foreground hover:bg-muted dark:hover:bg-muted/50 bg-card w-full rounded-full border px-6 py-3.5 text-left transition-colors dark:bg-transparent ${key >= 2 ? 'hidden md:block' : ''}`}
className={cn(
/* eslint-disable-next-line shadcn/no-restyle --
The demo card is a two-line pill (title over a clamped
query at 12px), so it undoes lg's one-line height, the
base's row layout, weight and nowrap. */
'h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal',
key >= 2 && 'hidden md:flex',
)}
>
<p className="text-foreground mb-2 font-semibold">
{demo.header}
@@ -182,7 +196,7 @@ export default function Hero({
<span className="text-muted-foreground line-clamp-2">
{demo.query}
</span>
</button>
</Button>
),
)}
</div>
+151 -138
View File
@@ -10,8 +10,8 @@ import {
Pin,
PinOff,
Plus,
Search as SearchIcon,
Settings as SettingsIcon,
Search,
Settings,
} from 'lucide-react';
import {
@@ -26,7 +26,8 @@ import Discord from './assets/discord.svg';
import Github from './assets/git_nav.svg';
import { Avatar } from './components/ui/avatar';
import { Button } from './components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { IconButton } from './components/ui/icon-button';
import { LoadingState } from '@/components/ui/loading-state';
import Twitter from './assets/TwitterX.svg';
import Help from './components/Help';
import {
@@ -96,7 +97,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const selectedAgent = useSelector(selectSelectedAgent);
const isAdmin = useSelector(selectIsAdmin);
const { isMobile, isTablet } = useMediaQuery();
const { isMobile } = useMediaQuery();
const { showTokenModal, handleTokenSubmit } = useTokenAuth();
// Section state is derived from the route, so deep links and the browser
@@ -143,7 +144,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
: t('navigation.backToApp');
const exitSectionFrom = (section: Section | null) => () => {
if (isMobile || isTablet) setNavOpen(false);
if (isMobile) setNavOpen(false);
goToLevel(section?.parentPath ?? lastAppPath.current ?? '/');
};
@@ -153,7 +154,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
: t('navigation.backToApp');
const closeNavOnMobile = () => {
if (isMobile || isTablet) setNavOpen(false);
if (isMobile) setNavOpen(false);
};
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
@@ -168,7 +169,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
if (
navRef.current &&
!navRef.current.contains(event.target as Node) &&
(isMobile || isTablet) &&
isMobile &&
navOpen
) {
setNavOpen(false);
@@ -176,13 +177,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}
//event listener only for mobile/tablet when nav is open
if ((isMobile || isTablet) && navOpen) {
if (isMobile && navOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [navOpen, isMobile, isTablet, setNavOpen]);
}, [navOpen, isMobile, setNavOpen]);
useEffect(() => {
function handleSearchShortcut(event: KeyboardEvent) {
@@ -275,7 +276,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const handleAgentClick = (agent: Agent) => {
resetConversation();
dispatch(setSelectedAgent(agent));
if (isMobile || isTablet) setNavOpen(!navOpen);
if (isMobile) setNavOpen(!navOpen);
navigate(agent.id ? agentChatPath(agent.id) : '/c/new');
};
@@ -380,12 +381,12 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}
useEffect(() => {
setNavOpen(!(isMobile || isTablet));
}, [isMobile, isTablet]);
setNavOpen(!isMobile);
}, [isMobile]);
return (
<>
{(isMobile || isTablet) && navOpen && (
{isMobile && navOpen && (
<div
className="fixed inset-0 z-10 bg-black opacity-50 transition-opacity duration-300"
onClick={() => setNavOpen(false)}
@@ -393,20 +394,20 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
{/* Icon rail (desktop only, when sidebar collapsed) */}
{!navOpen && !isMobile && !isTablet && (
{!navOpen && !isMobile && (
<div
ref={navRef}
className="bg-sidebar border-border scrollbar-overlay fixed top-0 left-0 z-10 hidden h-full w-14 flex-col items-center gap-2 overflow-x-hidden overflow-y-auto border-r py-3 lg:flex"
>
<Button
type="button"
<IconButton
label={t('navigation.openSidebar')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => setNavOpen(true)}
aria-label="Open navigation menu"
>
<PanelLeftOpen className="size-5" />
</Button>
<PanelLeftOpen aria-hidden />
</IconButton>
{activeSection ? (
<SectionRail
section={activeSection}
@@ -418,50 +419,49 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
) : (
<>
{queries?.length > 0 && (
<Button
type="button"
<IconButton
label={t('newChat')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => newChat()}
aria-label="Start new chat"
>
<Plus className="size-5" />
</Button>
<Plus aria-hidden />
</IconButton>
)}
<Button
type="button"
<IconButton
label={t('manageAgents')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => {
dispatch(setSelectedAgent(null));
goToLevel(AGENTS_MANAGE_ROOT);
}}
aria-label={t('manageAgents')}
>
<LayoutGrid className="size-5" />
</Button>
<LayoutGrid aria-hidden />
</IconButton>
{conversations?.data && conversations.data.length > 0 && (
<Button
type="button"
<IconButton
label={t('modals.searchConversations.searchPlaceholder')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => setSearchOpen(true)}
aria-label={t('modals.searchConversations.searchPlaceholder')}
title={t('modals.searchConversations.searchPlaceholder')}
>
<SearchIcon className="size-5" />
</Button>
<Search aria-hidden />
</IconButton>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<Button
type="button"
<IconButton
label={t('settings.label')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => goToLevel('/settings')}
aria-label={t('settings.label')}
>
<SettingsIcon className="size-5" />
</Button>
<Settings aria-hidden />
</IconButton>
</div>
</>
)}
@@ -469,7 +469,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
<div
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',
'bg-sidebar text-foreground fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-[margin] duration-300 ease-in-out',
!navOpen && '-ml-96 md:-ml-72',
)}
>
@@ -481,28 +481,32 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="min-w-0 flex-1">
<TeamSwitcher
onNavigate={() => {
if (isMobile || isTablet) {
if (isMobile) {
setNavOpen(false);
}
}}
/>
</div>
<Button
type="button"
<IconButton
label={
navOpen
? t('navigation.closeSidebar')
: t('navigation.openSidebar')
}
side="bottom"
variant="ghost-muted"
size="icon"
className="shrink-0"
onClick={() => {
setNavOpen(!navOpen);
}}
aria-label={navOpen ? 'Collapse sidebar' : 'Expand sidebar'}
>
{navOpen ? (
<PanelLeftClose className="size-5 transition-all duration-300 ease-in-out hover:scale-110" />
<PanelLeftClose aria-hidden />
) : (
<PanelLeftOpen className="size-5 transition-all duration-300 ease-in-out hover:scale-110" />
<PanelLeftOpen aria-hidden />
)}
</Button>
</IconButton>
</div>
{/* The chat list and the section nav swap places here. Both stay
mounted so the conversation list keeps its scroll position while
@@ -512,20 +516,21 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<NavLink
to={'/c/new'}
onClick={() => {
if (isMobile || isTablet) {
if (isMobile) {
setNavOpen(!navOpen);
}
resetConversation();
}}
className={({ isActive }) =>
`${
isActive ? 'bg-transparent' : ''
} group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent dark:text-white`
cn(
'group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent',
isActive && 'bg-transparent',
)
}
>
<Plus
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
aria-label="Create new chat"
aria-hidden
/>
<p className="text-muted-foreground group-hover:text-foreground text-sm">
{t('newChat')}
@@ -533,16 +538,14 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</NavLink>
<div
id="conversationsMainDiv"
className="scrollbar-overlay min-h-0 flex-1 overflow-x-hidden overflow-y-auto dark:text-white"
className="scrollbar-overlay min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
>
{conversations?.loading && !isDeletingConversation && (
<div
className="text-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform dark:text-white"
role="status"
aria-label="Loading conversations"
>
<Spinner size="sm" />
</div>
<LoadingState
fill="parent"
size="sm"
className="pointer-events-none absolute inset-0"
/>
)}
{recentAgents?.length > 0 ? (
<div>
@@ -553,50 +556,70 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
<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-3 ${
agent.id === selectedAgent?.id && !conversationId
? 'bg-sidebar-accent'
: ''
}`}
onClick={() => handleAgentClick(agent)}
>
<div className="flex items-center gap-2.5">
<div className="flex w-6 justify-center">
<Avatar
src={agent.image}
alt="agent-logo"
shape="circle"
className="overflow-hidden"
imgClassName="h-6 w-6 object-contain"
{recentAgents.map((agent, idx) => {
const isCurrent =
agent.id === selectedAgent?.id && !conversationId;
return (
<div key={idx} className="group relative mx-4 mt-4">
<Button
variant="sidebar-item"
asChild
/* eslint-disable-next-line shadcn/no-restyle -- the link and its pin button are siblings, so the row keeps its fill while the pointer is on the pin, and pr-10 keeps the name clear of it */
className="group-hover:bg-sidebar-accent flex w-full pr-10"
>
<Link
to={
agent.id ? agentChatPath(agent.id) : '/c/new'
}
aria-current={isCurrent ? 'page' : undefined}
onClick={(event) => {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey
)
return;
event.preventDefault();
handleAgentClick(agent);
}}
>
<div className="flex w-6 shrink-0 justify-center">
<Avatar
src={agent.image}
alt=""
shape="circle"
className="overflow-hidden"
imgClassName="size-6 object-contain"
/>
</div>
<span className="truncate">{agent.name}</span>
</Link>
</Button>
<div
className={cn(
'absolute top-1 right-1.5 flex items-center',
!isMobile &&
'invisible group-focus-within:visible group-hover:visible',
)}
>
<IconButton
label={
agent.pinned
? t('agents.card.unpin')
: t('agents.card.pin')
}
icon={agent.pinned ? PinOff : Pin}
variant="ghost-on-accent"
size="icon-xs"
onClick={(e) => {
e.stopPropagation();
handleTogglePin(agent);
}}
/>
</div>
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{agent.name}
</p>
</div>
<div
className={`${isMobile || isTablet ? 'flex' : 'invisible flex group-hover:visible'} items-center px-1.5`}
>
<Button
type="button"
variant="ghost-on-accent"
size="icon-xs"
onClick={(e) => {
e.stopPropagation();
handleTogglePin(agent);
}}
aria-label={
agent.pinned ? 'Unpin agent' : 'Pin agent'
}
>
{agent.pinned ? <PinOff /> : <Pin />}
</Button>
</div>
</div>
))}
);
})}
</div>
<Button
variant="sidebar-item"
@@ -618,12 +641,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="flex w-6 justify-center">
<LayoutGrid
className="text-muted-foreground size-5"
aria-label="manage-agents"
aria-hidden
/>
</div>
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
<span className="truncate">{t('manageAgents')}</span>
</NavLink>
</Button>
</div>
@@ -648,11 +669,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<LayoutGrid
className="text-muted-foreground size-5 shrink-0"
aria-label="manage-agents"
aria-hidden
/>
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
<span className="truncate">{t('manageAgents')}</span>
</NavLink>
</Button>
)}
@@ -662,20 +681,16 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<p className="mt-1 ml-4 text-sm font-semibold">
{t('chats')}
</p>
<Button
type="button"
<IconButton
label={t('modals.searchConversations.searchPlaceholder')}
variant="ghost-muted"
size="icon"
shape="pill"
onClick={() => setSearchOpen(true)}
className="mr-1"
aria-label={t(
'modals.searchConversations.searchPlaceholder',
)}
title={t('modals.searchConversations.searchPlaceholder')}
>
<SearchIcon className="size-5" aria-label="search" />
</Button>
<Search aria-hidden />
</IconButton>
</div>
<div>
{(conversations.data ?? []).map((conversation) => (
@@ -736,14 +751,14 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
</SidebarLevel>
</div>
<div className="text-foreground flex h-auto shrink-0 flex-col justify-end dark:text-white">
<div className="text-foreground flex h-auto shrink-0 flex-col justify-end">
{/* Inside a section its own nav is the way around, so this entry
would only duplicate what is already on screen. Entering settings
no longer clears the conversation either, so the section's back
button can return to it. */}
<div
className={cn(
'dark:border-b-sidebar-border flex flex-col gap-2 border-b py-2',
'flex flex-col gap-2 border-b py-2',
inSection && 'hidden',
)}
>
@@ -761,17 +776,15 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
goToLevel('/settings');
}}
>
<SettingsIcon
<Settings
className="text-muted-foreground size-5 shrink-0"
aria-label="Settings"
aria-hidden
/>
<p className="text-foreground text-sm dark:text-white">
{t('settings.label')}
</p>
<p className="text-foreground text-sm">{t('settings.label')}</p>
</Link>
</Button>
</div>
<div className="text-foreground flex flex-col justify-end dark:text-white">
<div className="text-foreground flex flex-col justify-end">
<div className="flex items-center justify-between py-1">
<Help />
@@ -785,7 +798,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
src={Discord}
width={24}
height={24}
alt="Join Discord community"
alt={t('navigation.social.discord')}
className="m-2 w-6 self-center filter dark:invert"
/>
</NavLink>
@@ -798,7 +811,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
src={Twitter}
width={20}
height={20}
alt="Follow us on X"
alt={t('navigation.social.x')}
className="m-2 self-center filter dark:invert"
/>
</NavLink>
@@ -809,7 +822,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<img
src={Github}
alt="View on GitHub"
alt={t('navigation.social.github')}
width={28}
height={28}
className="m-2 self-center filter dark:invert"
@@ -820,18 +833,18 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
</div>
</div>
<div className="dark:border-b-sidebar-border bg-sidebar sticky z-10 h-16 w-full border-b-2 lg:hidden">
<div className="bg-sidebar sticky z-10 h-16 w-full border-b-2 lg:hidden">
<div className="relative flex h-full items-center">
<Button
type="button"
<IconButton
label={t('navigation.openSidebar')}
side="bottom"
variant="ghost-muted"
size="icon"
className="ml-4 size-9 lg:hidden"
className="ml-4 lg:hidden"
onClick={() => setNavOpen(true)}
aria-label="Toggle mobile menu"
>
<Menu className="size-5" />
</Button>
<Menu aria-hidden />
</IconButton>
</div>
</div>
<ConfirmationModal
@@ -840,7 +853,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
setModalState={(state) => dispatch(setModalStateDeleteConv(state))}
submitLabel={t('modals.deleteConv.delete')}
handleSubmit={handleDeleteAllConversations}
variant="danger"
variant="destructive"
/>
{uploadModalState === 'ACTIVE' && (
<Upload
@@ -862,7 +875,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
token={token}
onSelectConversation={(id) => {
handleConversationClick(id);
if (isMobile || isTablet) setNavOpen(false);
if (isMobile) setNavOpen(false);
}}
/>
)}
+3 -3
View File
@@ -6,8 +6,8 @@ export default function PageNotFound() {
return (
<div className="bg-background grid min-h-screen">
<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>
<div 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 className="text-3xl">{t('pageNotFound.title')}</h1>
<p>{t('pageNotFound.message')}</p>
<Link
to="/"
@@ -15,7 +15,7 @@ export default function PageNotFound() {
>
{t('pageNotFound.goHome')}
</Link>
</p>
</div>
</div>
);
}
+83 -82
View File
@@ -8,7 +8,13 @@ import adminService, {
} from '../api/services/adminService';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { IconButton } from '../components/ui/icon-button';
import SearchInput from '../components/SearchInput';
import {
DescriptionItem,
DescriptionList,
} from '../components/ui/description-list';
import { LoadingState } from '../components/ui/loading-state';
import { MultiSelect } from '../components/ui/multi-select';
import {
Table,
@@ -19,9 +25,11 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import { Pagination } from '../components/ui/pagination';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { selectToken } from '../preferences/preferenceSlice';
import {
Loading,
LoadError,
categoryTone,
eventLabel,
eventTone,
@@ -32,6 +40,7 @@ import {
outcomeLabel,
outcomeTone,
} from './AdminUI';
import { cn } from '@/lib/utils';
type ActivityRow = {
feed: string;
@@ -75,10 +84,9 @@ function DetailRow({ label, value }: { label: string; value: unknown }) {
? JSON.stringify(value)
: String(value);
return (
<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>
<DescriptionItem label={label} mono>
{text}
</DescriptionItem>
);
}
@@ -91,24 +99,28 @@ function RowDetail({ row }: { row: ActivityRow }) {
const detail = row.detail ?? {};
const entries = Object.entries(detail);
return (
<div className="bg-muted/40 space-y-1 rounded-lg px-4 py-3">
<DetailRow label="Event" value={row.event} />
<DetailRow label="Journal" value={row.feed} />
{row.actor_id ? <DetailRow label="Actor" value={row.actor_id} /> : null}
{row.target_id ? (
<DetailRow label="Affected user" value={row.target_id} />
) : null}
{row.outcome ? <DetailRow label="Outcome" value={row.outcome} /> : null}
{row.ip ? <DetailRow label="IP" value={row.ip} /> : null}
{row.user_agent ? (
<DetailRow label="User agent" value={row.user_agent} />
) : null}
<DetailRow label="Recorded" value={fmtDate(row.created_at)} />
<div className="bg-muted rounded-lg px-4 py-3">
<DescriptionList layout="columns" size="xs">
<DetailRow label="Event" value={row.event} />
<DetailRow label="Journal" value={row.feed} />
{row.actor_id ? <DetailRow label="Actor" value={row.actor_id} /> : null}
{row.target_id ? (
<DetailRow label="Affected user" value={row.target_id} />
) : null}
{row.outcome ? <DetailRow label="Outcome" value={row.outcome} /> : null}
{row.ip ? <DetailRow label="IP" value={row.ip} /> : null}
{row.user_agent ? (
<DetailRow label="User agent" value={row.user_agent} />
) : null}
<DetailRow label="Recorded" value={fmtDate(row.created_at)} />
</DescriptionList>
{entries.length > 0 ? (
<div className="border-border mt-2 space-y-1 border-t pt-2">
{entries.map(([key, value]) => (
<DetailRow key={key} label={key} value={value} />
))}
<div className="border-border mt-2 border-t pt-2">
<DescriptionList layout="columns" size="xs">
{entries.map(([key, value]) => (
<DetailRow key={key} label={key} value={value} />
))}
</DescriptionList>
</div>
) : null}
</div>
@@ -126,6 +138,7 @@ export default function Activity() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
@@ -183,6 +196,7 @@ export default function Activity() {
const load = useCallback(async () => {
const id = ++requestId.current;
setLoading(true);
setFailed(false);
try {
const res = await adminService.getActivity(
{ ...filters, page, page_size: PAGE_SIZE },
@@ -192,6 +206,9 @@ export default function Activity() {
if (id !== requestId.current) return;
setRows(json.activity ?? []);
setTotal(json.total ?? 0);
setFailed(!res.ok || json.success === false);
} catch {
if (id === requestId.current) setFailed(true);
} finally {
if (id === requestId.current) setLoading(false);
}
@@ -244,16 +261,17 @@ export default function Activity() {
const applySearch = () => withPageReset(setSearch)(searchDraft.trim());
return (
<div className="mt-6">
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="Search user, IP or detail"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applySearch()}
onBlur={applySearch}
className="max-w-xs"
/>
<div className="w-full max-w-xs">
<SearchInput
label="Search user, IP or detail"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applySearch()}
onBlur={applySearch}
/>
</div>
<MultiSelect
options={categoryOptions}
selected={categories}
@@ -269,19 +287,21 @@ export default function Activity() {
searchPlaceholder="Find an event"
className="w-56"
/>
<div className="flex items-center gap-1">
<ToggleGroup
type="single"
size="sm"
value={String(rangeDays)}
onValueChange={(value) =>
value && withPageReset(setRangeDays)(Number(value))
}
aria-label="Range"
>
{RANGES.map((range) => (
<Button
key={range.label}
variant={range.days === rangeDays ? 'default' : 'outline'}
size="sm"
shape="pill"
onClick={() => withPageReset(setRangeDays)(range.days)}
>
<ToggleGroupItem key={range.label} value={String(range.days)}>
{range.label}
</Button>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
{hasFilters ? (
<Button
variant="ghost"
@@ -304,7 +324,7 @@ export default function Activity() {
disabled={exporting}
onClick={() => exportAs('csv')}
>
<Download className="size-4" />
<Download />
CSV
</Button>
<Button
@@ -313,14 +333,16 @@ export default function Activity() {
disabled={exporting}
onClick={() => exportAs('ndjson')}
>
<Download className="size-4" />
<Download />
NDJSON
</Button>
</div>
</div>
{loading ? (
<Loading />
<LoadingState fill="block" />
) : failed ? (
<LoadError message="Failed to load activity." onRetry={load} />
) : rows.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">No activity.</p>
) : (
@@ -349,23 +371,18 @@ export default function Activity() {
return [
<TableRow key={key}>
<TableCell>
<button
type="button"
<IconButton
variant="ghost-muted"
size="icon-xs"
aria-expanded={isOpen}
aria-label={
label={
isOpen
? `Hide details for ${eventLabel(row.event)}`
: `Show details for ${eventLabel(row.event)}`
}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring cursor-pointer rounded focus-visible:ring-2 focus-visible:outline-none"
icon={isOpen ? ChevronDown : ChevronRight}
onClick={() => setExpanded(isOpen ? null : key)}
>
{isOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
@@ -385,9 +402,10 @@ export default function Activity() {
</Badge>
</TableCell>
<TableCell
className={`font-mono text-xs ${
repeat ? 'text-muted-foreground/50' : ''
}`}
className={cn(
'font-mono text-xs',
repeat ? 'text-muted-foreground/50' : '',
)}
>
{repeat ? '〃' : (row.actor_id ?? '—')}
</TableCell>
@@ -427,29 +445,12 @@ export default function Activity() {
</TableBody>
</Table>
</TableContainer>
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{fmtNumber(total)} events · page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
<Pagination
page={page}
pageCount={totalPages}
onPageChange={setPage}
summary={`${fmtNumber(total)} events`}
/>
</>
)}
</div>
+23 -42
View File
@@ -1,42 +1,34 @@
import type { VariantProps } from 'class-variance-authority';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import type { badgeVariants } from '../components/ui/badge';
import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils';
export function Loading() {
return (
<div className="flex h-40 items-center justify-center">
<Spinner />
</div>
);
}
import {
formatDateOnly,
formatDateTime,
formatRelative,
} from '../utils/dateTimeUtils';
/** A failed admin fetch: a red message with a Retry that re-runs the fetch. */
export function LoadError({
message = 'Failed to load.',
onRetry,
}: {
message?: string;
}) {
return <p className="text-muted-foreground mt-8 text-sm">{message}</p>;
}
export function StatCard({
label,
value,
sub,
}: {
label: string;
value: React.ReactNode;
sub?: string;
onRetry: () => void;
}) {
return (
<div className="border-border rounded-2xl border px-6 py-5">
<p className="text-muted-foreground text-sm">{label}</p>
<p className="text-foreground mt-1 text-2xl font-bold tabular-nums">
{value}
</p>
{sub ? <p className="text-muted-foreground mt-1 text-xs">{sub}</p> : null}
</div>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={message}
action={
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
}
/>
);
}
@@ -51,21 +43,10 @@ export function fmtDateShort(value?: string | null): string {
return value ? formatDateOnly(value) : '—';
}
/** "3 minutes ago" in the UI language; `never` when there is no value. */
export function fmtRelative(value?: string | null): string {
if (!value) return 'never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const sec = Math.round((Date.now() - date.getTime()) / 1000);
if (sec < 60) return 'just now';
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}d ago`;
if (day < 30) return `${Math.round(day / 7)}w ago`;
if (day < 365) return `${Math.round(day / 30)}mo ago`;
return `${Math.round(day / 365)}y ago`;
return formatRelative(value) ?? String(value);
}
export function fmtNumber(n?: number | null): string {
+22 -5
View File
@@ -13,7 +13,8 @@ import {
TableRow,
} from '../components/ui/table';
import { selectToken } from '../preferences/preferenceSlice';
import { Loading, fmtDate } from './AdminUI';
import { LoadingState } from '@/components/ui/loading-state';
import { LoadError, fmtDate } from './AdminUI';
type Admin = { user_id: string; granted_at?: string; sources?: string[] };
@@ -21,29 +22,45 @@ export default function Admins() {
const token = useSelector(selectToken);
const [admins, setAdmins] = useState<Admin[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setFailed(false);
adminService
.getAdmins(token)
.then((res) => res.json())
.then((json) => {
if (!cancelled) {
setAdmins(json.admins ?? []);
setFailed(!json.success);
setLoading(false);
}
})
.catch(() => !cancelled && setLoading(false));
.catch(() => {
if (!cancelled) {
setFailed(true);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [token]);
}, [token, reloadKey]);
if (loading) return <Loading />;
if (loading) return <LoadingState fill="block" />;
if (failed)
return (
<LoadError
message="Failed to load admins."
onRetry={() => setReloadKey((k) => k + 1)}
/>
);
return (
<div className="mt-6">
<div>
<p className="text-muted-foreground mb-4 text-sm">
Admin access is the deployment super-admin role. Grant or revoke it from
the Users tab; OIDC-group-derived grants reconcile automatically at
+40
View File
@@ -0,0 +1,40 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { LoadError } from './AdminUI';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('LoadError', () => {
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 a destructive empty state whose Retry re-runs the fetch', () => {
const onRetry = vi.fn();
act(() => {
root.render(
<LoadError message="Failed to load users." onRetry={onRetry} />,
);
});
const block = container.querySelector('[data-slot="empty-state"]');
expect(block?.getAttribute('data-tone')).toBe('destructive');
expect(block?.getAttribute('role')).toBe('alert');
expect(block?.textContent).toContain('Failed to load users.');
const retry = container.querySelector('button');
expect(retry?.textContent).toBe('Retry');
act(() => retry?.click());
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
+39 -33
View File
@@ -4,7 +4,10 @@ import { Link } from 'react-router-dom';
import adminService from '../api/services/adminService';
import { selectToken } from '../preferences/preferenceSlice';
import { Loading, LoadError, StatCard, fmtNumber } from './AdminUI';
import { Button } from '@/components/ui/button';
import { LoadingState } from '@/components/ui/loading-state';
import StatCard from '@/components/StatCard';
import { LoadError, fmtNumber } from './AdminUI';
function Section({
title,
@@ -14,7 +17,8 @@ function Section({
children: React.ReactNode;
}) {
return (
<div className="mt-8">
// The shell puts the first group 32px under the title.
<div className="mt-8 first:mt-0">
<p className="text-muted-foreground mb-3 text-sm font-medium">{title}</p>
{children}
</div>
@@ -25,6 +29,7 @@ export default function Overview() {
const token = useSelector(selectToken);
const [data, setData] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
@@ -42,10 +47,16 @@ export default function Overview() {
return () => {
cancelled = true;
};
}, [token]);
}, [token, reloadKey]);
if (loading) return <Loading />;
if (!data?.success) return <LoadError message="Failed to load overview." />;
if (loading) return <LoadingState fill="block" />;
if (!data?.success)
return (
<LoadError
message="Failed to load overview."
onRetry={() => setReloadKey((k) => k + 1)}
/>
);
const users = data.users ?? {};
const failed = data.failed_logins_7d ?? 0;
@@ -85,34 +96,29 @@ export default function Overview() {
<Section title="Security & access">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div
className={`rounded-2xl border px-6 py-5 ${
failed > 0
? '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-destructive' : 'text-foreground'
}`}
>
{fmtNumber(failed)}
</p>
{failed > 0 ? (
<Link
to="/admin/audit?event=oidc_login_denied"
className="text-destructive mt-2 inline-block text-xs hover:underline"
>
View in Audit →
</Link>
) : (
<p className="text-muted-foreground mt-2 text-xs">
No denied logins
</p>
)}
</div>
<StatCard
label="Failed logins (7d)"
value={fmtNumber(failed)}
tone={failed > 0 ? 'destructive' : 'default'}
valueTone={failed > 0 ? 'destructive' : undefined}
sub={
failed > 0 ? (
<Button
variant="link"
size="inline"
asChild
// eslint-disable-next-line shadcn/no-restyle -- keeps the tile's destructive tone at hint size
className="text-destructive text-xs font-normal"
>
<Link to="/admin/audit?event=oidc_login_denied">
View in Audit →
</Link>
</Button>
) : (
'No denied logins'
)
}
/>
</div>
</Section>
</div>
+7 -4
View File
@@ -1,7 +1,9 @@
import { CircleAlert } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Progress } from '../components/ui/progress';
@@ -188,7 +190,7 @@ export default function QuotaEditor({
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-xs">{inheritHint}</p>
{policy && !policy.enabled ? (
<p className="text-warning text-xs">
@@ -225,9 +227,10 @@ export default function QuotaEditor({
/>
</div>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
<Alert variant="destructive">
<CircleAlert className="size-4" aria-hidden="true" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="flex justify-end gap-2">
{policy ? (
+93 -70
View File
@@ -1,11 +1,15 @@
import { TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import teamsService from '../api/services/teamsService';
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Modal } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
import {
Select,
SelectContent,
@@ -23,7 +27,8 @@ import {
TableRow,
} from '../components/ui/table';
import { selectToken } from '../preferences/preferenceSlice';
import { LoadError, Loading, fmtDate, fmtNumber, fmtRelative } from './AdminUI';
import { LoadingState } from '@/components/ui/loading-state';
import { LoadError, fmtDate, fmtNumber, fmtRelative } from './AdminUI';
import QuotaEditor from './QuotaEditor';
import { describeBudget, type QuotaPolicy } from './quotaUtils';
@@ -47,6 +52,10 @@ const HINTS: Record<QuotaScope, string> = {
user: 'Overrides team allowances and the instance default for this user.',
};
// React escapes on render; i18next's own escaping would mangle model ids
// such as `org/model` into `org&#x2F;model`.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
function PolicyCells({ policy }: { policy: QuotaPolicy }) {
return (
<>
@@ -67,6 +76,7 @@ function PolicyCells({ policy }: { policy: QuotaPolicy }) {
}
export default function Quotas() {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [data, setData] = useState<any | null>(null);
const [teams, setTeams] = useState<any[]>([]);
@@ -107,8 +117,9 @@ export default function Quotas() {
return teams.filter((team) => !covered.has(String(team.id)));
}, [teams, teamPolicies]);
if (data === null && loading) return <Loading />;
if (!data?.success) return <LoadError message="Failed to load quotas." />;
if (data === null && loading) return <LoadingState fill="block" />;
if (!data?.success)
return <LoadError message="Failed to load quotas." onRetry={load} />;
const bucketPill = (policy: QuotaPolicy) => (
<>
@@ -120,7 +131,7 @@ export default function Quotas() {
);
return (
<div className="mt-6 space-y-8">
<div className="flex flex-col gap-8">
<p className="text-muted-foreground text-sm">
Usage is counted per user over each calendar {data.period} (UTC). The
current window resets {fmtDate(data.resets_at)}. A request is refused
@@ -128,39 +139,49 @@ export default function Quotas() {
</p>
{(data.unpriced_models ?? []).length > 0 ? (
<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>
<p className="text-muted-foreground mt-1">
These were used this {data.period} and recorded at $0:{' '}
{(data.unpriced_models as any[])
.map((m) => `${m.model_id} (${fmtNumber(m.tokens)} tokens)`)
.join(', ')}
. Use a token limit for them, or declare their rates in the model
catalog.
</p>
</div>
<Alert variant="warning" role="note">
<TriangleAlert className="size-4" aria-hidden="true" />
<AlertTitle>{t('admin.quotas.unpriced.title')}</AlertTitle>
<AlertDescription>
<p>
{t('admin.quotas.unpriced.body', {
period: data.period,
models: (data.unpriced_models as any[])
.map((m) =>
t('admin.quotas.unpriced.model', {
model: m.model_id,
tokens: fmtNumber(m.tokens),
...NO_ESCAPE,
}),
)
.join(', '),
...NO_ESCAPE,
})}
</p>
</AlertDescription>
</Alert>
) : null}
<section>
<div className="flex items-center justify-between gap-3">
<p className="text-foreground font-bold">Instance default</p>
<Button
variant="outline"
size="sm"
onClick={() =>
setEditing({
scope: 'instance',
subjectId: null,
title: 'Instance default',
policy: instancePolicy,
})
}
>
{instancePolicy ? 'Edit' : 'Set default'}
</Button>
</div>
<SectionHeader
title="Instance default"
actions={
<Button
variant="outline"
size="sm"
onClick={() =>
setEditing({
scope: 'instance',
subjectId: null,
title: 'Instance default',
policy: instancePolicy,
})
}
>
{instancePolicy ? 'Edit' : 'Set default'}
</Button>
}
/>
<p className="text-muted-foreground mt-1 text-sm">
{instancePolicy
? `${instancePolicy.enabled ? '' : 'Disabled · '}Tokens: ${describeBudget(instancePolicy.token_limit, instancePolicy.token_unlimited, 'tokens')} · Cost: ${describeBudget(instancePolicy.cost_limit_usd, instancePolicy.cost_unlimited, 'cost')}`
@@ -169,41 +190,43 @@ export default function Quotas() {
</section>
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-foreground font-bold">Team allowances</p>
{teamsWithoutPolicy.length > 0 ? (
<div className="flex items-center gap-2">
<Select value={teamPick} onValueChange={setTeamPick}>
<SelectTrigger className="w-52" aria-label="Team">
<SelectValue placeholder="Choose a team" />
</SelectTrigger>
<SelectContent>
{teamsWithoutPolicy.map((team) => (
<SelectItem key={team.id} value={String(team.id)}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={!teamPick}
onClick={() => {
const team = teams.find((tm) => String(tm.id) === teamPick);
setEditing({
scope: 'team',
subjectId: teamPick,
title: team?.name ?? 'Team',
policy: null,
});
}}
>
Add allowance
</Button>
</div>
) : null}
</div>
<SectionHeader
title="Team allowances"
actions={
teamsWithoutPolicy.length > 0 ? (
<>
<Select value={teamPick} onValueChange={setTeamPick}>
<SelectTrigger className="w-52" aria-label="Team">
<SelectValue placeholder="Choose a team" />
</SelectTrigger>
<SelectContent>
{teamsWithoutPolicy.map((team) => (
<SelectItem key={team.id} value={String(team.id)}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={!teamPick}
onClick={() => {
const team = teams.find((tm) => String(tm.id) === teamPick);
setEditing({
scope: 'team',
subjectId: teamPick,
title: team?.name ?? 'Team',
policy: null,
});
}}
>
Add allowance
</Button>
</>
) : null
}
/>
{teamPolicies.length === 0 ? (
<p className="text-muted-foreground mt-1 text-sm">
No team has an allowance.
@@ -262,7 +285,7 @@ export default function Quotas() {
</section>
<section>
<p className="text-foreground font-bold">User overrides</p>
<SectionHeader title="User overrides" />
{userPolicies.length === 0 ? (
<p className="text-muted-foreground mt-1 text-sm">
No user has an override. Add one from a user&apos;s menu on the
+64 -57
View File
@@ -20,16 +20,14 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import { useDarkTheme } from '../hooks';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { selectToken } from '../preferences/preferenceSlice';
import {
Loading,
LoadError,
StatCard,
fmtMs,
fmtNumber,
fmtUsd,
} from './AdminUI';
import { LoadingState } from '@/components/ui/loading-state';
import StatCard from '@/components/StatCard';
import { Card } from '@/components/ui/card';
import { SectionHeader } from '@/components/ui/section-header';
import { LoadError, fmtMs, fmtNumber, fmtUsd } from './AdminUI';
import { useChartPalette } from '../utils/chartUtils';
import UsageChart from './UsageChart';
import {
GROUP_OPTIONS,
@@ -54,7 +52,8 @@ const RANGES = [7, 30, 90];
export default function Usage() {
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
// Re-read on every theme change; Chart.js can't read CSS variables.
const palette = useChartPalette();
const [days, setDays] = useState(30);
const [groupBy, setGroupBy] = useState<GroupBy>('none');
const [metric, setMetric] = useState<Metric>('tokens');
@@ -70,6 +69,8 @@ export default function Usage() {
token,
);
setData(await res.json().catch(() => ({})));
} catch {
setData({});
} finally {
setLoading(false);
}
@@ -84,33 +85,32 @@ export default function Usage() {
const latency: Latency | undefined = data?.latency;
const chartData = useMemo(
() => buildUsageChart(series, groupBy, metric),
// isDarkTheme re-resolves the canvas colors when the theme toggles.
[series, groupBy, metric, isDarkTheme],
() => buildUsageChart(series, groupBy, metric, palette),
[series, groupBy, metric, palette],
);
const cacheHitRate = useMemo(() => computeCacheHitRate(series), [series]);
if (data === null && loading) return <Loading />;
if (data === null && loading) return <LoadingState fill="block" />;
if (data && !data.success)
return <LoadError message="Failed to load usage." />;
return <LoadError message="Failed to load usage." onRetry={load} />;
return (
<div className="mt-6">
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
<ToggleGroup
type="single"
size="sm"
value={String(days)}
onValueChange={(value) => value && setDays(Number(value))}
aria-label="Range"
>
{RANGES.map((range) => (
<Button
key={range}
variant={range === days ? 'default' : 'outline'}
size="sm"
shape="pill"
onClick={() => setDays(range)}
>
<ToggleGroupItem key={range} value={String(range)}>
{range}d
</Button>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
<Select
value={groupBy}
onValueChange={(value) => setGroupBy(value as GroupBy)}
@@ -126,27 +126,19 @@ export default function Usage() {
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Button
variant={metric === 'tokens' ? 'default' : 'outline'}
size="sm"
shape="pill"
onClick={() => setMetric('tokens')}
>
Tokens
</Button>
<Button
variant={metric === 'cost' ? 'default' : 'outline'}
size="sm"
shape="pill"
onClick={() => setMetric('cost')}
>
Cost
</Button>
</div>
<ToggleGroup
type="single"
size="sm"
value={metric}
onValueChange={(value) => value && setMetric(value as Metric)}
aria-label="Metric"
>
<ToggleGroupItem value="tokens">Tokens</ToggleGroupItem>
<ToggleGroupItem value="cost">Cost</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label={`Spend (${days}d)`}
value={fmtUsd(data?.total_cost)}
@@ -176,17 +168,23 @@ export default function Usage() {
/>
</div>
<div className="border-border mt-4 h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5">
<Card
variant="subtle"
padding="lg"
className="mt-4 h-[345px] w-full overflow-hidden"
>
<div className="flex flex-row items-center justify-between gap-3">
<p className="text-foreground font-bold">
{metric === 'cost' ? 'Spend' : 'Token usage'}
</p>
<SectionHeader
as="h3"
size="xs"
title={metric === 'cost' ? 'Spend' : 'Token usage'}
/>
<div
id="admin-usage-legend"
className="flex flex-row items-center justify-end"
></div>
</div>
<div className="relative mt-px h-[260px] w-full">
<div className="relative h-[260px] w-full">
{loading ? (
<SkeletonLoader count={1} component={'analysis'} />
) : series.length === 0 ? (
@@ -198,13 +196,19 @@ export default function Usage() {
data={chartData}
legendID="admin-usage-legend"
currency={metric === 'cost'}
gridColor={palette.border}
tickColor={palette.mutedForeground}
/>
)}
</div>
</div>
</Card>
<div className="border-border mt-4 w-full overflow-hidden rounded-2xl border px-6 py-5">
<p className="text-foreground mb-3 font-bold">Top users</p>
<Card
variant="subtle"
padding="lg"
className="mt-4 w-full overflow-hidden"
>
<SectionHeader as="h3" size="xs" title="Top users" />
{topUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">No usage.</p>
) : (
@@ -221,13 +225,16 @@ export default function Usage() {
{topUsers.map((user) => (
<TableRow key={user.user_id}>
<TableCell className="font-mono text-xs break-all">
<button
<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"
variant="link"
size="inline"
// eslint-disable-next-line shadcn/no-restyle -- the id keeps the cell's 12px mono type and wraps
className="text-left font-mono text-xs font-normal whitespace-normal"
onClick={() => setDrilldown(user.user_id)}
>
{user.user_id}
</button>
</Button>
</TableCell>
<TableCell
align="right"
@@ -247,7 +254,7 @@ export default function Usage() {
</Table>
</TableContainer>
)}
</div>
</Card>
<UserUsageModal userId={drilldown} onClose={() => setDrilldown(null)} />
</div>
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { seriesColor, usageColors } from './UsageChart';
const palette = {
primary: 'c1',
series: ['c1', 'c2', 'c3', 'c4', 'c5'],
success: 's',
warning: 'w',
destructive: 'd',
border: 'b',
mutedForeground: 'm',
};
describe('usageColors', () => {
it('draws prompt in chart-1 and generated in chart-2', () => {
expect(usageColors(palette)).toEqual({ prompt: 'c1', generated: 'c2' });
});
it('reads the theme tokens when no palette is passed', () => {
expect(usageColors()).toEqual({ prompt: '#7d54d1', generated: '#2563eb' });
});
});
describe('seriesColor', () => {
it('walks chart-1 to chart-5 in order', () => {
expect([0, 1, 2, 3, 4].map((i) => seriesColor(i, palette))).toEqual(
palette.series,
);
});
it('never leaves the five chart tokens', () => {
expect(seriesColor(5, palette)).toBe('c1');
});
});
+57 -41
View File
@@ -9,7 +9,12 @@ import {
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
import { htmlLegendPlugin } from '../utils/chartUtils';
import {
hoverColor,
htmlLegendPlugin,
readChartPalette,
type ChartPalette,
} from '../utils/chartUtils';
import type { ChartData } from 'chart.js';
@@ -22,40 +27,38 @@ ChartJS.register(
Legend,
);
// Chart.js renders to a canvas and can't read CSS variables, so resolve the
// theme color to a concrete string at render time (mirrors settings/Analytics).
function readCssVar(name: string, fallback: string): string {
if (typeof window === 'undefined') return fallback;
const value = getComputedStyle(document.body).getPropertyValue(name).trim();
return value || fallback;
/**
* Colours for the ungrouped Prompt / Generated pair.
*
* Same as settings/Analytics: prompt is `chart-1` (the brand), generated is
* `chart-2`. Chart.js renders to a canvas, so these are resolved strings.
*
* @param palette The resolved theme palette; read from the DOM when omitted.
* @returns The two bar colours.
*/
export function usageColors(palette: ChartPalette = readChartPalette()): {
prompt: string;
generated: string;
} {
return { prompt: palette.series[0], generated: palette.series[1] };
}
export function usageColors(): { prompt: string; generated: string } {
return {
// Violet + pink is the established DocsGPT 2-series pairing (Analytics uses
// --primary + SERIES_COLORS[0]); far more colorblind-separable than blue.
prompt: readCssVar('--primary', '#7D54D1'),
generated: '#FF6384',
};
}
// Follow-on colors for a grouped chart (by model / agent / source), matching
// the palette settings/Analytics already uses for its stacked series.
const SERIES_COLORS = [
'#FF6384',
'#36A2EB',
'#FFCE56',
'#4BC0C0',
'#9966FF',
'#FF9F40',
'#2BC596',
];
/** Color for dataset ``index``; the first uses the resolved brand color. */
export function seriesColor(index: number): string {
return index === 0
? readCssVar('--primary', '#7D54D1')
: SERIES_COLORS[(index - 1) % SERIES_COLORS.length];
/**
* Colour for dataset `index` of a grouped chart (by model / agent / flow).
*
* Groups have no meaning of their own, so they take `chart-1` to `chart-5` in
* order (DESIGN.md, chart colours). Callers fold anything past five into
* "Other" with `foldSeries`, so the modulo never repeats a colour in practice.
*
* @param index The dataset's position.
* @param palette The resolved theme palette; read from the DOM when omitted.
* @returns The bar colour.
*/
export function seriesColor(
index: number,
palette: ChartPalette = readChartPalette(),
): string {
return palette.series[index % palette.series.length];
}
function compactTick(value: number | string): string {
@@ -82,6 +85,10 @@ type UsageChartProps = {
maxTicksLimitInX?: number;
/** Format the y axis as USD rather than a token count. */
currency?: boolean;
/** Grid lines and axis borders (`--border`). */
gridColor: string;
/** Axis tick labels and legend text (`--muted-foreground`). */
tickColor: string;
};
export default function UsageChart({
@@ -89,25 +96,31 @@ export default function UsageChart({
legendID,
maxTicksLimitInX = 8,
currency = false,
gridColor,
tickColor,
}: UsageChartProps) {
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
// The HTML legend copies each item's fontColor, which is this.
legend: { display: false, labels: { color: tickColor } },
htmlLegend: { containerID: legendID },
},
scales: {
x: {
grid: { lineWidth: 0.2, color: '#C4C4C4' },
border: { width: 0.2, color: '#C4C4C4' },
ticks: { maxTicksLimit: maxTicksLimitInX },
grid: { lineWidth: 0.2, color: gridColor },
border: { width: 0.2, color: gridColor },
ticks: { maxTicksLimit: maxTicksLimitInX, color: tickColor },
stacked: true,
},
y: {
grid: { lineWidth: 0.2, color: '#C4C4C4' },
border: { width: 0.2, color: '#C4C4C4' },
ticks: { callback: currency ? currencyTick : compactTick },
grid: { lineWidth: 0.2, color: gridColor },
border: { width: 0.2, color: gridColor },
ticks: {
callback: currency ? currencyTick : compactTick,
color: tickColor,
},
stacked: true,
},
},
@@ -120,7 +133,10 @@ export default function UsageChart({
...data,
datasets: data.datasets.map((dataset) => ({
...dataset,
hoverBackgroundColor: `${dataset.backgroundColor}CC`,
hoverBackgroundColor:
typeof dataset.backgroundColor === 'string'
? hoverColor(dataset.backgroundColor)
: dataset.backgroundColor,
})),
}}
/>
+9 -9
View File
@@ -5,7 +5,9 @@ import adminService from '../api/services/adminService';
import teamsService from '../api/services/teamsService';
import { Modal } from '../components/ui/modal';
import { selectToken } from '../preferences/preferenceSlice';
import { LoadError, Loading, fmtDate } from './AdminUI';
import { LoadingState } from '@/components/ui/loading-state';
import { SectionHeader } from '@/components/ui/section-header';
import { LoadError, fmtDate } from './AdminUI';
import QuotaEditor, { UsageBar } from './QuotaEditor';
import {
sourceLabel,
@@ -79,13 +81,13 @@ export default function UserQuotaModal({
title={userId ? `Quota · ${userId}` : 'Quota'}
>
{data === null ? (
<Loading />
<LoadingState fill="block" />
) : !data.success ? (
<LoadError message="Failed to load this user's quota." />
<LoadError message="Failed to load this user's quota." onRetry={load} />
) : (
<div className="space-y-5">
<div className="flex flex-col gap-5">
{overall ? (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<UsageBar
label={`Tokens this ${data.period}`}
kind="tokens"
@@ -103,10 +105,8 @@ export default function UserQuotaModal({
</p>
</div>
) : null}
<div className="border-border border-t pt-4">
<p className="text-foreground mb-2 text-sm font-bold">
User override
</p>
<div className="border-border flex flex-col gap-2 border-t pt-4">
<SectionHeader as="h3" size="xs" title="User override" />
<QuotaEditor
scope="user"
subjectId={userId}
+52 -29
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService from '../api/services/adminService';
import { Button } from '../components/ui/button';
import { Modal } from '../components/ui/modal';
import {
Table,
@@ -13,10 +12,15 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import { useDarkTheme } from '../hooks';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { selectToken } from '../preferences/preferenceSlice';
import { formatDate } from '../utils/dateTimeUtils';
import { Loading, LoadError, StatCard, fmtNumber, fmtUsd } from './AdminUI';
import { Card } from '@/components/ui/card';
import { LoadingState } from '@/components/ui/loading-state';
import { SectionHeader } from '@/components/ui/section-header';
import StatCard from '@/components/StatCard';
import { LoadError, fmtNumber, fmtUsd } from './AdminUI';
import { useChartPalette } from '../utils/chartUtils';
import UsageChart, { usageColors } from './UsageChart';
type Bucket = {
@@ -106,10 +110,12 @@ export default function UserUsageModal({
onClose: () => void;
}) {
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
// Re-read on every theme change; Chart.js can't read CSS variables.
const palette = useChartPalette();
const [days, setDays] = useState(30);
const [data, setData] = useState<any | null>(null);
const [loading, setLoading] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
if (!userId) {
@@ -132,11 +138,11 @@ export default function UserUsageModal({
return () => {
cancelled = true;
};
}, [userId, days, token]);
}, [userId, days, token, reloadKey]);
const series: Bucket[] = data?.series ?? [];
const chartData = useMemo(() => {
const colors = usageColors();
const colors = usageColors(palette);
return {
labels: series.map((bucket) => formatDate(bucket.bucket)),
datasets: [
@@ -152,8 +158,7 @@ export default function UserUsageModal({
},
],
};
// isDarkTheme re-resolves the canvas colors when the theme toggles.
}, [series, isDarkTheme]);
}, [series, palette]);
const totals = data?.totals ?? {};
@@ -164,38 +169,54 @@ export default function UserUsageModal({
title={userId ? `Usage · ${userId}` : 'Usage'}
size="lg"
>
<div className="mb-4 flex items-center gap-1">
<ToggleGroup
type="single"
size="sm"
value={String(days)}
onValueChange={(value) => value && setDays(Number(value))}
aria-label="Range"
className="mb-4"
>
{RANGES.map((range) => (
<Button
key={range}
variant={range === days ? 'default' : 'outline'}
size="sm"
shape="pill"
onClick={() => setDays(range)}
>
<ToggleGroupItem key={range} value={String(range)}>
{range}d
</Button>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
{loading ? (
<Loading />
<LoadingState fill="block" />
) : data && !data.success ? (
<LoadError message="Failed to load usage." />
<LoadError
message="Failed to load usage."
onRetry={() => setReloadKey((k) => k + 1)}
/>
) : (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<StatCard label="Cost" value={fmtUsd(totals.cost)} />
<StatCard label="Tokens" value={fmtNumber(totals.tokens)} />
<StatCard label="Calls" value={fmtNumber(totals.calls)} />
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-4">
<StatCard
variant="outline"
label="Cost"
value={fmtUsd(totals.cost)}
/>
<StatCard
variant="outline"
label="Tokens"
value={fmtNumber(totals.tokens)}
/>
<StatCard
variant="outline"
label="Calls"
value={fmtNumber(totals.calls)}
/>
</div>
<div className="border-border h-64 rounded-2xl border px-4 py-3">
<Card variant="outline" padding="default" className="h-66">
<div className="flex items-center justify-between">
<p className="text-foreground text-sm font-bold">Daily tokens</p>
<SectionHeader as="h3" size="xs" title="Daily tokens" />
<div id="admin-user-usage-legend" className="flex" />
</div>
<div className="relative mt-px h-48 w-full">
<div className="relative h-48 w-full">
{series.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">
No usage in this period.
@@ -204,10 +225,12 @@ export default function UserUsageModal({
<UsageChart
data={chartData}
legendID="admin-user-usage-legend"
gridColor={palette.border}
tickColor={palette.mutedForeground}
/>
)}
</div>
</div>
</Card>
<SplitTable title="Model" rows={data?.by_model ?? []} />
<SplitTable
+51 -65
View File
@@ -22,7 +22,6 @@ import {
DropdownMenuTrigger,
type MenuOption,
} from '../components/ui/dropdown-menu';
import { Input } from '../components/ui/input';
import { Modal } from '../components/ui/modal';
import {
Table,
@@ -33,12 +32,19 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import SearchInput from '../components/SearchInput';
import {
DescriptionItem,
DescriptionList,
} from '../components/ui/description-list';
import { LoadingState } from '../components/ui/loading-state';
import { Pagination } from '../components/ui/pagination';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import {
Loading,
LoadError,
eventLabel,
fmtDateShort,
fmtNumber,
@@ -74,6 +80,7 @@ export default function Users() {
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [menuUserId, setMenuUserId] = useState<string | null>(null);
const [detail, setDetail] = useState<any | null>(null);
@@ -88,6 +95,7 @@ export default function Users() {
const load = useCallback(async () => {
setLoading(true);
setFailed(false);
try {
const [usersRes, adminsRes] = await Promise.all([
adminService.getUsers(
@@ -103,6 +111,9 @@ export default function Users() {
setAdminIds(
new Set((adminsJson.admins ?? []).map((a: any) => a.user_id)),
);
setFailed(!usersRes.ok || usersJson.success === false);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
@@ -251,17 +262,18 @@ export default function Users() {
};
return (
<div className="mt-6">
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="Filter by user id"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applySearch();
}}
className="max-w-xs"
/>
<div className="w-full max-w-xs">
<SearchInput
label="Filter by user id"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applySearch();
}}
/>
</div>
<Button variant="outline" size="sm" onClick={applySearch}>
Search
</Button>
@@ -281,7 +293,9 @@ export default function Users() {
</div>
{loading ? (
<Loading />
<LoadingState fill="block" />
) : failed ? (
<LoadError message="Failed to load users." onRetry={load} />
) : users.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">No users found.</p>
) : (
@@ -372,29 +386,12 @@ export default function Users() {
</TableBody>
</Table>
</TableContainer>
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{fmtNumber(total)} users · page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
<Pagination
page={page}
pageCount={totalPages}
onPageChange={setPage}
summary={`${fmtNumber(total)} users`}
/>
</>
)}
@@ -404,7 +401,7 @@ export default function Users() {
modalState={confirmState}
setModalState={setConfirmState}
submitLabel={confirm.submitLabel}
variant="danger"
variant="destructive"
handleSubmit={() => {
confirm.run();
setConfirm(null);
@@ -460,7 +457,7 @@ export default function Users() {
}
>
{detail ? (
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-4 text-sm">
<div>
<p className="text-muted-foreground mb-1 text-xs">
Roles & status
@@ -481,35 +478,24 @@ export default function Users() {
)}
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Agents</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.agents)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sources</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.sources)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Conversations</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.conversations)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tokens (30d)</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.tokens_30d)}
</span>
</div>
</div>
<DescriptionList layout="justified" size="sm" columns={2}>
<DescriptionItem label="Agents">
{fmtNumber(detail.counts?.agents)}
</DescriptionItem>
<DescriptionItem label="Sources">
{fmtNumber(detail.counts?.sources)}
</DescriptionItem>
<DescriptionItem label="Conversations">
{fmtNumber(detail.counts?.conversations)}
</DescriptionItem>
<DescriptionItem label="Tokens (30d)">
{fmtNumber(detail.counts?.tokens_30d)}
</DescriptionItem>
</DescriptionList>
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => {
// Close the detail dialog before the usage dialog opens, so
// the two never stack.
@@ -524,7 +510,7 @@ export default function Users() {
<p className="text-muted-foreground mb-1 text-xs">
Recent auth events
</p>
<div className="max-h-64 space-y-1 overflow-auto">
<div className="flex max-h-64 flex-col gap-1 overflow-auto">
{(detail.recent_events ?? []).length === 0 ? (
<p className="text-muted-foreground">None</p>
) : (
+23 -25
View File
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { useMediaQuery } from '../hooks';
import SectionIndexPage from '../navigation/SectionIndexPage';
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
import SectionShell from '../navigation/SectionShell';
import { ADMIN_SECTION } from '../navigation/sections';
import Admins from './Admins';
import Activity from './Activity';
@@ -18,32 +18,30 @@ import Users from './Users';
*/
export default function Admin() {
const location = useLocation();
const { isMobile, isTablet } = useMediaQuery();
const { isMobile } = useMediaQuery();
const showIndex =
(isMobile || isTablet) && location.pathname === ADMIN_SECTION.rootPath;
const showIndex = isMobile && location.pathname === ADMIN_SECTION.rootPath;
if (showIndex) {
return (
<SectionShell width="wide" header={false}>
<SectionIndexPage section={ADMIN_SECTION} />
</SectionShell>
);
}
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-7xl">
{showIndex ? (
<SectionIndexPage section={ADMIN_SECTION} />
) : (
<>
<CurrentSectionHeader />
<Routes>
<Route index element={<Overview />} />
<Route path="overview" element={<Overview />} />
<Route path="users" element={<Users />} />
<Route path="roles" element={<Admins />} />
<Route path="usage" element={<Usage />} />
<Route path="quotas" element={<Quotas />} />
<Route path="audit" element={<Activity />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</>
)}
</div>
</div>
<SectionShell width="wide">
<Routes>
<Route index element={<Overview />} />
<Route path="overview" element={<Overview />} />
<Route path="users" element={<Users />} />
<Route path="roles" element={<Admins />} />
<Route path="usage" element={<Usage />} />
<Route path="quotas" element={<Quotas />} />
<Route path="audit" element={<Activity />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</SectionShell>
);
}
+28
View File
@@ -68,6 +68,34 @@ describe('buildUsageChart', () => {
expect(chart.datasets[0].label).toBe('unknown');
});
it('folds more than five groups into four plus Other in chart-5', () => {
const keys = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
const chart = buildUsageChart(
keys.map((key, i) =>
// 'a' is the smallest; b..e are the four largest.
bucket({ group_key: key, prompt_tokens: i === 0 ? 1 : 100 - i * 10 }),
),
'model',
'tokens',
);
expect(chart.datasets.map((d) => d.label)).toEqual([
'b',
'c',
'd',
'e',
'Other',
]);
expect(chart.datasets.map((d) => d.backgroundColor)).toEqual([
'color-0',
'color-1',
'color-2',
'color-3',
'color-4',
]);
// Other sums a, f and g (prompt 1, 50, 40, plus 20 generated each).
expect(chart.datasets[4].data).toEqual([1 + 50 + 40 + 60]);
});
it('handles an empty series', () => {
const chart = buildUsageChart([], 'model', 'cost');
expect(chart.labels).toEqual([]);
+30 -11
View File
@@ -1,3 +1,5 @@
import { foldSeries, OTHER_SERIES_KEY } from '../settings/foldSeries';
import type { ChartPalette } from '../utils/chartUtils';
import { formatDate } from '../utils/dateTimeUtils';
import { seriesColor, usageColors } from './UsageChart';
@@ -34,12 +36,20 @@ function valueOf(row: UsageBucket, metric: Metric): number {
* Ungrouped gives one dataset per token kind (or a single cost series);
* grouped gives one dataset per group key, each aligned to the shared bucket
* axis so a group missing a day plots 0 rather than shifting the whole series
* left by one bar.
* left by one bar. Past five groups, the four largest stay and the rest sum
* into one "Other" series (`foldSeries`), so no chart colour repeats.
*
* @param series Usage rows from the server.
* @param groupBy How the rows are grouped.
* @param metric Tokens or cost.
* @param palette The resolved theme palette; read from the DOM when omitted.
* @returns Chart.js bar data.
*/
export function buildUsageChart(
series: UsageBucket[],
groupBy: GroupBy,
metric: Metric,
palette?: ChartPalette,
) {
const buckets = Array.from(new Set(series.map((row) => row.bucket))).sort();
const labels = buckets.map((bucket) => formatDate(bucket));
@@ -52,12 +62,12 @@ export function buildUsageChart(
{
label: 'Cost',
data: series.map((row) => row.cost),
backgroundColor: seriesColor(0),
backgroundColor: seriesColor(0, palette),
},
],
};
}
const colors = usageColors();
const colors = usageColors(palette);
return {
labels,
datasets: [
@@ -78,20 +88,29 @@ export function buildUsageChart(
const keys = Array.from(
new Set(series.map((row) => row.group_key ?? 'unknown')),
);
return {
labels,
datasets: keys.map((key, index) => {
// Each group aligned to the shared bucket axis, then folded past five.
const entries = foldSeries(
keys.map((key): [string, Record<string, number>] => {
const byBucket = new Map(
series
.filter((row) => (row.group_key ?? 'unknown') === key)
.map((row) => [row.bucket, valueOf(row, metric)]),
);
return {
label: key,
data: buckets.map((bucket) => byBucket.get(bucket) ?? 0),
backgroundColor: seriesColor(index),
};
return [
key,
Object.fromEntries(
buckets.map((bucket) => [bucket, byBucket.get(bucket) ?? 0]),
),
];
}),
);
return {
labels,
datasets: entries.map(([key, values], index) => ({
label: key === OTHER_SERIES_KEY ? 'Other' : key,
data: buckets.map((bucket) => values[bucket] ?? 0),
backgroundColor: seriesColor(index, palette),
})),
};
}
+16 -15
View File
@@ -19,6 +19,7 @@ import userService from '../api/services/userService';
import { Avatar } from '../components/ui/avatar';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card, CardDescription, CardTitle } from '../components/ui/card';
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
import { Modal } from '../components/ui/modal';
import ConfirmationModal from '../modals/ConfirmationModal';
@@ -277,11 +278,14 @@ export default function AgentCard({
updateAgents?.(updatedAgents);
};
return (
<div
<Card
variant="filled"
interactive={agent.status === 'published'}
padding="lg"
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-3 sm:w-48 sm:px-6 ${agent.status === 'published' && 'cursor-pointer'}`}
className="relative h-44 justify-between"
onClick={(e) => {
e.stopPropagation();
handleClick();
@@ -306,7 +310,7 @@ export default function AgentCard({
(right-11 clears the 28px trigger at right-3) so the two align. */}
{agent.ownership === 'team' && (
<Badge variant="neutral" className="absolute top-4 right-11 z-10">
<Users size={11} aria-hidden="true" />
<Users aria-hidden="true" />
{agent.team_access === 'editor'
? t('agents.teamBadge.editor')
: t('agents.teamBadge.viewer')}
@@ -317,9 +321,9 @@ export default function AgentCard({
<Avatar
src={agent.image}
alt={`${agent.name}`}
size="sm"
size="xs"
shape="circle"
imgClassName="h-7 w-7 object-contain"
imgClassName="size-7 object-contain"
/>
{agent.status === 'draft' && (
<p className="text-foreground text-xs opacity-50">
@@ -327,16 +331,13 @@ export default function AgentCard({
</p>
)}
</div>
<div className="mt-2">
<p
title={agent.name}
className="text-foreground truncate px-1 text-sm leading-relaxed font-semibold capitalize"
>
<div className="mt-2 px-1">
<CardTitle title={agent.name} className="truncate capitalize">
{agent.name}
</p>
<p className="dark:text-muted-foreground text-muted-foreground mt-1 h-20 overflow-auto px-1 text-xs leading-relaxed">
</CardTitle>
<CardDescription size="xs" className="mt-1 line-clamp-3">
{agent.description}
</p>
</CardDescription>
</div>
</div>
<ConfirmationModal
@@ -349,7 +350,7 @@ export default function AgentCard({
setDeleteConfirmation('INACTIVE');
}}
cancelLabel={t('cancel')}
variant="danger"
variant="destructive"
/>
<Modal
open={exportError !== null}
@@ -387,6 +388,6 @@ export default function AgentCard({
onClose={() => setShareModalOpen(false)}
/>
)}
</div>
</Card>
);
}
+26 -27
View File
@@ -8,8 +8,7 @@ import { selectToken } from '../preferences/preferenceSlice';
import Analytics from '../settings/Analytics';
import Logs from '../settings/Logs';
import { formatDateTime } from '../utils/dateTimeUtils';
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
import SectionPills from '../navigation/SectionPills';
import SectionShell from '../navigation/SectionShell';
import GuardrailEvents from './components/GuardrailEvents';
import { Agent } from './types';
@@ -40,35 +39,35 @@ export default function AgentLogs() {
}, [agentId, token]);
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<SectionPills className="mt-4" />
<div className="mt-6 flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
{agentId && (
<>
<SectionShell pills>
<div className="flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
{agentId && (
<>
<div className="mt-8">
<Analytics agentId={agentId} />
<GuardrailEvents agentId={agentId} />
</div>
<GuardrailEvents agentId={agentId} />
<div className="mt-8">
<Logs
agentId={agentId}
tableHeader={t('agents.logs.tableHeader')}
/>
</>
)}
</div>
</div>
</div>
</>
)}
</SectionShell>
);
}
+1 -1
View File
@@ -120,7 +120,7 @@ export default function AgentPreview() {
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-foreground text-sm font-medium">
Test your agent
{t('agents.preview.emptyTitle')}
</p>
</section>
) : (
+54 -61
View File
@@ -1,4 +1,3 @@
import { Search } from 'lucide-react';
import {
Fragment,
useCallback,
@@ -39,7 +38,9 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '../components/ui/breadcrumb';
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
import SectionShell from '../navigation/SectionShell';
import PageToolbar from '../components/PageToolbar';
import SearchInput from '../components/SearchInput';
import SectionPills from '../navigation/SectionPills';
import { useAgentSearch } from './hooks/useAgentSearch';
import { agentsListPath, filterFromPath } from './paths';
@@ -173,69 +174,62 @@ export default function AgentsList() {
activeFilter === 'all';
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<p className="text-muted-foreground mt-5 text-sm leading-6">
{t('agents.description')}
</p>
<SectionShell>
<p className="text-muted-foreground text-sm leading-6">
{t('agents.description')}
</p>
<SectionPills className="mt-6" />
<SectionPills className="mt-6" />
<div className="mt-6 flex flex-col gap-4 pb-4">
<div className="w-full max-w-md">
<Input
type="text"
<div className="mt-6">
<PageToolbar
search={
<SearchInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
label={t('agents.searchPlaceholder')}
labelSurface="background"
shape="pill"
leftIcon={<Search className="text-muted-foreground size-4" />}
/>
</div>
</div>
{visibleSections.map((sectionConfig) => (
<AgentSection
key={sectionConfig.id}
config={sectionConfig}
filteredAgents={
filteredAgentsBySection[sectionConfig.id as AgentSectionId]
}
totalAgents={
totalAgentsBySection[sectionConfig.id as AgentSectionId]
}
searchQuery={searchQuery}
isFilteredView={activeFilter !== 'all'}
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
folders={sectionConfig.id === 'user' ? folders : null}
folderPath={sectionConfig.id === 'user' ? folderPath : []}
onFolderPathChange={
sectionConfig.id === 'user' ? setFolderPath : undefined
}
onCreateFolder={handleSubmitNewFolder}
onDeleteFolder={handleDeleteFolder}
onRenameFolder={handleRenameFolder}
setModalFolderId={setModalFolderId}
setShowAgentTypeModal={setShowAgentTypeModal}
/>
))}
{showSearchEmptyState && (
<div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-2">
<p className="text-lg">{t('agents.noSearchResults')}</p>
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
)}
<AgentTypeModal
isOpen={showAgentTypeModal}
onClose={() => setShowAgentTypeModal(false)}
folderId={modalFolderId}
}
/>
</div>
</div>
{visibleSections.map((sectionConfig) => (
<AgentSection
key={sectionConfig.id}
config={sectionConfig}
filteredAgents={
filteredAgentsBySection[sectionConfig.id as AgentSectionId]
}
totalAgents={totalAgentsBySection[sectionConfig.id as AgentSectionId]}
searchQuery={searchQuery}
isFilteredView={activeFilter !== 'all'}
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
folders={sectionConfig.id === 'user' ? folders : null}
folderPath={sectionConfig.id === 'user' ? folderPath : []}
onFolderPathChange={
sectionConfig.id === 'user' ? setFolderPath : undefined
}
onCreateFolder={handleSubmitNewFolder}
onDeleteFolder={handleDeleteFolder}
onRenameFolder={handleRenameFolder}
setModalFolderId={setModalFolderId}
setShowAgentTypeModal={setShowAgentTypeModal}
/>
))}
{showSearchEmptyState && (
<div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-2">
<p className="text-lg">{t('agents.noSearchResults')}</p>
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
)}
<AgentTypeModal
isOpen={showAgentTypeModal}
onClose={() => setShowAgentTypeModal(false)}
folderId={modalFolderId}
/>
</SectionShell>
);
}
@@ -568,14 +562,14 @@ function AgentSection({
<div className="flex flex-col gap-4">
{isLoading ? (
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<SkeletonLoader component="agentCards" count={4} />
</div>
) : (
<>
{/* Show subfolders at current level */}
{config.id === 'user' && currentLevelFolders.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{currentLevelFolders.map((folder) => (
<FolderCard
key={folder.id}
@@ -583,7 +577,6 @@ function AgentSection({
agentCount={getAgentsForFolder(folder.id).length}
onDelete={onDeleteFolder}
onRename={onRenameFolder}
isExpanded={false}
onToggleExpand={handleNavigateIntoFolder}
/>
))}
@@ -592,7 +585,7 @@ function AgentSection({
{/* Show agents at current level */}
{unfolderedAgents.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{unfolderedAgents.map((agent) => (
<AgentCard
key={agent.id}
+11 -13
View File
@@ -2,10 +2,12 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pencil, Trash2 } from 'lucide-react';
import { Card, CardTitle } from '../components/ui/card';
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
import ConfirmationModal from '../modals/ConfirmationModal';
import FolderNameModal from '../modals/FolderManagementModal';
import { ActiveState } from '../models/misc';
import { AgentFolder } from './types';
type FolderCardProps = {
@@ -13,7 +15,6 @@ type FolderCardProps = {
agentCount: number;
onDelete: (folderId: string) => Promise<boolean>;
onRename: (folderId: string, newName: string) => void;
isExpanded: boolean;
onToggleExpand: (folderId: string) => void;
};
@@ -22,7 +23,6 @@ export default function FolderCard({
agentCount,
onDelete,
onRename,
isExpanded,
onToggleExpand,
}: FolderCardProps) {
const { t } = useTranslation();
@@ -51,13 +51,13 @@ export default function FolderCard({
return (
<>
<div
<Card
variant="filled"
interactive
padding="default"
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-3 sm:w-48 ${
isExpanded ? 'bg-accent' : 'bg-muted hover:bg-accent'
}`}
className="relative flex-row items-center justify-between"
onClick={() => onToggleExpand(folder.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -67,9 +67,7 @@ export default function FolderCard({
}}
>
<div className="flex items-center gap-2 overflow-hidden">
<span className="text-foreground truncate text-sm font-medium">
{folder.name}
</span>
<CardTitle className="truncate">{folder.name}</CardTitle>
<span className="text-muted-foreground shrink-0 text-xs">
({agentCount})
</span>
@@ -78,12 +76,12 @@ export default function FolderCard({
options={menuOptions}
triggerLabel={t('agents.folders.menuAriaLabel', {
folderName: folder.name,
defaultValue: 'Folder actions',
interpolation: { escapeValue: false },
})}
align="end"
className="ml-2 shrink-0"
/>
</div>
</Card>
<ConfirmationModal
message={t('agents.folders.deleteConfirm')}
modalState={deleteConfirmation}
@@ -94,7 +92,7 @@ export default function FolderCard({
setDeleteConfirmation('INACTIVE');
}}
cancelLabel={t('cancel')}
variant="danger"
variant="destructive"
/>
<FolderNameModal
modalState={renameModalState}
+37
View File
@@ -182,6 +182,31 @@ describe('NewAgent form', () => {
}
});
it('titles each form panel with a SectionHeader spaced by the panel gap', async () => {
await render();
const titles = Array.from(
container.querySelectorAll('[data-slot="section-header"] > h2'),
).map((h) => h.textContent);
expect(titles).toEqual(
expect.arrayContaining([
'agents.form.sections.meta',
'agents.form.sections.source',
'agents.form.sections.tools',
'agents.form.sections.agentType',
'agents.form.sections.models',
]),
);
const meta = Array.from(
container.querySelectorAll('[data-slot="section-header"]'),
).find((h) => h.textContent === 'agents.form.sections.meta')!;
expect(meta.parentElement!.className.split(' ')).toEqual(
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
);
expect((meta.nextElementSibling as HTMLElement).className).not.toContain(
'mt-5',
);
});
// 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 () => {
@@ -249,6 +274,18 @@ describe('NewAgent form', () => {
expect(publish.disabled).toBe(true);
});
// Item 42: the header's Cancel (shown once the form is dirty) is ghost.
it('renders the header Cancel as a ghost pill once the form changes', async () => {
await render();
const name = container.querySelector<HTMLInputElement>(
'input[placeholder="agents.form.placeholders.agentName"]',
)!;
await act(async () => setNativeValue(name, 'Support bot'));
const cancel = buttonByText('agents.form.buttons.cancel');
expect(cancel.getAttribute('data-variant')).toBe('ghost');
expect(cancel.getAttribute('data-shape')).toBe('pill');
});
it('shows a failed save as a destructive alert with an icon', async () => {
await render();
const draft = buttonByText('agents.form.buttons.saveDraft');
+125 -138
View File
@@ -1,11 +1,5 @@
import isEqual from 'lodash/isEqual';
import {
ChevronRight,
CircleCheck,
CircleX,
Database,
MoreHorizontal,
} from 'lucide-react';
import { ChevronRight, CircleCheck, CircleX, Database } from 'lucide-react';
import React, {
useCallback,
useEffect,
@@ -21,12 +15,8 @@ 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,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Card } from '@/components/ui/card';
import { ActionMenu } from '@/components/ui/dropdown-menu';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
@@ -37,6 +27,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SectionHeader } from '@/components/ui/section-header';
import { SettingRow, SettingRows } from '@/components/ui/setting-row';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
@@ -225,7 +216,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
};
const agentTypes = [
{ label: t('agents.form.agentTypes.classic'), value: 'classic' },
{ label: 'Research', value: 'research' },
{ label: t('agents.form.agentTypes.research'), value: 'research' },
];
const isPublishable = () => {
@@ -597,7 +588,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const base: MultiSelectPopoverItem = {
id: tool.id,
label: getToolDisplayName(tool),
icon: <ToolIcon name={tool.name} className="h-5 w-5" />,
icon: <ToolIcon name={tool.name} className="size-5" />,
group: groupFor(tool),
};
if (tool.name === 'remote_device') {
@@ -812,7 +803,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const showAgentNav = effectiveMode === 'edit' && Boolean(agent.id);
return (
<div className="flex flex-col p-4 pb-2 max-[1179px]:min-h-dvh min-[1180px]:h-dvh md:p-12 md:pt-4 md:pb-3">
<div className="flex min-h-dvh flex-col p-4 pb-2 md:p-12 md:pt-4 md:pb-3 xl:h-dvh">
{agent.agent_type === 'workflow' && (
<div className="mt-4 w-full">
<WorkflowBuilder />
@@ -822,7 +813,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{showAgentNav ? <CurrentSectionHeader /> : <span aria-hidden />}
<div className="flex flex-wrap items-center gap-2">
{hasChanges && (
<Button type="button" variant="link" onClick={handleCancel}>
<Button
type="button"
variant="ghost"
shape="pill"
onClick={handleCancel}
>
{t('agents.form.buttons.cancel')}
</Button>
)}
@@ -848,31 +844,25 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{modeConfig[effectiveMode].buttonText}
</Button>
{modeConfig[effectiveMode].showAccessDetails && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t('agents.form.buttons.moreActions')}
title={t('agents.form.buttons.moreActions')}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setAgentDetails('ACTIVE')}>
{t('agents.form.buttons.accessDetails')}
</DropdownMenuItem>
{/* Sharing is owner-only — hidden for agents shared into the
workspace by a team (ownership === 'team'). */}
{agent.ownership !== 'team' && agent.id && (
<DropdownMenuItem onSelect={() => setShareModalOpen(true)}>
{t('agents.shareWithTeam')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<ActionMenu
triggerLabel={t('agents.form.buttons.moreActions')}
options={[
{
label: t('agents.form.buttons.accessDetails'),
onClick: () => setAgentDetails('ACTIVE'),
},
// Sharing is owner-only — hidden for agents shared into the
// workspace by a team (ownership === 'team').
...(agent.ownership !== 'team' && agent.id
? [
{
label: t('agents.shareWithTeam'),
onClick: () => setShareModalOpen(true),
},
]
: []),
]}
/>
)}
</div>
</div>
@@ -883,30 +873,31 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<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">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.meta')}
</h2>
<Input
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
size="lg"
className="mt-3 h-32"
placeholder={t('agents.form.placeholders.describeAgent')}
value={agent.description}
onChange={(e) =>
setAgent({ ...agent, description: e.target.value })
}
/>
<div className="mt-3">
<div className="bg-muted mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-2xl p-5 xl:grid xl:gap-5 xl:overflow-hidden">
<div className="scrollbar-overlay col-span-2 flex flex-col gap-5 xl:max-h-full xl:overflow-y-auto xl:pr-3">
<div className="bg-card flex flex-col gap-5 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.meta')} />
<div className="flex flex-col gap-5">
<FormField label={t('agents.form.labels.name')}>
<Input
shape="pill"
type="text"
value={agent.name}
placeholder={t('agents.form.placeholders.agentName')}
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
</FormField>
<FormField label={t('agents.form.labels.description')}>
<Textarea
size="lg"
className="h-32"
placeholder={t('agents.form.placeholders.describeAgent')}
value={agent.description}
onChange={(e) =>
setAgent({ ...agent, description: e.target.value })
}
/>
</FormField>
<FileUpload
showPreview
size="compact"
@@ -925,11 +916,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
/>
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.source')}
</h2>
<div className="mt-3">
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.source')} />
<div>
<div className="flex flex-wrap items-center gap-1">
<MultiSelectPopover
open={isSourcePopupOpen}
@@ -960,19 +949,23 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
size="field"
shape="pill"
ref={sourceAnchorButtonRef}
title={selectedSourceNames.join(', ')}
data-placeholder={
selectedSourceIds.size > 0 ? undefined : ''
}
className="w-full justify-start text-left"
>
<span className="truncate">{sourceTriggerLabel}</span>
<span
className="truncate"
title={selectedSourceNames.join(', ')}
>
{sourceTriggerLabel}
</span>
</Button>
}
/>
</div>
{selectedSourceIds.size === 0 && (
<p className="text-muted-foreground mt-2 text-xs">
<p className="text-muted-foreground mt-1.5 text-xs">
{t('agents.form.sourcePopup.noSourceHint')}
</p>
)}
@@ -1006,7 +999,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
setPrompts={(newPrompts) => dispatch(setPrompts(newPrompts))}
title={t('agents.form.sections.prompt')}
titleClassName="text-lg font-semibold"
titleAs="heading"
showAddButton={false}
dropdownProps={{ className: 'w-full' }}
/>
@@ -1023,11 +1016,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</Button>
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.tools')}
</h2>
<div className="mt-3 flex flex-wrap items-center gap-1">
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.tools')} />
<div className="flex flex-wrap items-center gap-1">
<MultiSelectPopover
open={isToolsPopupOpen}
onOpenChange={setIsToolsPopupOpen}
@@ -1079,18 +1070,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
/>
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.agentType')}
</h2>
<div className="mt-3">
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.agentType')} />
<div>
<Select
value={agent.agent_type || undefined}
onValueChange={(value) =>
setAgent({ ...agent, agent_type: value })
}
>
<SelectTrigger className="w-full" shape="pill" size="lg">
<SelectTrigger className="w-full" shape="pill" size="field">
<SelectValue
placeholder={t('agents.form.placeholders.selectType')}
/>
@@ -1105,11 +1094,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</Select>
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.models')}
</h2>
<div className="mt-3 flex flex-col gap-3">
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.models')} />
<div className="flex flex-col gap-3">
<MultiSelectPopover
open={isModelsPopupOpen}
onOpenChange={setIsModelsPopupOpen}
@@ -1174,7 +1161,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setAgent({ ...agent, default_model_id: value })
}
>
<SelectTrigger className="w-full" shape="pill" size="lg">
<SelectTrigger className="w-full" shape="pill" size="field">
<SelectValue
placeholder={t(
'agents.form.placeholders.selectDefaultModel',
@@ -1212,7 +1199,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform',
'transition-transform duration-200',
isAdvancedSectionExpanded && 'rotate-90',
)}
/>
@@ -1222,20 +1209,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</Button>
</h2>
{isAdvancedSectionExpanded && (
<div className="mt-3">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.jsonSchema')}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.advanced.jsonSchemaDescription')}
</p>
</div>
<Textarea
size="lg"
value={jsonSchemaText}
onChange={(e) => validateAndSetJsonSchema(e.target.value)}
placeholder={`{
<div className="mt-5">
<FormField
label={t('agents.form.advanced.jsonSchema')}
hint={t('agents.form.advanced.jsonSchemaDescription')}
>
<Textarea
size="lg"
value={jsonSchemaText}
onChange={(e) => validateAndSetJsonSchema(e.target.value)}
placeholder={`{
"type": "object",
"properties": {
"name": {"type": "string"},
@@ -1244,14 +1227,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
"required": ["name", "email"],
"additionalProperties": false
}`}
rows={9}
className="mt-2 font-mono"
/>
rows={9}
className="font-mono"
/>
</FormField>
{jsonSchemaText.trim() !== '' && (
<div
className={`mt-2 flex items-center gap-2 text-sm ${
jsonSchemaValid ? 'text-success' : 'text-destructive'
}`}
className={cn(
'mt-2 flex items-center gap-2 text-sm',
jsonSchemaValid ? 'text-success' : 'text-destructive',
)}
>
{jsonSchemaValid ? (
<CircleCheck className="size-4" aria-hidden="true" />
@@ -1288,6 +1273,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
placeholder={t(
'agents.form.placeholders.enterTokenLimit',
)}
aria-label={t('agents.form.advanced.tokenLimit')}
shape="pill"
/>
}
@@ -1329,6 +1315,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
placeholder={t(
'agents.form.placeholders.enterRequestLimit',
)}
aria-label={t('agents.form.advanced.requestLimit')}
shape="pill"
/>
}
@@ -1390,31 +1377,31 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
/>
{modeConfig[effectiveMode].showDelete && agent.id && (
<div className="border-destructive/40 bg-destructive/5 rounded-2xl border px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-destructive text-lg font-semibold">
{t('agents.form.dangerZone.heading')}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.dangerZone.description')}
</p>
</div>
<Button
type="button"
variant="destructive-outline"
size="sm"
onClick={() => setDeleteConfirmation('ACTIVE')}
className="shrink-0"
>
{t('agents.form.dangerZone.deleteButton')}
</Button>
</div>
</div>
<Card
tone="destructive"
padding="lg"
className="flex-row flex-wrap items-start justify-between"
>
<SectionHeader
tone="destructive"
title={t('agents.form.dangerZone.heading')}
description={t('agents.form.dangerZone.description')}
className="min-w-0 flex-1"
/>
<Button
type="button"
variant="destructive-outline"
size="sm"
onClick={() => setDeleteConfirmation('ACTIVE')}
className="shrink-0"
>
{t('agents.form.dangerZone.deleteButton')}
</Button>
</Card>
)}
</div>
<div className="col-span-3 flex flex-col gap-2 max-[1179px]:h-auto max-[1179px]:px-0 max-[1179px]:py-0 min-[1180px]:h-full min-[1180px]:py-2">
<div className="flex-1 max-[1179px]:overflow-visible min-[1180px]:min-h-0 min-[1180px]:overflow-hidden">
<div className="col-span-3 flex flex-col gap-2 xl:h-full xl:py-2">
<div className="flex-1 xl:min-h-0 xl:overflow-hidden">
<AgentPreviewArea />
</div>
</div>
@@ -1429,7 +1416,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setDeleteConfirmation('INACTIVE');
}}
cancelLabel={t('agents.form.buttons.cancel')}
variant="danger"
variant="destructive"
/>
<AgentDetailsModal
agent={agent}
@@ -1473,7 +1460,7 @@ function AgentPreviewArea() {
const { t } = useTranslation();
const selectedAgent = useSelector(selectSelectedAgent);
return (
<div className="bg-card border-border w-full rounded-2xl border max-[1179px]:h-[600px] min-[1180px]:h-full">
<div className="bg-card border-border h-[600px] w-full rounded-2xl border xl:h-full">
{selectedAgent?.status === 'published' ? (
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl">
<AgentPreview />
+6 -25
View File
@@ -4,11 +4,10 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import userService from '../api/services/userService';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import MessageInput from '../components/MessageInput';
import { Avatar } from '../components/ui/avatar';
import { Spinner } from '@/components/ui/spinner';
import { EmptyState } from '@/components/ui/empty-state';
import { LoadingState } from '@/components/ui/loading-state';
import ConversationMessages from '../conversation/ConversationMessages';
import { Query } from '../conversation/conversationModels';
import {
@@ -18,7 +17,6 @@ import {
selectQueries,
selectStatus,
} from '../conversation/conversationSlice';
import { useDarkTheme } from '../hooks';
import { selectToken, setSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import SharedAgentCard from './SharedAgentCard';
@@ -28,7 +26,6 @@ export default function SharedAgent() {
const { t } = useTranslation();
const { agentId } = useParams();
const dispatch = useDispatch<AppDispatch>();
const [isDarkTheme] = useDarkTheme();
const token = useSelector(selectToken);
const queries = useSelector(selectQueries);
@@ -128,36 +125,20 @@ export default function SharedAgent() {
if (sharedAgent) dispatch(setSelectedAgent(sharedAgent));
}, [sharedAgent, dispatch]);
if (isLoading)
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
);
if (isLoading) return <LoadingState fill="parent" />;
if (!sharedAgent)
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-full flex-col items-center justify-center gap-4">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt="No agent found"
className="mx-auto mb-6 h-32 w-32"
/>
<p className="text-muted-foreground text-center text-lg">
{t('agents.shared.notFound')}
</p>
</div>
</div>
<EmptyState className="h-full" title={t('agents.shared.notFound')} />
);
return (
<div className="relative h-full w-full">
<div className="absolute top-5 left-4 hidden items-center gap-3 sm:flex">
<Avatar
src={sharedAgent.image}
alt="agent-logo"
alt={t('agents.shared.logoAlt')}
shape="circle"
className="overflow-hidden"
imgClassName="h-6 w-6 object-contain"
imgClassName="size-6 object-contain"
/>
<h2 className="text-foreground text-lg font-semibold">
{sharedAgent.name}
+27 -15
View File
@@ -3,7 +3,9 @@ import { Pencil } from 'lucide-react';
import ToolIcon from '../components/ToolIcon';
import { Avatar } from '../components/ui/avatar';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { formatDateTime } from '../utils/dateTimeUtils';
import { getToolDisplayName } from '../utils/toolUtils';
import { Agent } from './types';
@@ -23,9 +25,13 @@ export default function SharedAgentCard({
agent.shared_metadata !== null &&
Object.keys(agent.shared_metadata).length > 0;
return (
<div className="border-border flex w-full max-w-[720px] flex-col rounded-3xl border p-6 shadow-xs sm:w-fit sm:min-w-[480px]">
<Card
variant="subtle"
padding="lg"
className="w-full max-w-[720px] sm:w-fit sm:min-w-[480px]"
>
<div className="flex items-start gap-3">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1">
<div className="flex size-12 items-center justify-center overflow-hidden rounded-full p-1">
<Avatar
src={agent.image}
alt={agent.name}
@@ -58,41 +64,47 @@ export default function SharedAgentCard({
)}
</div>
{hasSharedMetadata && (
<div className="mt-4 flex items-center gap-8">
<div className="mt-1 flex items-center gap-8">
{agent.shared_metadata?.shared_by && (
<p className="text-foreground text-xs font-light sm:text-sm">
by {agent.shared_metadata.shared_by}
{t('agents.shared.sharedBy', {
name: agent.shared_metadata.shared_by,
interpolation: { escapeValue: false },
})}
</p>
)}
{agent.shared_metadata?.shared_at && (
<p className="text-muted-foreground text-xs font-light sm:text-sm">
Shared on {formatDateTime(agent.shared_metadata.shared_at)}
{t('agents.shared.sharedOn', {
date: formatDateTime(agent.shared_metadata.shared_at),
interpolation: { escapeValue: false },
})}
</p>
)}
</div>
)}
{agent.tool_details && agent.tool_details.length > 0 && (
<div className="mt-8">
<div className="mt-5">
<p className="text-foreground text-sm font-semibold sm:text-base">
Connected Tools
{t('agents.shared.connectedTools')}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{agent.tool_details.map((tool, index) => (
<span
key={index}
className="bg-accent text-foreground dark:bg-card flex items-center gap-1 rounded-full px-3 py-1 text-xs font-light"
>
<Badge key={index} variant="default">
<ToolIcon
name={tool.name}
title={`${getToolDisplayName(tool)} icon`}
className="h-3 w-3"
title={t('agents.shared.toolIconTitle', {
name: getToolDisplayName(tool),
interpolation: { escapeValue: false },
})}
className="size-3"
/>{' '}
{getToolDisplayName(tool)}
</span>
</Badge>
))}
</div>
</div>
)}
</div>
</Card>
);
}
@@ -44,8 +44,8 @@ describe('AgentTypeModal', () => {
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].textContent).toContain('agents.typeModal.classicTitle');
expect(found[1].textContent).toContain('agents.typeModal.workflowTitle');
expect(found[0].querySelector('[data-slot="option-card-icon"]')).not.toBe(
null,
);
@@ -1,5 +1,6 @@
import { Bot, Workflow } from 'lucide-react';
import { agentNewPath } from '../paths';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../../components/ui/modal';
@@ -16,6 +17,7 @@ export default function AgentTypeModal({
onClose,
folderId,
}: AgentTypeModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleSelect = (type: 'normal' | 'workflow') => {
@@ -31,21 +33,21 @@ export default function AgentTypeModal({
<Modal
open={isOpen}
onOpenChange={(o) => !o && onClose()}
title="Create New Agent"
description="Choose the type of agent you want to create"
title={t('agents.typeModal.title')}
description={t('agents.typeModal.description')}
size="md"
>
<div className="flex flex-col gap-4">
<OptionCard
icon={<Bot />}
title="Classic Agent"
description="Create a standard AI agent with a single model, tools, and knowledge sources"
title={t('agents.typeModal.classicTitle')}
description={t('agents.typeModal.classicDescription')}
onClick={() => handleSelect('normal')}
/>
<OptionCard
icon={<Workflow />}
title="Workflow Agent"
description="Design complex multi-step workflows with different models, conditional logic, and state management"
title={t('agents.typeModal.workflowTitle')}
description={t('agents.typeModal.workflowDescription')}
onClick={() => handleSelect('workflow')}
/>
</div>
@@ -23,12 +23,20 @@ const events = [
created_at: '2026-09-01T00:00:00Z',
}));
// Flipped by the Retry test to make the next events fetch fail.
const fetchState = vi.hoisted(() => ({ failNext: false }));
vi.mock('../../api/services/userService', () => ({
default: {
getGuardrailEvents: () =>
Promise.resolve({
getGuardrailEvents: () => {
if (fetchState.failNext) {
fetchState.failNext = false;
return Promise.reject(new Error('network'));
}
return Promise.resolve({
json: () => Promise.resolve({ success: true, events }),
}),
});
},
getGuardrailSummary: () =>
Promise.resolve({
json: () =>
@@ -89,4 +97,90 @@ describe('GuardrailEvents tones', () => {
'text-muted-foreground',
);
});
it('renders the stat tiles as StatCards', () => {
for (const id of [
'guardrail-stat-blocked',
'guardrail-stat-redacted',
'guardrail-stat-flagged',
'guardrail-stat-not-evaluated',
]) {
const tile = container.querySelector(`[data-testid="${id}"]`);
expect(tile?.getAttribute('data-slot')).toBe('card');
expect(tile?.getAttribute('data-variant')).toBe('subtle');
}
expect(
container
.querySelector('[data-testid="guardrail-stat-not-evaluated"]')
?.getAttribute('title'),
).toBe('agents.guardrailEvents.notEvaluatedHint');
});
it('puts the window Select in the section header actions', () => {
const header = container.querySelector('[data-slot="section-header"]');
expect(
header?.querySelector('[data-testid="guardrail-events-window"]'),
).not.toBeNull();
});
it('renders ui/table named by the Guardrail decisions sub-heading', () => {
const table = container.querySelector('table[data-slot="table"]');
expect(table).not.toBeNull();
const labelId = table!.getAttribute('aria-labelledby');
expect(labelId).toBeTruthy();
// SectionHeader puts the id on its root; with no actions or description
// the root holds only the h3, so the name is the heading's text.
const label = document.getElementById(labelId!);
expect(label?.querySelector('h3')?.textContent).toBe(
'agents.guardrailEvents.tableHeader',
);
expect(label?.textContent).toBe('agents.guardrailEvents.tableHeader');
expect(table!.querySelector('[data-slot="table-head"]')).not.toBeNull();
expect(
table!.querySelector(
'[data-slot="table-body"][data-testid="guardrail-events-rows"]',
),
).not.toBeNull();
});
});
describe('GuardrailEvents load error', () => {
it('shows a destructive empty state whose Retry re-runs the fetch', async () => {
fetchState.failNext = true;
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
<Provider store={makeStore()}>
<GuardrailEvents agentId="a1" />
</Provider>,
);
});
const errorState = container.querySelector(
'[data-slot="empty-state"][data-tone="destructive"]',
);
expect(errorState?.textContent).toContain(
'agents.guardrailEvents.loadError',
);
const retry = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'retry',
);
expect(retry).toBeDefined();
await act(async () => retry!.click());
expect(
container.querySelector(
'[data-slot="empty-state"][data-tone="destructive"]',
),
).toBeNull();
expect(
container.querySelector('[data-testid="guardrail-events-rows"]'),
).not.toBeNull();
await act(async () => root.unmount());
container.remove();
});
});
+111 -127
View File
@@ -10,8 +10,19 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { SectionHeader } from '@/components/ui/section-header';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import StatCard from '@/components/StatCard';
import userService from '../../api/services/userService';
import SkeletonLoader from '../../components/SkeletonLoader';
@@ -62,6 +73,8 @@ export default function GuardrailEvents({ agentId }: Props) {
const [days, setDays] = React.useState(30);
const [checkFilter, setCheckFilter] = React.useState('all');
const [outcomeFilter, setOutcomeFilter] = React.useState('all');
// Bumped by Retry to re-run the fetch below.
const [reloadKey, setReloadKey] = React.useState(0);
React.useEffect(() => {
if (!agentId) return;
@@ -94,7 +107,7 @@ export default function GuardrailEvents({ agentId }: Props) {
return () => {
cancelled = true;
};
}, [agentId, token, days, t]);
}, [agentId, token, days, t, reloadKey]);
const checkNames = React.useMemo(
() => Array.from(new Set(events.map((e) => e.check_name))).sort(),
@@ -111,83 +124,87 @@ export default function GuardrailEvents({ agentId }: Props) {
);
const totals = summary?.totals;
const tableHeadingId = React.useId();
return (
<div className="mt-8 px-4" data-testid="guardrail-events">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">
{t('agents.guardrailEvents.heading')}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.guardrailEvents.description')}
</p>
</div>
<Select
value={String(days)}
onValueChange={(value) => setDays(Number(value))}
>
<SelectTrigger
className="w-[150px]"
size="lg"
shape="pill"
data-testid="guardrail-events-window"
<SectionHeader
title={t('agents.guardrailEvents.heading')}
description={t('agents.guardrailEvents.description')}
actions={
<Select
value={String(days)}
onValueChange={(value) => setDays(Number(value))}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((window) => (
<SelectItem key={window} value={String(window)}>
{t('agents.guardrailEvents.lastDays', { count: window })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<SelectTrigger
className="w-[150px]"
size="field"
shape="pill"
data-testid="guardrail-events-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((window) => (
<SelectItem key={window} value={String(window)}>
{t('agents.guardrailEvents.lastDays', { count: window })}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
{/* Blocked / flagged / redacted / not-evaluated are four different
product problems; a single "violations" number hides which one you
have. Not-evaluated in particular means a check silently stopped
working. */}
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label={t('agents.guardrailEvents.blocked')}
value={totals?.blocked}
value={totals?.blocked ?? 0}
loading={loading}
testId="guardrail-stat-blocked"
tone="text-destructive"
data-testid="guardrail-stat-blocked"
valueTone="destructive"
/>
<StatTile
<StatCard
label={t('agents.guardrailEvents.redacted')}
value={totals?.redacted}
value={totals?.redacted ?? 0}
loading={loading}
testId="guardrail-stat-redacted"
tone="text-info"
data-testid="guardrail-stat-redacted"
valueTone="info"
/>
<StatTile
<StatCard
label={t('agents.guardrailEvents.flagged')}
value={totals?.flagged}
value={totals?.flagged ?? 0}
loading={loading}
testId="guardrail-stat-flagged"
tone="text-warning"
data-testid="guardrail-stat-flagged"
valueTone="warning"
/>
<StatTile
<StatCard
label={t('agents.guardrailEvents.notEvaluated')}
value={totals?.not_evaluated}
value={totals?.not_evaluated ?? 0}
loading={loading}
testId="guardrail-stat-not-evaluated"
tone="text-muted-foreground"
data-testid="guardrail-stat-not-evaluated"
valueTone="muted"
hint={t('agents.guardrailEvents.notEvaluatedHint')}
/>
</div>
{summary && summary.breakdown.length > 0 && <ByCheck summary={summary} />}
<div className="mt-6 flex flex-wrap items-center gap-3">
<SectionHeader
as="h3"
size="xs"
id={tableHeadingId}
title={t('agents.guardrailEvents.tableHeader')}
className="mt-6"
/>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Select value={checkFilter} onValueChange={setCheckFilter}>
<SelectTrigger
className="w-[170px]"
size="lg"
size="field"
shape="pill"
data-testid="guardrail-events-check-filter"
>
@@ -207,7 +224,7 @@ export default function GuardrailEvents({ agentId }: Props) {
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
<SelectTrigger
className="w-[170px]"
size="lg"
size="field"
shape="pill"
data-testid="guardrail-events-outcome-filter"
>
@@ -233,19 +250,28 @@ export default function GuardrailEvents({ agentId }: Props) {
</Select>
</div>
<div className="border-border bg-card mt-3 w-full overflow-hidden rounded-xl border dark:bg-black">
<div className="flex h-8 flex-col items-start justify-center bg-black/10 dark:bg-white/5">
<p className="text-muted-foreground px-3 text-xs">
{t('agents.guardrailEvents.tableHeader')}
</p>
</div>
<div className="border-border bg-card mt-3 w-full overflow-hidden rounded-xl border">
<div className="max-h-[45vh] overflow-y-auto">
{loading ? (
<div className="p-3">
<SkeletonLoader count={3} />
</div>
) : error ? (
<p className="text-destructive p-4 text-sm">{error}</p>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={error}
action={
<Button
variant="outline"
size="sm"
onClick={() => setReloadKey((key) => key + 1)}
>
{t('retry')}
</Button>
}
/>
) : visible.length === 0 ? (
<p
className="text-muted-foreground p-4 text-sm"
@@ -256,60 +282,51 @@ export default function GuardrailEvents({ agentId }: Props) {
: t('agents.guardrailEvents.emptyForFilter')}
</p>
) : (
<table className="w-full text-left text-xs">
<thead className="text-muted-foreground">
<tr className="border-border border-b">
<th className="px-3 py-2 font-medium">
{t('agents.guardrailEvents.when')}
</th>
<th className="px-3 py-2 font-medium">
{t('agents.guardrailEvents.check')}
</th>
<th className="px-3 py-2 font-medium">
{t('agents.guardrailEvents.stage')}
</th>
<th className="px-3 py-2 font-medium">
<Table aria-labelledby={tableHeadingId}>
<TableHead>
<TableRow>
<TableHeader>{t('agents.guardrailEvents.when')}</TableHeader>
<TableHeader>{t('agents.guardrailEvents.check')}</TableHeader>
<TableHeader>{t('agents.guardrailEvents.stage')}</TableHeader>
<TableHeader>
{t('agents.guardrailEvents.outcome')}
</th>
<th className="px-3 py-2 font-medium">
</TableHeader>
<TableHeader>
{t('agents.guardrailEvents.detail')}
</th>
</tr>
</thead>
<tbody data-testid="guardrail-events-rows">
</TableHeader>
</TableRow>
</TableHead>
<TableBody data-testid="guardrail-events-rows">
{visible.map((event) => (
<tr
key={event.id}
className="border-border/60 border-b last:border-0"
>
<td className="text-muted-foreground px-3 py-2 whitespace-nowrap">
<TableRow key={event.id}>
<TableCell className="text-muted-foreground whitespace-nowrap">
{formatDateTime(event.created_at)}
</td>
<td className="px-3 py-2">
</TableCell>
<TableCell>
<span className="font-medium">{event.check_name}</span>
{event.category && (
<span className="text-muted-foreground ml-1">
· {event.category}
</span>
)}
</td>
<td className="text-muted-foreground px-3 py-2">
</TableCell>
<TableCell className="text-muted-foreground">
{t(STAGE_KEYS[event.stage] ?? event.stage)}
</td>
<td className="px-3 py-2">
</TableCell>
<TableCell>
<Badge variant={actionTone(event.action, event.outcome)}>
{event.outcome === 'not_evaluated'
? t('agents.guardrailEvents.notEvaluated')
: t(ACTION_KEYS[event.action] ?? event.action)}
</Badge>
</td>
<td className="text-muted-foreground max-w-[28rem] px-3 py-2">
</TableCell>
<TableCell className="text-muted-foreground">
{event.detail || '—'}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
)}
</div>
</div>
@@ -322,39 +339,6 @@ export default function GuardrailEvents({ agentId }: Props) {
);
}
function StatTile({
label,
value,
loading,
tone,
testId,
hint,
}: {
label: string;
value?: number;
loading: boolean;
tone: string;
testId: string;
hint?: string;
}) {
return (
<div
className="border-border bg-card rounded-xl border px-4 py-3"
data-testid={testId}
title={hint}
>
<p className="text-muted-foreground text-xs">{label}</p>
{loading ? (
<div className="mt-1 h-6 w-10">
<SkeletonLoader count={1} />
</div>
) : (
<p className={cn('mt-1 text-xl font-semibold', tone)}>{value ?? 0}</p>
)}
</div>
);
}
/**
* Per-check totals. The point is to see *which* control is firing: a rollout's
* first week is spent finding the one that is over-triggering, and an
@@ -221,13 +221,54 @@ describe('GuardrailsSection', () => {
expect(floor?.className).toContain('text-info');
});
// Item 38: the instance-policy line is an info Alert announced as a note.
it('renders an instance-enforced control as an info note', async () => {
await render();
const note = q('guardrail-floor-pii:output')!;
expect(note.dataset.slot).toBe('alert');
expect(note.getAttribute('role')).toBe('note');
expect(note.className).toContain('text-info');
expect(note.querySelector('svg.lucide-info')).not.toBeNull();
expect(note.textContent).toContain('agents.form.guardrails.floorControl');
});
// Item 38: a control's setup error is its FormField error, tied to the
// action select.
it('announces the setup error under the control row', async () => {
await render();
const error = q('guardrail-needs-setup-policy-input')!;
expect(error.getAttribute('role')).toBe('alert');
expect(error.textContent).toBe('agents.form.guardrails.setupRequired');
expect(q('guardrail-needs-setup-pii-input')).toBeNull();
});
it('announces an empty PII entity list', async () => {
await render({
value: {
...config,
controls: [{ ...config.controls[0], settings: { entities: [] } }],
},
});
await act(async () => {
q('guardrail-configure-pii-input')?.click();
});
const errors = Array.from(container.querySelectorAll('[role="alert"]')).map(
(el) => el.textContent,
);
expect(errors).toContain('agents.form.guardrails.pickAtLeastOne');
});
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');
// The hint lives on the hoverable wrapper; the named button has no title.
expect(locked?.hasAttribute('title')).toBe(false);
expect(locked?.parentElement?.title).toBe(
'agents.form.guardrails.lockedByFloor',
);
expect(q('guardrail-stage-policy-input')?.dataset.variant).toBe(
'secondary',
);
@@ -239,10 +280,19 @@ describe('GuardrailsSection', () => {
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');
// On the tinted orphan row the hover is a destructive tint, not grey.
expect(orphan?.dataset.variant).toBe('ghost-destructive-on-accent');
expect(orphan?.dataset.size).toBe('xs');
});
it('draws the orphan-guardrail row as a small destructive Card', async () => {
await render();
const row = q('guardrail-orphan-gone');
expect(row?.dataset.slot).toBe('card');
expect(row?.dataset.tone).toBe('destructive');
expect(row?.dataset.padding).toBe('sm');
});
// Decision 63 (3): the form's fields and selects are all 42px.
it('sizes the fields through Input and Textarea props', async () => {
await render();
@@ -255,7 +305,7 @@ describe('GuardrailsSection', () => {
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.size).toBe('field');
expect(mode?.dataset.shape).toBe('pill');
await act(async () => {
@@ -264,6 +314,38 @@ describe('GuardrailsSection', () => {
expect(q('guardrail-policy-text')?.dataset.slot).toBe('textarea');
});
// Decision 63e-b: the monitor-only line is advice, so the field's muted hint.
it('explains monitor-only mode in the mode field hint', async () => {
await render();
const hint = Array.from(container.querySelectorAll('p')).find(
(p) => p.textContent === 'agents.form.guardrails.monitorHint',
)!;
expect(hint.className.split(' ')).toEqual(
expect.arrayContaining(['text-muted-foreground', 'text-xs']),
);
expect(hint.className).not.toContain('text-warning');
expect(q('guardrails-mode')?.getAttribute('aria-describedby')).toContain(
hint.id,
);
});
// Decision 63a: each control's name is a 14px semibold sub-heading.
it('titles each check card with an xs section header', async () => {
await render();
const title = q('guardrail-check-pii')?.querySelector('h4');
expect(title?.textContent).toBe('PII');
expect(title?.className.split(' ')).toEqual(
expect.arrayContaining(['text-foreground', 'text-sm', 'font-semibold']),
);
});
it('renders each check as a small-padded Card', async () => {
await render();
const card = q('guardrail-check-pii');
expect(card?.dataset.slot).toBe('card');
expect(card?.dataset.padding).toBe('sm');
});
it('shows PII entities as toggle chips', async () => {
await render();
await act(async () => {
@@ -272,4 +354,30 @@ describe('GuardrailsSection', () => {
expect(q('guardrail-pii-EMAIL')?.dataset.variant).toBe('secondary');
expect(q('guardrail-pii-PHONE')?.dataset.variant).toBe('ghost-muted');
});
it('shows a failed catalog load as a destructive empty state with Retry', async () => {
getGuardrailCatalog.mockReset();
getGuardrailCatalog.mockRejectedValueOnce(new Error('network'));
getGuardrailCatalog.mockResolvedValue({
json: async () => ({ success: true, ...catalog }),
});
await render();
const errorState = () =>
container.querySelector<HTMLElement>(
'[data-slot="empty-state"][data-tone="destructive"]',
);
expect(errorState()?.textContent).toContain(
'agents.form.guardrails.loadError',
);
expect(errorState()?.dataset.size).toBe('sm');
const retry = Array.from(errorState()!.querySelectorAll('button')).find(
(b) => b.textContent === 'retry',
);
await act(async () => retry!.click());
expect(getGuardrailCatalog).toHaveBeenCalledTimes(2);
expect(errorState()).toBeNull();
});
});
@@ -5,6 +5,8 @@ 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 { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import {
@@ -14,6 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SectionHeader } from '@/components/ui/section-header';
import { SettingRow } from '@/components/ui/setting-row';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
@@ -115,6 +118,8 @@ export default function GuardrailsSection({
const [catalog, setCatalog] = React.useState<GuardrailCatalog | null>(null);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [openSettings, setOpenSettings] = React.useState<string | null>(null);
// Bumped by Retry to re-run the catalog fetch below.
const [reloadKey, setReloadKey] = React.useState(0);
const enabledId = React.useId();
const failOpenId = React.useId();
@@ -122,6 +127,7 @@ export default function GuardrailsSection({
React.useEffect(() => {
let cancelled = false;
setLoadError(null);
userService
.getGuardrailCatalog(token)
.then((res) => res.json())
@@ -136,7 +142,7 @@ export default function GuardrailsSection({
return () => {
cancelled = true;
};
}, [token, t]);
}, [token, t, reloadKey]);
/** Floor-imposed controls, so the UI can show them as active and locked. */
const floorControls = React.useMemo(() => {
@@ -229,7 +235,10 @@ export default function GuardrailsSection({
>
<ChevronRight
aria-hidden="true"
className={cn('transition-transform', expanded && 'rotate-90')}
className={cn(
'transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
<span className="text-lg font-semibold">
{t('agents.form.sections.guardrails')}
@@ -258,7 +267,22 @@ export default function GuardrailsSection({
{expanded && (
<div className="mt-3 pb-3">
{loadError && (
<p className="text-destructive mt-3 text-xs">{loadError}</p>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={loadError}
action={
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setReloadKey((k) => k + 1)}
>
{t('retry')}
</Button>
}
/>
)}
{disabled && disabledNotice && (
@@ -314,7 +338,15 @@ export default function GuardrailsSection({
{config.enabled && (
<>
<div className="mt-5">
<FormField label={t('agents.form.guardrails.mode')}>
<FormField
label={t('agents.form.guardrails.mode')}
hint={
config.mode === 'monitor_only'
? t('agents.form.guardrails.monitorHint')
: undefined
}
disabled={disabled}
>
<Select
value={config.mode}
onValueChange={(mode) =>
@@ -324,7 +356,7 @@ export default function GuardrailsSection({
>
<SelectTrigger
className="w-full"
size="lg"
size="field"
shape="pill"
data-testid="guardrails-mode"
>
@@ -341,11 +373,6 @@ export default function GuardrailsSection({
</SelectContent>
</Select>
</FormField>
{config.mode === 'monitor_only' && (
<p className="text-warning mt-2 text-xs">
{t('agents.form.guardrails.monitorHint')}
</p>
)}
</div>
<div className="mt-5">
@@ -370,9 +397,11 @@ export default function GuardrailsSection({
/>
))}
{orphanControls.map((control) => (
<div
<Card
key={key(control.check, control.stage)}
className="border-destructive/40 flex items-center justify-between gap-3 rounded-xl border px-4 py-3"
tone="destructive"
padding="sm"
className="flex-row items-center justify-between"
data-testid={`guardrail-orphan-${control.check}`}
>
<p className="text-xs">
@@ -382,7 +411,7 @@ export default function GuardrailsSection({
</p>
<Button
type="button"
variant="ghost-destructive"
variant="ghost-destructive-on-accent"
size="xs"
disabled={disabled}
onClick={() =>
@@ -391,7 +420,7 @@ export default function GuardrailsSection({
>
{t('agents.form.guardrails.remove')}
</Button>
</div>
</Card>
))}
</div>
</div>
@@ -400,12 +429,12 @@ export default function GuardrailsSection({
className="mt-6"
label={t('agents.form.guardrails.blockMessage')}
hint={t('agents.form.guardrails.blockMessageDescription')}
disabled={disabled}
>
<Input
type="text"
value={config.block_message}
maxLength={500}
disabled={disabled}
data-testid="guardrails-block-message"
onChange={(e) => patch({ block_message: e.target.value })}
shape="pill"
@@ -428,8 +457,9 @@ export default function GuardrailsSection({
</SettingRow>
<FormField
className="mt-4"
className="mt-5"
label={t('agents.form.guardrails.timeout')}
disabled={disabled}
>
<NumberField
value={config.timeout_ms}
@@ -567,13 +597,10 @@ function CheckCard({
);
return (
<div
className="border-border rounded-xl border px-4 py-3"
data-testid={`guardrail-check-${info.name}`}
>
<Card padding="sm" 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>
<SectionHeader as="h4" size="xs" title={info.label} />
<Badge
variant="neutral"
title={t('agents.form.guardrails.latencyHint')}
@@ -590,7 +617,7 @@ function CheckCard({
<p className="text-muted-foreground mt-1 text-xs">{info.description}</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2">
{info.stages.map((stage) => {
const control = controlFor(info.name, stage);
const floorAction = floorControls.get(key(info.name, stage));
@@ -612,9 +639,6 @@ function CheckCard({
disabled={!canToggle}
data-testid={`guardrail-stage-${info.name}-${stage}`}
onClick={() => toggleControl(info, stage, !control)}
title={
locked ? t('agents.form.guardrails.lockedByFloor') : undefined
}
>
{t(STAGE_KEYS[stage] ?? stage)}
{locked ? ' 🔒' : ''}
@@ -637,16 +661,20 @@ function CheckCard({
</div>
{floorForCheck.map(([k, action]) => (
<div
<Alert
key={`floor-${k}`}
className="bg-info/10 text-info mt-3 rounded-lg px-3 py-2 text-xs"
variant="info"
role="note"
data-testid={`guardrail-floor-${k}`}
>
{t('agents.form.guardrails.floorControl', {
stage: t(STAGE_KEYS[k.split(':')[1] as GuardrailStage] ?? ''),
action: t(ACTION_KEYS[action] ?? action),
})}
</div>
<Info aria-hidden="true" className="size-4" />
<AlertDescription>
{t('agents.form.guardrails.floorControl', {
stage: t(STAGE_KEYS[k.split(':')[1] as GuardrailStage] ?? ''),
action: t(ACTION_KEYS[action] ?? action),
})}
</AlertDescription>
</Alert>
))}
{active.map((control) => {
@@ -656,9 +684,9 @@ function CheckCard({
<div
key={panelKey}
className={cn(
'mt-3 rounded-lg px-3 py-2',
'rounded-lg px-3 py-2',
needsSetup
? 'border-destructive/50 bg-destructive/5 border'
? 'border-destructive/50 bg-destructive/10 border'
: 'bg-muted',
)}
>
@@ -725,6 +753,7 @@ function CheckCard({
</div>
{needsSetup && (
<p
role="alert"
className="text-destructive mt-1 text-xs"
data-testid={`guardrail-needs-setup-${control.check}-${control.stage}`}
>
@@ -744,7 +773,7 @@ function CheckCard({
</div>
);
})}
</div>
</Card>
);
}
@@ -768,11 +797,10 @@ function CheckSettings({
const set = (next: Record<string, any>) => onChange({ ...s, ...next });
const listField = (fieldKey: string, label: string, placeholder: string) => (
<FormField className="mt-2" label={label}>
<FormField label={label} labelSurface="muted" disabled={disabled}>
<Textarea
rows={2}
size="sm"
disabled={disabled}
data-testid={`guardrail-setting-${control.check}-${fieldKey}`}
value={(s[fieldKey] ?? []).join('\n')}
placeholder={placeholder}
@@ -790,7 +818,7 @@ function CheckSettings({
);
return (
<div className="border-border mt-3 border-t pt-3">
<div className="border-border mt-3 flex flex-col gap-5 border-t pt-5">
{control.check === 'pii' && (
<div className="flex flex-wrap gap-2">
{(catalog?.pii_entities ?? []).map((entity) => {
@@ -817,7 +845,7 @@ function CheckSettings({
);
})}
{(s.entities ?? []).length === 0 && (
<p className="text-destructive text-xs">
<p role="alert" className="text-destructive text-xs">
{t('agents.form.guardrails.pickAtLeastOne')}
</p>
)}
@@ -848,14 +876,14 @@ function CheckSettings({
{control.check === 'policy' && (
<FormField
className="mt-2"
label={t('agents.form.guardrails.policyText')}
labelSurface="muted"
disabled={disabled}
>
<Textarea
rows={4}
size="sm"
value={s.policy ?? ''}
disabled={disabled}
data-testid="guardrail-policy-text"
onChange={(e) => set({ policy: e.target.value })}
variant="filled"
@@ -864,8 +892,12 @@ function CheckSettings({
)}
{control.check === 'groundedness' && (
<div className="mt-2 grid grid-cols-2 gap-2">
<FormField label={t('agents.form.guardrails.minOverlap')}>
<div className="grid grid-cols-2 gap-2">
<FormField
label={t('agents.form.guardrails.minOverlap')}
labelSurface="muted"
disabled={disabled}
>
<NumberField
value={s.min_overlap ?? 0.3}
min={0}
@@ -876,7 +908,11 @@ function CheckSettings({
onCommit={(min_overlap) => set({ min_overlap })}
/>
</FormField>
<FormField label={t('agents.form.guardrails.minWords')}>
<FormField
label={t('agents.form.guardrails.minWords')}
labelSurface="muted"
disabled={disabled}
>
<NumberField
value={s.min_words ?? 25}
min={1}
@@ -892,9 +928,10 @@ function CheckSettings({
{control.check === 'policy' && (
<FormField
className="mt-2"
label={t('agents.form.guardrails.confidence')}
hint={t('agents.form.guardrails.confidenceHint')}
labelSurface="muted"
disabled={disabled}
>
<NumberField
value={s.confidence_threshold ?? 0.7}
@@ -0,0 +1,136 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import '../../locale/i18n';
import type { ScheduleRun } from '../types/schedule';
import RunDetailDrawer from './RunDetailDrawer';
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 run: ScheduleRun = {
id: 'r1',
schedule_id: 's1',
user_id: 'u1',
agent_id: 'a1',
status: 'failed',
scheduled_for: '2026-09-25T09:00:00Z',
trigger_source: 'cron',
started_at: '2026-09-25T09:00:00Z',
finished_at: '2026-09-25T09:01:00Z',
output: null,
output_truncated: false,
error: 'brave_search returned 429',
error_type: null,
prompt_tokens: 1120,
generated_tokens: 84,
created_at: '2026-09-25T09:00:00Z',
updated_at: '2026-09-25T09:01:00Z',
};
const content = () =>
document.querySelector<HTMLElement>('[data-slot="sheet-content"]');
describe('RunDetailDrawer', () => {
it('opens as a right Sheet with the standard X, not a hand-rolled aside', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const panel = content()!;
expect(panel).not.toBeNull();
expect(panel.dataset.side).toBe('right');
expect(panel.getAttribute('role')).toBe('dialog');
expect(panel.className.split(' ')).toEqual(
expect.arrayContaining(['bg-background', 'sm:max-w-xl']),
);
expect(panel.querySelector('[data-slot="sheet-title"]')!.textContent).toBe(
'Run details',
);
expect(panel.querySelector('[aria-label="Close"]')).not.toBeNull();
expect(document.querySelector('aside')).toBeNull();
});
it('labels come from the locale, trigger and status included', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const text = content()!.textContent!;
expect(text).toContain('Scheduled for');
expect(text).toContain('1120 prompt · 84 generated');
expect(text).toContain('Scheduled');
expect(text).toContain('Failed');
});
// L9: a code block on the sheet is a filled Card around the mono recipe.
it('puts the error on a filled Card so it shows on the sheet', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const pre = content()!.querySelector('pre')!;
const card = pre.parentElement!;
expect(card.getAttribute('data-slot')).toBe('card');
expect(card.getAttribute('data-variant')).toBe('filled');
expect(card.className.split(' ')).toContain('bg-muted');
expect(card.className.split(' ')).not.toContain('bg-background');
expect(pre.className.split(' ')).toEqual(
expect.arrayContaining([
'font-mono',
'text-xs',
'whitespace-pre-wrap',
'wrap-break-word',
]),
);
});
// Decisions 63a / 63b: the title is the default SheetTitle; the Error
// heading is a destructive xs sub-heading.
it('uses the default sheet title and an xs destructive error heading', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const title = content()!.querySelector('[data-slot="sheet-title"]')!;
expect(title.className.split(' ')).toEqual(
expect.arrayContaining(['text-xl', 'leading-tight', 'font-semibold']),
);
expect(title.className).not.toContain('text-lg');
const error = content()!.querySelector('h3')!;
expect(error.textContent).toBe('Error');
expect(error.className.split(' ')).toEqual(
expect.arrayContaining(['text-destructive', 'text-sm', 'font-semibold']),
);
});
it('closes from the X', async () => {
const onClose = vi.fn();
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={onClose} />),
);
await act(async () =>
content()!
.querySelector<HTMLButtonElement>('[aria-label="Close"]')!
.click(),
);
expect(onClose).toHaveBeenCalled();
});
it('renders nothing without a run', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={null} onClose={vi.fn()} />),
);
expect(content()).toBeNull();
});
});
@@ -1,6 +1,12 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/button';
import { Card } from '@/components/ui/card';
import {
DescriptionItem,
DescriptionList,
} from '@/components/ui/description-list';
import { SectionHeader } from '@/components/ui/section-header';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { formatDateTime } from '../../utils/dateTimeUtils';
import type { ScheduleRun } from '../types/schedule';
import ScheduleStatusBadge from './StatusBadge';
@@ -14,83 +20,104 @@ const formatTimestamp = (value?: string | null): string => {
return value ? formatDateTime(value) : '—';
};
/** Side drawer with a single run's output / error (terminal-state only). */
/** Side sheet with a single run's output / error (terminal-state only). */
export default function RunDetailDrawer({
run,
onClose,
}: RunDetailDrawerProps) {
useEffect(() => {
if (!run) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [run, onClose]);
const { t } = useTranslation();
if (!run) return null;
return (
<>
<div
className="fixed inset-0 z-20 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
<aside
className="border-border bg-card fixed top-0 right-0 z-30 flex h-full w-full max-w-xl flex-col border-l p-6 shadow-lg"
role="dialog"
aria-label="Schedule run details"
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
className="sm:max-w-xl"
aria-describedby={undefined}
>
<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}>
Close
</Button>
{/* The sheet owns no padding of its own; the body sets it. */}
<div className="flex min-h-0 flex-1 flex-col gap-4 p-6">
<SheetTitle>{t('agents.schedules.runDetails.title')}</SheetTitle>
<DescriptionList size="sm">
<DescriptionItem label={t('agents.schedules.runDetails.status')}>
<ScheduleStatusBadge status={run.status} />
</DescriptionItem>
<DescriptionItem
label={t('agents.schedules.runDetails.scheduledFor')}
>
{formatTimestamp(run.scheduled_for)}
</DescriptionItem>
<DescriptionItem label={t('agents.schedules.runDetails.started')}>
{formatTimestamp(run.started_at)}
</DescriptionItem>
<DescriptionItem label={t('agents.schedules.runDetails.finished')}>
{formatTimestamp(run.finished_at)}
</DescriptionItem>
<DescriptionItem label={t('agents.schedules.runDetails.tokens')}>
{t('agents.schedules.runDetails.tokensValue', {
prompt: run.prompt_tokens,
generated: run.generated_tokens,
})}
</DescriptionItem>
<DescriptionItem label={t('agents.schedules.runDetails.trigger')}>
{t(`agents.schedules.trigger.${run.trigger_source}`, {
defaultValue: run.trigger_source,
})}
</DescriptionItem>
</DescriptionList>
{run.error && (
<section className="flex flex-col gap-1">
<SectionHeader
as="h3"
size="xs"
tone="destructive"
title={
run.error_type
? t('agents.schedules.runDetails.errorWithType', {
type: run.error_type,
})
: t('agents.schedules.runDetails.error')
}
/>
<Card
variant="filled"
padding="sm"
className="max-h-48 overflow-y-auto"
>
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
{run.error}
</pre>
</Card>
</section>
)}
{run.output && (
<section className="flex min-h-0 flex-1 flex-col gap-1">
<SectionHeader
as="h3"
size="xs"
title={
<>
{t('agents.schedules.runDetails.output')}
{run.output_truncated && (
<span className="text-muted-foreground ml-1 text-xs">
{t('agents.schedules.runDetails.truncated')}
</span>
)}
</>
}
/>
<Card
variant="filled"
padding="sm"
className="min-h-0 flex-1 overflow-y-auto"
>
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
{run.output}
</pre>
</Card>
</section>
)}
</div>
<dl className="mb-4 grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Status</dt>
<dd>
<ScheduleStatusBadge status={run.status} />
</dd>
<dt className="text-muted-foreground">Scheduled for</dt>
<dd>{formatTimestamp(run.scheduled_for)}</dd>
<dt className="text-muted-foreground">Started</dt>
<dd>{formatTimestamp(run.started_at)}</dd>
<dt className="text-muted-foreground">Finished</dt>
<dd>{formatTimestamp(run.finished_at)}</dd>
<dt className="text-muted-foreground">Tokens</dt>
<dd>
{run.prompt_tokens} prompt · {run.generated_tokens} generated
</dd>
<dt className="text-muted-foreground">Trigger</dt>
<dd>{run.trigger_source}</dd>
</dl>
{run.error && (
<section className="mb-4">
<h3 className="text-destructive text-sm font-semibold">
Error{run.error_type ? ` (${run.error_type})` : ''}
</h3>
<pre className="bg-background mt-1 max-h-48 overflow-auto rounded-md p-3 font-mono text-xs">
{run.error}
</pre>
</section>
)}
{run.output && (
<section className="flex-1 overflow-hidden">
<h3 className="text-sm font-semibold">
Output
{run.output_truncated && (
<span className="text-muted-foreground ml-1 text-xs">
(truncated)
</span>
)}
</h3>
<pre className="bg-background mt-1 h-full overflow-auto rounded-md p-3 font-mono text-xs whitespace-pre-wrap">
{run.output}
</pre>
</section>
)}
</aside>
</>
</SheetContent>
</Sheet>
);
}
+50 -24
View File
@@ -1,7 +1,16 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Button } from '../../components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../components/ui/table';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch, RootState } from '../../store';
import { formatDateTime } from '../../utils/dateTimeUtils';
@@ -9,6 +18,10 @@ import type { ScheduleRun } from '../types/schedule';
import ScheduleStatusBadge from './StatusBadge';
import { loadRunsForSchedule, selectRunsForSchedule } from './schedulesSlice';
// The column heads are a label row, so they take the eyebrow recipe.
const HEADER_CELL =
'text-muted-foreground text-xs font-semibold tracking-wider uppercase';
export type RunLogProps = {
scheduleId: string;
onSelect?: (run: ScheduleRun) => void;
@@ -20,6 +33,7 @@ const formatTimestamp = (value?: string | null): string => {
/** Paginated run log for a schedule (SSE updates merge via schedulesSlice). */
export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const runs = useSelector((state: RootState) =>
@@ -34,27 +48,35 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
if (runs.length === 0) {
return (
<p className="text-muted-foreground py-3 text-sm">
No runs recorded for this schedule yet.
{t('agents.schedules.runLog.empty')}
</p>
);
}
return (
<table className="w-full text-left text-sm">
<thead className="text-muted-foreground text-xs uppercase">
<tr>
<th className="py-2">When</th>
<th className="py-2">Status</th>
<th className="py-2">Tokens</th>
<th className="py-2">Trigger</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
<Table minWidth="min-w-0">
<TableHead>
<TableRow>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runLog.when')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.status')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.tokens')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.trigger')}
</TableHeader>
<TableHeader className={HEADER_CELL}></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{runs.map((run) => (
<tr key={run.id} className="border-border border-t">
<td className="py-2">{formatTimestamp(run.scheduled_for)}</td>
<td className="py-2">
<TableRow key={run.id}>
<TableCell>{formatTimestamp(run.scheduled_for)}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<ScheduleStatusBadge status={run.status} />
{run.error_type && (
@@ -63,10 +85,14 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
</span>
)}
</div>
</td>
<td className="py-2">{run.prompt_tokens + run.generated_tokens}</td>
<td className="py-2">{run.trigger_source}</td>
<td className="py-2">
</TableCell>
<TableCell>{run.prompt_tokens + run.generated_tokens}</TableCell>
<TableCell>
{t(`agents.schedules.trigger.${run.trigger_source}`, {
defaultValue: run.trigger_source,
})}
</TableCell>
<TableCell>
{onSelect && (
<Button
type="button"
@@ -75,13 +101,13 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
onClick={() => onSelect(run)}
className="-mx-2 -my-1"
>
Details
{t('agents.schedules.runLog.details')}
</Button>
)}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
);
}
@@ -1,9 +1,11 @@
import { CalendarIcon } from 'lucide-react';
import { CalendarIcon, CircleAlert } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import {
@@ -20,6 +22,7 @@ import {
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { TimePicker } from '@/components/ui/time-picker';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Modal } from '../../components/ui/modal';
import { formatDateOnly } from '../../utils/dateTimeUtils';
@@ -140,14 +143,24 @@ export default function ScheduleFormModal({
// the engine's supported list (e.g. an exotic tz saved on the schedule).
return list.includes(timezone) ? list : [timezone, ...list];
}, [timezone]);
const [error, setError] = useState<string | null>(null);
// A missing instruction is the Instructions field's error; a run time in
// the past spans the date, time and timezone, so it is an Alert.
const [instructionError, setInstructionError] = useState<string | null>(null);
const [runAtError, setRunAtError] = useState<string | null>(null);
const setError = (field: 'instruction' | 'runAt', message: string) => {
setInstructionError(field === 'instruction' ? message : null);
setRunAtError(field === 'runAt' ? message : null);
};
const setFrequency = (frequency: ScheduleFrequency) =>
setValues((current) => ({ ...current, frequency }));
const submit = async () => {
if (!instruction.trim()) {
setError(t('agents.schedules.modal.errors.instructionRequired'));
setError(
'instruction',
t('agents.schedules.modal.errors.instructionRequired'),
);
return;
}
const payload: ScheduleCreatePayload = {
@@ -161,11 +174,11 @@ export default function ScheduleFormModal({
try {
runAt = buildRunAtUtc(values.date, values.time, timezone);
} catch {
setError(t('agents.schedules.modal.errors.runAtInPast'));
setError('runAt', t('agents.schedules.modal.errors.runAtInPast'));
return;
}
if (new Date(runAt).getTime() <= Date.now()) {
setError(t('agents.schedules.modal.errors.runAtInPast'));
setError('runAt', t('agents.schedules.modal.errors.runAtInPast'));
return;
}
payload.trigger_type = 'once';
@@ -173,13 +186,17 @@ export default function ScheduleFormModal({
} else {
const cron = buildCron(values.frequency, values);
if (!cron) {
setError(t('agents.schedules.modal.errors.instructionRequired'));
setError(
'instruction',
t('agents.schedules.modal.errors.instructionRequired'),
);
return;
}
payload.trigger_type = 'recurring';
payload.cron = cron;
}
setError(null);
setInstructionError(null);
setRunAtError(null);
await onSubmit(payload);
};
@@ -197,8 +214,6 @@ export default function ScheduleFormModal({
}
size="md"
mobileVariant="sheet"
className="w-[min(560px,92vw)]"
contentClassName="max-h-[80vh]"
footer={
<Button
type="button"
@@ -239,6 +254,7 @@ export default function ScheduleFormModal({
monthly: t('agents.schedules.modal.frequency.monthly'),
yearly: t('agents.schedules.modal.frequency.yearly'),
}}
ariaLabel={t('agents.schedules.modal.frequencyLabel')}
/>
<OnPicker
@@ -250,10 +266,14 @@ export default function ScheduleFormModal({
on: t('agents.schedules.modal.on'),
at: t('agents.schedules.modal.at'),
pickDate: t('agents.schedules.modal.pickDate'),
days: t('agents.schedules.modal.dayOfWeek'),
}}
/>
<div className="border-border flex flex-wrap items-center justify-between gap-2 rounded-md border p-3">
<Card
padding="sm"
className="flex-row flex-wrap items-center justify-between gap-2"
>
<span className="text-foreground text-sm font-medium">
{t('agents.schedules.modal.timezone')}
</span>
@@ -270,9 +290,12 @@ export default function ScheduleFormModal({
ariaLabel={t('agents.schedules.modal.timezone')}
/>
</div>
</div>
</Card>
<FormField label={t('agents.schedules.modal.instructionsLabel')}>
<FormField
label={t('agents.schedules.modal.instructionsLabel')}
error={instructionError}
>
<Textarea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
@@ -281,7 +304,12 @@ export default function ScheduleFormModal({
/>
</FormField>
{error && <p className="text-destructive text-sm">{error}</p>}
{runAtError && (
<Alert variant="destructive">
<CircleAlert aria-hidden="true" className="size-4" />
<AlertDescription>{runAtError}</AlertDescription>
</Alert>
)}
</div>
</Modal>
);
@@ -291,28 +319,32 @@ type FrequencyTabsProps = {
frequency: ScheduleFrequency;
onChange: (f: ScheduleFrequency) => void;
labels: Record<ScheduleFrequency, string>;
ariaLabel: string;
};
function FrequencyTabs({ frequency, onChange, labels }: FrequencyTabsProps) {
function FrequencyTabs({
frequency,
onChange,
labels,
ariaLabel,
}: FrequencyTabsProps) {
return (
<div className="bg-muted/60 dark:bg-muted/40 inline-flex w-full gap-1 rounded-full p-1">
{FREQUENCIES.map((f) => {
const active = f === frequency;
return (
<Button
key={f}
type="button"
variant={active ? 'outline' : 'ghost-muted'}
size="xs"
shape="pill"
onClick={() => onChange(f)}
className="flex-1"
aria-pressed={active}
>
// The muted track is a plain wrapper: ToggleGroup takes layout only.
<div className="bg-muted w-full rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
value={frequency}
onValueChange={(f) => f && onChange(f as ScheduleFrequency)}
aria-label={ariaLabel}
className="flex-nowrap"
>
{FREQUENCIES.map((f) => (
<ToggleGroupItem key={f} value={f} className="flex-1">
{labels[f]}
</Button>
);
})}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
@@ -322,7 +354,7 @@ type OnPickerProps = {
onChange: (next: ScheduleFormValues) => void;
tDay: (key: string) => string;
tMonth: (key: string) => string;
labels: { on: string; at: string; pickDate: string };
labels: { on: string; at: string; pickDate: string; days: string };
};
function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
@@ -330,7 +362,7 @@ function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
onChange({ ...values, ...patch });
return (
<div className="border-border flex flex-col gap-3 rounded-md border p-3">
<Card padding="sm">
{values.frequency === 'once' && (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-foreground text-sm font-medium">
@@ -366,24 +398,19 @@ function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
{values.frequency === 'weekly' && (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1">
{DAY_OPTIONS.map((d) => {
const active = d.value === values.dayOfWeek;
return (
<Button
key={d.key}
type="button"
size="sm"
variant={active ? 'default' : 'outline'}
onClick={() => set({ dayOfWeek: d.value })}
shape="pill"
aria-pressed={active}
>
{tDay(d.key)}
</Button>
);
})}
</div>
{/* A weekly schedule runs on one day: a radio group. */}
<ToggleGroup
type="single"
value={String(values.dayOfWeek)}
onValueChange={(day) => day && set({ dayOfWeek: Number(day) })}
aria-label={labels.days}
>
{DAY_OPTIONS.map((d) => (
<ToggleGroupItem key={d.key} value={String(d.value)}>
{tDay(d.key)}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center justify-between gap-2">
<span className="text-foreground text-sm font-medium">
{labels.at}
@@ -442,7 +469,7 @@ function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
</div>
</div>
)}
</div>
</Card>
);
}
@@ -461,17 +488,15 @@ function DatePicker({ value, onChange, placeholder }: DatePickerProps) {
<Button
type="button"
variant="combobox"
size="sm"
aria-label={placeholder}
data-placeholder={value ? undefined : ''}
className="h-9 justify-start"
className="justify-start"
>
<CalendarIcon className="size-4 opacity-70" />
<CalendarIcon className="opacity-70" />
{value ? formatDateLabel(value) : placeholder}
</Button>
</PopoverTrigger>
{/* z-200 keeps the popover above Modal (z-50); matches SelectContent. */}
<PopoverContent className="z-200 w-auto p-0" align="start">
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
@@ -511,7 +536,7 @@ function DayOfMonthSelect({
}: DayOfMonthSelectProps) {
return (
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
<SelectTrigger size="sm" aria-label={ariaLabel} className="h-9 w-[5rem]">
<SelectTrigger aria-label={ariaLabel} className="w-[5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -535,11 +560,7 @@ type MonthSelectProps = {
function MonthSelect({ value, onChange, tMonth, ariaLabel }: MonthSelectProps) {
return (
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
<SelectTrigger
size="sm"
aria-label={ariaLabel}
className="h-9 w-[6.5rem]"
>
<SelectTrigger aria-label={ariaLabel} className="w-[6.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1,6 +1,21 @@
import { describe, expect, it } from 'vitest';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { extractToolError } from './SchedulerToolCallCard';
import '../../locale/i18n';
import SchedulerToolCallCard, {
extractToolError,
type SchedulerToolCallCardProps,
} from './SchedulerToolCallCard';
vi.mock('react-redux', () => ({
useDispatch: () => vi.fn(),
useSelector: () => null,
}));
const render = (props: SchedulerToolCallCardProps) =>
renderToStaticMarkup(createElement(SchedulerToolCallCard, props));
// Regression for the iter-6 issue where ``cancel_scheduled_task`` returning
// a plain ``"Error: …"`` string still rendered "Scheduled task cancelled."
@@ -35,3 +50,80 @@ describe('extractToolError', () => {
expect(extractToolError(null)).toBeNull();
});
});
describe('SchedulerToolCallCard', () => {
const scheduled = {
task_id: 't1',
resolved_run_at: '2026-09-27T09:00:00Z',
instruction: 'Send the weekly report',
};
it('renders a scheduled task on a Card with an icon, time and cancel pill', () => {
const html = render({ actionName: 'schedule_task', result: scheduled });
expect(html).toContain('data-slot="card"');
expect(html).not.toContain('rounded-2xl border p-4');
expect(html).toContain('lucide-calendar-clock');
expect(html).toContain('>Scheduled task<');
expect(html).not.toContain('⏰');
expect(html).toContain('text-muted-foreground text-xs');
// Instruction: muted, indented under the title, no quotes or italic.
expect(html).toContain('Send the weekly report');
expect(html).not.toContain('“');
expect(html).not.toContain('italic');
expect(html).toContain('pl-7');
expect(html).toContain('data-variant="destructive-outline"');
expect(html).toContain('>Cancel<');
expect(html).not.toContain('mt-2');
});
it('shows a spinner, not the calendar icon, while pending', () => {
const html = render({
actionName: 'schedule_task',
result: scheduled,
status: 'pending',
});
expect(html).toContain('data-slot="spinner"');
expect(html).not.toContain('lucide-calendar-clock');
expect(html).toContain('Scheduling…');
});
it('puts the scheduling error on its own line under a title-only heading', () => {
const html = render({
actionName: 'schedule_task',
result: { error: 'bad time' },
});
expect(html).toContain('lucide-circle-alert');
expect(html).toContain('>Scheduling failed<');
expect(html).toContain('>bad time<');
});
it('puts the cancel error on its own line under a title-only heading', () => {
const html = render({
actionName: 'cancel_scheduled_task',
result: 'Error: not found',
});
expect(html).toContain('lucide-circle-alert');
expect(html).toContain('>Cancel failed<');
expect(html).toContain('>not found<');
});
it('renders a successful cancel with the cancelled calendar icon', () => {
const html = render({
actionName: 'cancel_scheduled_task',
result: { task_id: 't1', status: 'cancelled' },
});
expect(html).toContain('lucide-calendar-x-2');
expect(html).toContain('Scheduled task cancelled.');
});
it('lists pending tasks on a Card', () => {
const html = render({
actionName: 'list_scheduled_tasks',
result: { tasks: [scheduled] },
});
expect(html).toContain('data-slot="card"');
expect(html).toContain('1 pending scheduled task');
expect(html).toContain('Send the weekly report');
expect(html).not.toContain('mt-2');
});
});
@@ -1,11 +1,17 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { CalendarClock, CalendarX2, CircleAlert } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Card } from '../../components/ui/card';
import { Spinner } from '../../components/ui/spinner';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch } from '../../store';
import { formatDateTime } from '../../utils/dateTimeUtils';
import { deleteSchedule, loadSchedulesForAgent } from './schedulesSlice';
import ScheduleStatusBadge from './StatusBadge';
export type SchedulerToolCallCardProps = {
/** Outcome JSON the scheduler tool returned (action result). */
@@ -53,6 +59,7 @@ export default function SchedulerToolCallCard({
status,
agentId,
}: SchedulerToolCallCardProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const [cancelled, setCancelled] = useState<boolean>(false);
@@ -93,17 +100,30 @@ export default function SchedulerToolCallCard({
const cancelError = extractToolError(result);
if (cancelError) {
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<p className="text-destructive font-semibold">
Cancel failed: {cancelError}
</p>
</div>
<Card>
<div className="flex items-start gap-3">
<CircleAlert className="text-destructive mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-destructive font-semibold">
{t('agents.schedules.toolCard.cancelFailed')}
</p>
<p className="text-muted-foreground">{cancelError}</p>
</div>
</div>
</Card>
);
}
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<p className="font-semibold">Scheduled task cancelled.</p>
</div>
<Card>
<div className="flex items-start gap-3">
<CalendarX2 className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="font-semibold">
{t('agents.schedules.toolCard.taskCancelled')}
</p>
</div>
</div>
</Card>
);
}
@@ -112,19 +132,28 @@ export default function SchedulerToolCallCard({
? (parsed?.tasks as Array<Record<string, unknown>>)
: [];
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<p className="font-semibold">
{tasks.length} pending scheduled task{tasks.length === 1 ? '' : 's'}
</p>
<ul className="mt-2 flex flex-col gap-1">
{tasks.map((task) => (
<li key={String(task.task_id)}>
{formatTimestamp(task.resolved_run_at as string)} —{' '}
{String(task.instruction || task.name || task.task_id)}
</li>
))}
</ul>
</div>
<Card>
<div className="flex items-start gap-3">
<CalendarClock className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="font-semibold">
{t('agents.schedules.toolCard.pendingCount', {
count: tasks.length,
})}
</p>
</div>
</div>
{tasks.length > 0 && (
<ul className="flex flex-col gap-1 pl-7">
{tasks.map((task) => (
<li key={String(task.task_id)}>
{formatTimestamp(task.resolved_run_at as string)} —{' '}
{String(task.instruction || task.name || task.task_id)}
</li>
))}
</ul>
)}
</Card>
);
}
@@ -133,45 +162,59 @@ export default function SchedulerToolCallCard({
const schedulingError = error || extractToolError(result);
if (schedulingError) {
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<p className="text-destructive font-semibold">
Scheduling failed: {schedulingError}
</p>
</div>
<Card>
<div className="flex items-start gap-3">
<CircleAlert className="text-destructive mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-destructive font-semibold">
{t('agents.schedules.toolCard.schedulingFailed')}
</p>
<p className="text-muted-foreground">{schedulingError}</p>
</div>
</div>
</Card>
);
}
const iconClass = 'text-muted-foreground mt-0.5 size-4 shrink-0';
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<div className="flex items-center justify-between">
<p className="font-semibold">
{status === 'pending' ? '⏰ Scheduling…' : '⏰ Scheduled task'}
</p>
{runAt && (
<span className="text-muted-foreground text-xs">
{formatTimestamp(runAt)}
</span>
<Card>
<div className="flex items-start gap-3">
{status === 'pending' ? (
<Spinner size="xs" className="text-muted-foreground mt-0.5" />
) : cancelled ? (
<CalendarX2 className={iconClass} />
) : (
<CalendarClock className={iconClass} />
)}
<div className="flex min-w-0 flex-1 flex-col">
<p className="font-semibold">
{status === 'pending'
? t('agents.schedules.toolCard.scheduling')
: t('agents.schedules.toolCard.scheduled')}
</p>
{runAt && (
<span className="text-muted-foreground text-xs">
{formatTimestamp(runAt)}
</span>
)}
</div>
{taskId && !cancelled && (
<Button
type="button"
variant="destructive-outline"
size="xs"
shape="pill"
onClick={cancel}
>
{t('agents.schedules.cancel')}
</Button>
)}
{cancelled && <ScheduleStatusBadge status="cancelled" />}
</div>
{instruction && (
<p className="text-muted-foreground mt-2 text-sm italic">
“{instruction}”
</p>
<p className="text-muted-foreground pl-7">{instruction}</p>
)}
{taskId && !cancelled && (
<Button
type="button"
variant="destructive-outline"
size="xs"
onClick={cancel}
className="mt-2"
>
Cancel
</Button>
)}
{cancelled && (
<p className="text-muted-foreground mt-2 text-xs">Cancelled.</p>
)}
</div>
</Card>
);
}
+220 -211
View File
@@ -4,15 +4,15 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import userService from '../../api/services/userService';
import { Spinner } from '@/components/ui/spinner';
import { LoadingState } from '@/components/ui/loading-state';
import { SectionHeader } from '@/components/ui/section-header';
import { Button } from '../../components/ui/button';
import ConfirmationModal from '../../modals/ConfirmationModal';
import { ActiveState } from '../../models/misc';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch, RootState } from '../../store';
import { formatDateTime } from '../../utils/dateTimeUtils';
import { CurrentSectionHeader } from '../../navigation/SectionPageHeader';
import SectionPills from '../../navigation/SectionPills';
import SectionShell from '../../navigation/SectionShell';
import type { Agent } from '../types';
import type {
Schedule,
@@ -34,6 +34,10 @@ import {
updateSchedule,
} from './schedulesSlice';
// Timezones and dates carry slashes: React escapes on render, so i18next
// must not escape them first.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
const formatTimestamp = (value?: string | null): string => {
return value ? formatDateTime(value) : '—';
};
@@ -148,70 +152,189 @@ export default function SchedulesView() {
};
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<SectionPills className="mt-4" />
<div className="mt-6 flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
{loadingAgent ? (
<div className="flex h-[55vh] w-full items-center justify-center">
<Spinner />
<SectionShell pills>
<div className="flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
) : (
agent && (
<div className="flex flex-col gap-4 p-4">
<header className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{t('agents.schedules.heading')}
</h2>
)}
</div>
{loadingAgent ? (
<LoadingState fill="block" />
) : (
agent && (
<div className="flex flex-col gap-4 p-4">
<SectionHeader
title={t('agents.schedules.heading')}
actions={
<Button type="button" size="sm" onClick={openCreate}>
{t('agents.schedules.newRecurring')}
</Button>
</header>
<section>
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
{t('agents.schedules.recurring')} ({recurring.length})
</h3>
{recurring.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noRecurring')}
</p>
) : (
<ul className="flex flex-col gap-3">
{recurring.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron)} · tz:{' '}
{schedule.timezone} · next:{' '}
{formatTimestamp(schedule.next_run_at)}
}
/>
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="sm"
title={`${t('agents.schedules.recurring')} (${recurring.length})`}
/>
{recurring.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noRecurring')}
</p>
) : (
<ul className="flex flex-col gap-3">
{recurring.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<div className="flex gap-2">
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron, t)} ·{' '}
{t('agents.schedules.timezoneMeta', {
...NO_ESCAPE,
timezone: schedule.timezone,
})}{' '}
·{' '}
{t('agents.schedules.nextRunMeta', {
...NO_ESCAPE,
time: formatTimestamp(schedule.next_run_at),
})}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
>
{t('agents.schedules.runNow')}
</Button>
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.delete')}
</Button>
</div>
</div>
<Button
type="button"
variant="link"
size="xs"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-0.5 -ml-2"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="sm"
title={`${t('agents.schedules.oneTime')} (${oneTime.length})`}
/>
{oneTime.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noOneTime')}
</p>
) : (
<ul className="flex flex-col gap-2">
{oneTime.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
variant="outline-primary"
@@ -221,41 +344,8 @@ export default function SchedulesView() {
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
>
{t('agents.schedules.runNow')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
@@ -263,124 +353,43 @@ export default function SchedulesView() {
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.delete')}
{t('agents.schedules.cancel')}
</Button>
</div>
)}
</div>
<Button
type="button"
variant="link"
size="xs"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-0.5 -ml-2"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section>
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
{t('agents.schedules.oneTime')} ({oneTime.length})
</h3>
{oneTime.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noOneTime')}
</p>
) : (
<ul className="flex flex-col gap-2">
{oneTime.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.cancel')}
</Button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<RunDetailDrawer
run={activeRun}
onClose={() => setActiveRun(null)}
/>
{modalOpen && (
<ScheduleFormModal
key={editing?.id ?? 'create'}
open={modalOpen}
initial={editing}
agentToolIds={agentToolIds}
onClose={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
/>
</div>
</li>
))}
</ul>
)}
<ConfirmationModal
message={t('agents.schedules.deleteConfirm')}
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel={t('agents.schedules.delete')}
handleSubmit={confirmDelete}
handleCancel={() => setScheduleToDelete(null)}
variant="danger"
</section>
<RunDetailDrawer
run={activeRun}
onClose={() => setActiveRun(null)}
/>
{modalOpen && (
<ScheduleFormModal
key={editing?.id ?? 'create'}
open={modalOpen}
initial={editing}
agentToolIds={agentToolIds}
onClose={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
/>
</div>
)
)}
</div>
</div>
)}
<ConfirmationModal
message={t('agents.schedules.deleteConfirm')}
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel={t('agents.schedules.delete')}
handleSubmit={confirmDelete}
handleCancel={() => setScheduleToDelete(null)}
variant="destructive"
/>
</div>
)
)}
</SectionShell>
);
}
@@ -1,5 +1,7 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import i18n from '../../locale/i18n';
import ScheduleStatusBadge, {
formatStatusLabel,
@@ -106,3 +108,15 @@ describe('getStatusVariant', () => {
expect(getStatusVariant('unknown')).toBe('neutral');
});
});
describe('ScheduleStatusBadge locale', () => {
afterEach(async () => {
await i18n.changeLanguage('en');
});
it('reads its label from the locale files', async () => {
await i18n.changeLanguage('de');
const html = renderToStaticMarkup(<ScheduleStatusBadge status="failed" />);
expect(html).toContain('Fehlgeschlagen');
});
});
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import type { ScheduleRunStatus, ScheduleStatus } from '../types/schedule';
@@ -42,13 +44,16 @@ export default function ScheduleStatusBadge({
status,
className,
}: ScheduleStatusBadgeProps) {
const { t } = useTranslation();
return (
<Badge
variant={getStatusVariant(status)}
className={className}
data-status={status}
>
{formatStatusLabel(status)}
{t(`agents.schedules.status.${status}`, {
defaultValue: formatStatusLabel(status),
})}
</Badge>
);
}
@@ -142,12 +142,11 @@ export default function TimezoneCombobox({
) : (
<span className="truncate">{placeholder}</span>
)}
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
<ChevronsUpDown className="shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{/* z-200 keeps the popover above Modal (z-50); matches DatePicker. */}
<PopoverContent
className="z-200 w-[min(20rem,calc(100vw-2rem))] p-0"
className="w-[min(20rem,calc(100vw-2rem))] p-0"
align="start"
>
<Command shouldFilter={false}>
@@ -173,7 +172,7 @@ export default function TimezoneCombobox({
>
<Check
className={cn(
'mr-2 size-4 shrink-0',
'size-4 shrink-0',
selected ? 'opacity-100' : 'opacity-0',
)}
/>
@@ -1,3 +1,4 @@
import i18next from 'i18next';
import { describe, expect, it } from 'vitest';
import type { Schedule } from '../types/schedule';
@@ -323,3 +324,64 @@ describe('formatCron', () => {
expect(formatCron(undefined)).toBe('');
});
});
describe('formatCron with a translate function', () => {
// The English locale strings (agents.schedules.cron) must read exactly like
// the untranslated fallback.
const t = i18next.createInstance();
void t.init({
lng: 'en',
initAsync: false,
resources: {
en: {
translation: {
agents: {
schedules: {
cron: {
daily: 'Daily at {{time}}',
weekly: 'Weekly on {{day}} at {{time}}',
monthly: 'Monthly on day {{day}} at {{time}}',
yearly: 'Yearly on {{month}} {{day}} at {{time}}',
custom: 'Custom: {{expression}}',
dayNames: {
sun: 'Sunday',
mon: 'Monday',
tue: 'Tuesday',
wed: 'Wednesday',
thu: 'Thursday',
fri: 'Friday',
sat: 'Saturday',
},
monthNames: {
jan: 'January',
feb: 'February',
mar: 'March',
apr: 'April',
may: 'May',
jun: 'June',
jul: 'July',
aug: 'August',
sep: 'September',
oct: 'October',
nov: 'November',
dec: 'December',
},
},
},
},
},
},
},
});
it.each([
'0 9 * * *',
'0 9 * * 1',
'15 7 * * 0',
'0 10 15 * *',
'0 8 15 3 *',
'0 9 * * 1-5',
])('matches the English fallback for %s', (cron) => {
expect(formatCron(cron, t.t)).toBe(formatCron(cron));
});
});
+62 -8
View File
@@ -1,11 +1,8 @@
import type { TFunction } from 'i18next';
import type { Schedule } from '../types/schedule';
export type ScheduleFrequency =
| 'once'
| 'daily'
| 'weekly'
| 'monthly'
| 'yearly';
'once' | 'daily' | 'weekly' | 'monthly' | 'yearly';
export type ScheduleFormValues = {
frequency: ScheduleFrequency;
@@ -312,13 +309,70 @@ const formatTime12h = (hour: number, minute: number): string => {
return `${h12}:${pad2(minute)} ${period}`;
};
/** Human-readable label for a cron string the form emits; falls back for custom shapes. */
export function formatCron(expression?: string | null): string {
const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const MONTH_KEYS = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];
// Cron text is plain text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
/**
* Human-readable label for a cron string the form emits; falls back for custom shapes.
*
* Args:
* expression: The cron expression.
* t: Optional i18next translate function; without it the label is English.
*
* Returns:
* The label, or an empty string when there is no expression.
*/
export function formatCron(expression?: string | null, t?: TFunction): string {
if (!expression) return '';
const parsed = parseCron(expression);
if (!parsed) return `Custom: ${expression}`;
const custom = () =>
t
? t('agents.schedules.cron.custom', { ...NO_ESCAPE, expression })
: `Custom: ${expression}`;
if (!parsed) return custom();
const { frequency, hour, minute, dom, mon, dow } = parsed;
const time = formatTime12h(hour, minute);
if (t) {
const dayIndex = (dow ?? 0) % 7;
const monthIndex = ((mon ?? 1) - 1) % 12;
switch (frequency) {
case 'daily':
return t('agents.schedules.cron.daily', { time });
case 'weekly':
return t('agents.schedules.cron.weekly', {
time,
day: t(`agents.schedules.cron.dayNames.${DAY_KEYS[dayIndex]}`),
});
case 'monthly':
return t('agents.schedules.cron.monthly', { time, day: dom });
case 'yearly':
return t('agents.schedules.cron.yearly', {
time,
day: dom,
month: t(
`agents.schedules.cron.monthNames.${MONTH_KEYS[monthIndex]}`,
),
});
default:
return custom();
}
}
switch (frequency) {
case 'daily':
return `Daily at ${time}`;
File diff suppressed because it is too large. Load diff
@@ -74,6 +74,9 @@ describe('WorkflowMiniMap', () => {
return byName;
};
// No i18n instance in this test: t() renders the key.
const START = 'agents.workflow.nodes.start';
const tokens = (el: HTMLElement) => el.className.split(/\s+/);
it('pins the success fill on hover for a completed row', () => {
@@ -105,15 +108,15 @@ describe('WorkflowMiniMap', () => {
'animate-pulse',
]),
);
expect(tokens(r.Start)).toEqual(
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');
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', () => {
@@ -1,26 +1,30 @@
import {
Bot,
CheckCircle2,
ChevronDown,
Circle,
CircleAlert,
Code2,
CircleCheck,
CircleX,
CodeXml,
Database,
FileBox,
Flag,
GitBranch,
Loader2,
type LucideIcon,
MessageSquare,
Play,
StickyNote,
Workflow,
XCircle,
} from 'lucide-react';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { SectionHeader } from '@/components/ui/section-header';
import { SheetTitle } from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
MessageScroller,
MessageScrollerButton,
@@ -68,14 +72,16 @@ interface WorkflowPreviewProps {
workflowId?: string | null;
}
const NODE_ICONS: Record<string, React.ReactNode> = {
start: <Play className="h-3 w-3" />,
agent: <Bot className="h-3 w-3" />,
end: <Flag className="h-3 w-3" />,
note: <StickyNote className="h-3 w-3" />,
state: <Database className="h-3 w-3" />,
condition: <GitBranch className="h-3 w-3" />,
code: <Code2 className="h-3 w-3" />,
// Components, not elements: the step list draws them at 12px, the minimap
// rows at 14px.
const NODE_ICONS: Record<string, LucideIcon> = {
start: Play,
agent: Bot,
end: Flag,
note: StickyNote,
state: Database,
condition: GitBranch,
code: CodeXml,
};
const NODE_COLORS: Record<string, string> = {
@@ -101,6 +107,7 @@ function ExecutionDetails({
onToggle: () => void;
stepRefs?: React.RefObject<Map<string, HTMLDivElement>>;
}) {
const { t } = useTranslation();
const completedSteps = steps.filter(
(s) => s.status === 'completed' || s.status === 'failed',
);
@@ -118,7 +125,7 @@ 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="text-muted-foreground h-5 w-5" />
<Workflow className="text-muted-foreground size-5" />
</div>
<Button
type="button"
@@ -128,10 +135,11 @@ function ExecutionDetails({
className="-ml-2.5"
>
<p className="text-base font-semibold">
Execution Details
{t('agents.workflow.preview.executionDetails')}
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
({completedSteps.length}{' '}
{completedSteps.length === 1 ? 'step' : 'steps'})
{t('agents.workflow.preview.stepCount', {
count: completedSteps.length,
})}
</span>
</p>
<ChevronDown
@@ -144,16 +152,17 @@ function ExecutionDetails({
</div>
<div
className={cn(
'ml-3 grid w-full transition-all duration-300 ease-in-out',
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="space-y-2 pr-2">
<div className="flex flex-col gap-2 pr-2">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
const stateVars = step.stateDelta
? Object.entries(step.stateDelta).filter(
([key]) => !['query', 'chat_history'].includes(key),
@@ -176,10 +185,10 @@ function ExecutionDetails({
ref={(el) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
className="bg-muted dark:bg-accent rounded-xl p-3"
className="bg-muted rounded-xl p-3"
>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground flex h-5 w-5 shrink-0 items-center justify-center text-xs font-medium">
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<div
@@ -188,28 +197,26 @@ function ExecutionDetails({
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
>
{NODE_ICONS[step.nodeType] || (
<Circle className="h-3 w-3" />
)}
<StepIcon className="size-3" />
</div>
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CheckCircle2 className="text-success h-4 w-4" />
<CircleCheck className="text-success size-4" />
)}
{step.status === 'failed' && (
<XCircle className="text-destructive h-4 w-4" />
<CircleX className="text-destructive size-4" />
)}
</div>
</div>
{(hasOutput || step.error || stateVars.length > 0) && (
<div className="mt-3 space-y-2 text-sm">
<div className="mt-3 flex flex-col gap-2 text-sm">
{hasOutput && (
<div className="bg-muted rounded-lg p-2">
<span className="text-muted-foreground font-medium">
Output:{' '}
{t('agents.workflow.preview.outputLabel')}{' '}
</span>
<span className="text-foreground wrap-break-word whitespace-pre-wrap">
{truncateText(formattedOutput, 300)}
@@ -220,7 +227,9 @@ function ExecutionDetails({
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">Error: </span>
<span className="font-medium">
{t('agents.workflow.preview.errorLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
@@ -270,11 +279,12 @@ function RunArtifactsSection({
onToggle: () => void;
runInProgress?: boolean;
}) {
const { t } = useTranslation();
return (
<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="text-muted-foreground h-5 w-5" />
<FileBox className="text-muted-foreground size-5" />
</div>
<Button
type="button"
@@ -283,7 +293,9 @@ function RunArtifactsSection({
onClick={onToggle}
className="-ml-2.5"
>
<p className="text-base font-semibold">Artifacts</p>
<p className="text-base font-semibold">
{t('agents.workflow.preview.artifacts')}
</p>
<ChevronDown
className={cn(
'transition-transform duration-200',
@@ -294,7 +306,7 @@ function RunArtifactsSection({
</div>
<div
className={cn(
'ml-3 grid w-full transition-all duration-300 ease-in-out',
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
@@ -324,9 +336,10 @@ export function WorkflowMiniMap({
executionSteps: WorkflowExecutionStep[];
onNodeClick?: (nodeId: string) => void;
}) {
const { t } = useTranslation();
const getNodeDisplayName = (node: WorkflowNode) => {
if (node.type === 'start') return 'Start';
if (node.type === 'end') return 'End';
if (node.type === 'start') return t('agents.workflow.nodes.start');
if (node.type === 'end') return t('agents.workflow.nodes.end');
return node.title || node.data?.title || node.type;
};
@@ -372,9 +385,10 @@ export function WorkflowMiniMap({
};
return (
<div className="space-y-1">
<div className="flex flex-col gap-1">
{sortedNodes.map((node, index) => {
const status = getNodeStatus(node.id);
const NodeIcon = NODE_ICONS[node.type] || Circle;
const isActive = node.id === activeNodeId;
return (
<div key={node.id} className="relative">
@@ -409,11 +423,11 @@ export function WorkflowMiniMap({
>
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
'flex size-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" />}
<NodeIcon className="size-3.5" />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-foreground truncate font-medium">
@@ -427,13 +441,13 @@ export function WorkflowMiniMap({
</div>
<div className="shrink-0">
{status === 'running' && (
<Loader2 className="text-primary h-3 w-3 animate-spin" />
<Spinner size="xs" className="text-primary" />
)}
{status === 'completed' && (
<CheckCircle2 className="text-success h-3 w-3" />
<CircleCheck className="text-success size-3.5" />
)}
{status === 'failed' && (
<XCircle className="text-destructive h-3 w-3" />
<CircleX className="text-destructive size-3.5" />
)}
</div>
</Button>
@@ -448,6 +462,7 @@ export default function WorkflowPreview({
workflowData,
workflowId,
}: WorkflowPreviewProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectWorkflowPreviewQueries) as WorkflowQuery[];
@@ -605,10 +620,10 @@ export default function WorkflowPreview({
<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 text-muted-foreground flex items-center justify-center rounded-full p-3">
<Play className="h-4 w-4" />
<Play className="size-4" />
</div>
<div>
<h2 className="text-foreground text-xl font-bold">Preview</h2>
<SheetTitle>{t('agents.form.sections.preview')}</SheetTitle>
<p className="text-muted-foreground max-w-md truncate text-xs">
{workflowData.name}
{workflowData.description && ` - ${workflowData.description}`}
@@ -617,8 +632,8 @@ export default function WorkflowPreview({
</div>
{status === 'loading' && (
<span className="text-primary flex items-center gap-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
Running
<Spinner size="xs" />
{t('agents.schedules.status.running')}
</span>
)}
</div>
@@ -626,11 +641,13 @@ 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-muted-foreground text-xs font-semibold tracking-wider uppercase">
Workflow
</h3>
<SectionHeader
as="h3"
size="sm"
title={t('agents.workflow.preview.minimapHeading')}
/>
</div>
<div className="flex-1 scrollbar-thin overflow-y-auto p-3">
<div className="flex-1 overflow-y-auto p-3">
<WorkflowMiniMap
nodes={workflowData.nodes}
activeNodeId={activeNodeId}
@@ -650,13 +667,13 @@ export default function WorkflowPreview({
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-foreground text-xl font-semibold">
Test the workflow
{t('agents.workflow.preview.emptyTitle')}
</p>
</div>
) : (
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport className="scrollbar-thin px-4 pt-4">
<MessageScrollerViewport className="px-4 pt-4">
<MessageScrollerContent className="w-full">
{queries.map((query, index) => {
const querySteps = query.executionSteps || [];
@@ -774,7 +791,7 @@ export default function WorkflowPreview({
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
{sendBlockedMessage && (
<p className="text-destructive text-xs" role="alert">
{sendBlockedMessage}
{t(sendBlockedMessage)}
</p>
)}
<MessageInput
@@ -0,0 +1,95 @@
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: () => null,
}));
const listMock = vi.fn();
const detailMock = vi.fn();
vi.mock('../../api/services/userService', () => ({
default: {
listWorkflowRunArtifacts: (...args: unknown[]) => listMock(...args),
getDocumentArtifact: (...args: unknown[]) => detailMock(...args),
},
}));
vi.mock('../../components/DocumentArtifactView', () => ({
default: () => <div data-testid="artifact-view" />,
}));
import WorkflowRunArtifacts from './WorkflowRunArtifacts';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const failed = { ok: false, status: 500, json: async () => ({}) };
describe('WorkflowRunArtifacts', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
listMock.mockReset();
detailMock.mockReset();
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(<WorkflowRunArtifacts workflowRunId="run-1" />);
});
};
const buttonByText = (text: string) =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes(text),
);
it('shows a failed list load as an alert with Retry that refetches', async () => {
listMock.mockResolvedValue(failed);
await render();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain(
'agents.workflow.artifacts.loadListFailed',
);
expect(listMock).toHaveBeenCalledTimes(1);
await act(async () => buttonByText('retry')!.click());
expect(listMock).toHaveBeenCalledTimes(2);
});
it('retries a failed artifact preview', async () => {
listMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
artifacts: [
{ id: 'a1', kind: 'pdf', title: 'Report', current_version: 1 },
],
}),
});
detailMock.mockResolvedValue(failed);
await render();
await act(async () => buttonByText('Report')!.click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'agents.workflow.artifacts.loadFailed',
);
expect(detailMock).toHaveBeenCalledTimes(1);
await act(async () => buttonByText('retry')!.click());
expect(detailMock).toHaveBeenCalledTimes(2);
});
});
@@ -1,10 +1,12 @@
import { ChevronLeft, FileBox } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import DocumentArtifactView from '../../components/DocumentArtifactView';
import { Spinner } from '@/components/ui/spinner';
import { EmptyState } from '@/components/ui/empty-state';
import { LoadingState } from '@/components/ui/loading-state';
import {
isDocumentArtifact,
type DocumentArtifact,
@@ -12,6 +14,11 @@ import {
import { Button } from '@/components/ui/button';
import { selectToken } from '../../preferences/preferenceSlice';
// Error states hold locale keys; the render translates them.
const PREVIEW_UNSUPPORTED = 'agents.workflow.artifacts.previewUnsupported';
const LIST_FAILED = 'agents.workflow.artifacts.loadListFailed';
const DETAIL_FAILED = 'agents.workflow.artifacts.loadFailed';
interface RunArtifactSummary {
id: string;
kind: string | null;
@@ -31,6 +38,7 @@ export default function WorkflowRunArtifacts({
workflowRunId,
inProgress = false,
}: WorkflowRunArtifactsProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [artifacts, setArtifacts] = useState<RunArtifactSummary[] | null>(null);
const [loading, setLoading] = useState(true);
@@ -58,7 +66,7 @@ export default function WorkflowRunArtifacts({
if (res.status === 403) {
setArtifacts([]);
} else {
setError('Failed to load artifacts');
setError(LIST_FAILED);
}
setLoading(false);
return;
@@ -70,7 +78,7 @@ export default function WorkflowRunArtifacts({
})
.catch(() => {
if (cancelled) return;
setError('Failed to load artifacts');
setError(LIST_FAILED);
setLoading(false);
});
return () => {
@@ -93,7 +101,7 @@ export default function WorkflowRunArtifacts({
.then(async (res: Response) => {
if (cancelled) return;
if (!res.ok) {
setDetailError('Failed to load artifact');
setDetailError(DETAIL_FAILED);
setDetailLoading(false);
return;
}
@@ -103,13 +111,13 @@ export default function WorkflowRunArtifacts({
setDetail(data.artifact);
setDetailLoading(false);
} else {
setDetailError('This artifact cannot be previewed');
setDetailError(PREVIEW_UNSUPPORTED);
setDetailLoading(false);
}
})
.catch(() => {
if (cancelled) return;
setDetailError('Failed to load artifact');
setDetailError(DETAIL_FAILED);
setDetailLoading(false);
});
return () => {
@@ -131,20 +139,32 @@ export default function WorkflowRunArtifacts({
if (loading) {
return (
<div className="text-muted-foreground flex items-center gap-2 px-3 py-4 text-sm">
<Spinner size="sm" /> Loading artifacts...
</div>
<LoadingState
fill="block"
size="sm"
label={t('agents.workflow.artifacts.loading')}
/>
);
}
if (error) {
return (
<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
</Button>
</div>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={t(error)}
action={
<Button
type="button"
variant="outline"
size="sm"
onClick={() => loadList()}
>
{t('retry')}
</Button>
}
/>
);
}
@@ -152,8 +172,8 @@ export default function WorkflowRunArtifacts({
return (
<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.'}
? t('agents.workflow.artifacts.inProgress')
: t('agents.workflow.artifacts.empty')}
</div>
);
}
@@ -168,19 +188,39 @@ export default function WorkflowRunArtifacts({
size="sm"
onClick={() => setSelectedId(null)}
>
<ChevronLeft className="h-4 w-4" />
Back to artifacts
<ChevronLeft />
{t('agents.workflow.artifacts.back')}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{detailLoading ? (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
<LoadingState fill="parent" />
) : detailError === PREVIEW_UNSUPPORTED ? (
// Not a failed load: nothing to retry.
<EmptyState
size="sm"
illustration="none"
title={t(detailError)}
className="h-full"
/>
) : detailError ? (
<div className="flex h-full items-center justify-center">
<p className="text-destructive text-sm">{detailError}</p>
</div>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={t(detailError)}
className="h-full"
action={
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fetchDetail(selectedId)}
>
{t('retry')}
</Button>
}
/>
) : detail ? (
<DocumentArtifactView
artifact={detail}
@@ -202,13 +242,16 @@ export default function WorkflowRunArtifacts({
onClick={() => setSelectedId(artifact.id)}
className="h-auto w-full justify-start text-left"
>
<FileBox className="text-muted-foreground h-4 w-4 shrink-0" />
<FileBox className="text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-foreground truncate text-sm font-medium">
{artifact.title || `Artifact ${artifact.id.slice(0, 8)}`}
{artifact.title ||
t('agents.workflow.artifacts.untitled', {
id: artifact.id.slice(0, 8),
})}
</div>
<div className="text-muted-foreground truncate text-xs">
{artifact.kind || 'file'}
{artifact.kind || t('agents.workflow.artifacts.fileKind')}
{artifact.current_version != null
? ` · v${artifact.current_version}`
: ''}
@@ -1,17 +1,18 @@
import { Monitor } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function MobileBlocker() {
const { t } = useTranslation();
return (
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-6 text-center md:hidden">
<div className="bg-primary/10 dark:bg-primary/20 mb-6 flex h-20 w-20 items-center justify-center rounded-2xl">
<Monitor className="text-primary h-10 w-10" />
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-6 text-center lg:hidden">
<div className="bg-secondary mb-6 flex size-20 items-center justify-center rounded-2xl">
<Monitor className="text-primary size-10" />
</div>
<h2 className="text-foreground mb-2 text-xl font-bold">
Desktop Required
<h2 className="text-foreground mb-2 text-xl leading-tight font-semibold">
{t('agents.workflow.mobileBlocker.title')}
</h2>
<p className="text-muted-foreground max-w-sm text-sm leading-relaxed">
The Workflow Builder requires a larger screen for the best experience.
Please open this page on a desktop or laptop computer.
{t('agents.workflow.mobileBlocker.body')}
</p>
</div>
);
@@ -1,10 +1,12 @@
import { Plus } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
appendDocumentRef,
@@ -22,10 +24,10 @@ interface NodeDocumentsControlProps {
helpText?: string;
}
const MODE_OPTIONS: { mode: DocumentsMode; label: string }[] = [
{ mode: 'all', label: 'All input docs' },
{ mode: 'none', label: 'None' },
{ mode: 'choose', label: 'Choose…' },
const MODE_OPTIONS: { mode: DocumentsMode; labelKey: string }[] = [
{ mode: 'all', labelKey: 'agents.workflow.documents.modeAll' },
{ mode: 'none', labelKey: 'agents.workflow.documents.modeNone' },
{ mode: 'choose', labelKey: 'agents.workflow.documents.modeChoose' },
];
/** Shared All/None/Choose documents picker for agent and code workflow nodes. */
@@ -36,6 +38,7 @@ export default function NodeDocumentsControl({
label,
helpText,
}: NodeDocumentsControlProps) {
const { t } = useTranslation();
// Track mode in component state so "Choose" stays reachable even when the
// chosen list is empty (an empty list otherwise reads back as "None").
const [mode, setMode] = useState<DocumentsMode>(() =>
@@ -61,37 +64,42 @@ export default function NodeDocumentsControl({
};
return (
<FormField label={label} hint={helpText} id={fieldId}>
<div
id={fieldId}
role="group"
aria-label={label}
className="border-border bg-card flex gap-1 rounded-xl border p-1"
>
{MODE_OPTIONS.map(({ mode: optionMode, label: modeLabel }) => (
<Button
key={optionMode}
type="button"
variant={mode === optionMode ? 'outline' : 'ghost-muted'}
size="xs"
onClick={() => selectMode(optionMode)}
className="flex-1"
>
{modeLabel}
</Button>
))}
<FormField label={label} hint={helpText} id={fieldId} float={false}>
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
<div className="border-border bg-card rounded-xl border p-1">
<ToggleGroup
id={fieldId}
type="single"
size="xs"
value={mode}
onValueChange={(next) => next && selectMode(next as DocumentsMode)}
aria-label={label}
className="flex-nowrap"
>
{MODE_OPTIONS.map(({ mode: optionMode, labelKey }) => (
<ToggleGroupItem
key={optionMode}
value={optionMode}
className="flex-1"
>
{t(labelKey)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{showChoose && (
<div className="flex flex-col gap-2">
<MultiSelect
// Its own id: the FormField id belongs to the mode group above.
id={`${fieldId}-pick`}
options={withChosenDocumentOptions(options, chosen)}
selected={chosen}
onChange={(next) =>
onChange(documentsModeToInputDocuments('choose', next))
}
placeholder="Select documents..."
searchPlaceholder="Search variables..."
emptyText="No upstream documents"
placeholder={t('agents.workflow.documents.selectPlaceholder')}
searchPlaceholder={t('agents.workflow.documents.searchPlaceholder')}
emptyText={t('agents.workflow.documents.empty')}
/>
<div className="flex gap-2">
<Input
@@ -105,7 +113,7 @@ export default function NodeDocumentsControl({
addRef();
}
}}
placeholder="Add ref (e.g. A1)"
placeholder={t('agents.workflow.documents.refPlaceholder')}
/>
<Button
type="button"
@@ -113,8 +121,8 @@ export default function NodeDocumentsControl({
onClick={addRef}
className="shrink-0"
>
<Plus size={14} />
Add
<Plus className="size-3.5" />
{t('agents.form.buttons.add')}
</Button>
</div>
</div>
@@ -0,0 +1,77 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
import PromptTextArea from './PromptTextArea';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('PromptTextArea mention menu', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (value: string) =>
act(() => {
root.render(
<PromptTextArea
value={value}
onChange={() => undefined}
nodes={[]}
edges={[]}
selectedNodeId="n1"
/>,
);
});
const menu = () =>
document.querySelector('[data-slot="popover-content"]') as HTMLElement;
it('opens a portalled popover on "{{" and keeps focus in the textarea', () => {
render('{{');
const textarea = container.querySelector('textarea')!;
act(() => {
textarea.focus();
textarea.setSelectionRange(2, 2);
textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
});
expect(menu()).not.toBeNull();
expect(container.contains(menu())).toBe(false);
expect(menu().textContent).toContain('source.content');
expect(document.activeElement).toBe(textarea);
});
it('closes when the trigger text is gone', () => {
render('{{');
const textarea = container.querySelector('textarea')!;
act(() => {
textarea.focus();
textarea.setSelectionRange(2, 2);
textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
});
render('hello');
act(() => {
textarea.setSelectionRange(5, 5);
textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
});
expect(menu()).toBeNull();
});
});
@@ -1,20 +1,15 @@
import { Braces, Plus, Search } from 'lucide-react';
import {
type CSSProperties,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useId, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Edge, Node } from 'reactflow';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { EmptyState } from '@/components/ui/empty-state';
import { FormField, FormFieldBoundary } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
@@ -208,6 +203,16 @@ export function extractUpstreamVariables(
return variables;
}
// Built-in section names (and the unnamed-node fallbacks) shown translated;
// a node's own title is a value and shows as written.
const SECTION_LABEL_KEYS: Record<string, string> = {
'Global context': 'agents.workflow.variables.globalContext',
'Workflow input': 'agents.workflow.variables.workflowInput',
Agent: 'agents.workflow.nodes.agent',
Code: 'agents.workflow.nodes.code',
'Set State': 'agents.workflow.nodes.setState',
};
function groupBySection(
vars: WorkflowVariable[],
): Map<string, WorkflowVariable[]> {
@@ -246,10 +251,11 @@ function VariableListWithSearch({
variables: WorkflowVariable[];
onSelect: (templatePath: string) => void;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
// Own id: this list renders inside PromptTextArea's FormField (also through
// the popover portal), whose context would otherwise hand the textarea's id
// to this search box too.
// Own id, and a FormFieldBoundary below: the "Add context" popover renders
// inside PromptTextArea's FormField (through a portal), whose context would
// otherwise hand this search box the textarea's id and floating placeholder.
const searchId = useId();
const filtered = useMemo(
@@ -265,54 +271,60 @@ function VariableListWithSearch({
const grouped = useMemo(() => groupBySection(filtered), [filtered]);
return (
<div className="flex w-full flex-col overflow-hidden">
<div className="border-border flex items-center gap-2 border-b px-3 py-2">
<Search className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
<Input
id={searchId}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search variables..."
variant="bare"
/>
</div>
<FormFieldBoundary>
<div className="flex w-full flex-col overflow-hidden">
<div className="border-border flex items-center gap-2 border-b px-3 py-2">
<Search className="text-muted-foreground size-3.5 shrink-0" />
<Input
id={searchId}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('agents.workflow.variables.searchPlaceholder')}
variant="bare"
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="text-muted-foreground px-3 py-4 text-center text-xs">
No variables found
</div>
) : (
Array.from(grouped.entries()).map(([section, vars]) => (
<div key={section}>
<div className="text-muted-foreground truncate px-3 pt-2.5 pb-1 text-xs font-semibold tracking-wider uppercase">
{section}
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<EmptyState
size="xs"
illustration="none"
title={t('agents.workflow.variables.empty')}
/>
) : (
Array.from(grouped.entries()).map(([section, vars]) => (
<div key={section}>
<div className="text-muted-foreground truncate px-3 pt-2.5 pb-1 text-xs font-semibold tracking-wider uppercase">
{SECTION_LABEL_KEYS[section]
? t(SECTION_LABEL_KEYS[section])
: section}
</div>
{vars.map((v) => (
<Button
key={`${section}-${v.templatePath}`}
type="button"
variant="ghost"
size="sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(v.templatePath);
}}
className="w-full justify-start"
>
<Braces className="text-primary size-3.5 shrink-0" />
<span className="text-foreground truncate font-medium">
{v.label}
</span>
</Button>
))}
</div>
{vars.map((v) => (
<Button
key={`${section}-${v.templatePath}`}
type="button"
variant="ghost"
size="sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(v.templatePath);
}}
className="w-full justify-start"
>
<Braces className="text-primary h-3.5 w-3.5 shrink-0" />
<span className="text-foreground truncate font-medium">
{v.label}
</span>
</Button>
))}
</div>
))
)}
))
)}
</div>
</div>
</div>
</FormFieldBoundary>
);
}
@@ -337,13 +349,11 @@ export default function PromptTextArea({
rows = 4,
label,
}: PromptTextAreaProps) {
const { t } = useTranslation();
const textareaId = useId();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
const [filterText, setFilterText] = useState('');
const [cursorInsertPos, setCursorInsertPos] = useState<number | null>(null);
const [contextOpen, setContextOpen] = useState(false);
@@ -375,14 +385,6 @@ export default function PromptTextArea({
if (triggerMatch) {
setFilterText(triggerMatch[1].trim());
setCursorInsertPos(cursorPos);
const wrapper = wrapperRef.current;
if (!wrapper) return;
setDropdownPos({
top: wrapper.offsetHeight + 4,
left: 0,
});
setShowDropdown(true);
} else {
setShowDropdown(false);
@@ -436,112 +438,114 @@ export default function PromptTextArea({
[value, onChange],
);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as HTMLElement)
) {
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [showDropdown]);
// The mention menu opens under the field and leaves focus in the textarea,
// so typing keeps filtering it.
const field = (
<div
ref={wrapperRef}
className="border-border focus-within:ring-ring bg-card relative rounded-xl border transition-all focus-within:ring-2"
>
<div
ref={overlayRef}
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap"
>
{value ? (
<HighlightedOverlay text={value} />
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
<textarea
id={textareaId}
ref={textareaRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
setTimeout(checkForTrigger, 0);
}}
onKeyUp={checkForTrigger}
onKeyDown={(e) => {
if (showDropdown && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setShowDropdown(false);
}
}}
onScroll={() => {
if (overlayRef.current && textareaRef.current) {
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 text-transparent caret-black outline-none focus-visible:ring-3 dark:caret-white"
rows={rows}
placeholder={placeholder}
spellCheck={false}
/>
<div className="absolute right-4 bottom-1.5 z-10">
<Popover open={contextOpen} onOpenChange={setContextOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="link" size="xs">
<Plus className="h-3 w-3" />
Add context
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
className="w-60 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<VariableListWithSearch
variables={variables}
onSelect={insertVariableFromButton}
/>
</PopoverContent>
</Popover>
</div>
{showDropdown && filtered.length > 0 && (
<PopoverAnchor asChild>
<div className="border-border focus-within:ring-ring bg-card relative rounded-xl border transition-shadow focus-within:ring-2">
<div
ref={dropdownRef}
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
}
ref={overlayRef}
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap"
>
<VariableListWithSearch
variables={filtered}
onSelect={insertVariable}
/>
{value ? (
<HighlightedOverlay text={value} />
) : (
// Under a floating label the example waits for focus, like
// ui/Textarea, so it doesn't collide with the resting label.
<span
className={
label
? 'text-muted-foreground invisible group-focus-within/float:visible'
: 'text-muted-foreground'
}
>
{placeholder}
</span>
)}
</div>
)}
</div>
<textarea
id={textareaId}
ref={textareaRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
setTimeout(checkForTrigger, 0);
}}
onKeyUp={checkForTrigger}
onKeyDown={(e) => {
if (showDropdown && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setShowDropdown(false);
}
}}
onScroll={() => {
if (overlayRef.current && textareaRef.current) {
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
}
}}
className="focus-visible:ring-ring/50 focus-visible:border-ring caret-foreground relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm text-transparent outline-none focus-visible:ring-3"
rows={rows}
placeholder={placeholder}
spellCheck={false}
/>
<div className="absolute right-4 bottom-1.5 z-10">
<Popover open={contextOpen} onOpenChange={setContextOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="link" size="xs">
<Plus className="size-3" />
{t('agents.workflow.variables.addContext')}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
className="w-60 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<VariableListWithSearch
variables={variables}
onSelect={insertVariableFromButton}
/>
</PopoverContent>
</Popover>
</div>
</div>
</PopoverAnchor>
);
if (!label) return <div>{field}</div>;
return (
<FormField label={label} id={textareaId}>
{field}
</FormField>
<Popover
open={showDropdown && filtered.length > 0}
onOpenChange={(open) => {
if (!open) setShowDropdown(false);
}}
>
{label ? (
<FormField label={label} id={textareaId}>
{field}
</FormField>
) : (
field
)}
<PopoverContent
align="start"
className="w-64 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (textareaRef.current?.contains(e.target as HTMLElement)) {
e.preventDefault();
}
}}
>
<VariableListWithSearch
variables={filtered}
onSelect={insertVariable}
/>
</PopoverContent>
</Popover>
);
}
@@ -1,6 +1,8 @@
import React, { ReactNode } from 'react';
import { Handle, Position } from 'reactflow';
import { cn } from '@/lib/utils';
interface BaseNodeProps {
title: string;
children?: ReactNode;
@@ -49,9 +51,13 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
return (
<div
className={`rounded-full border ${bgColor} ${borderColor} shadow-md transition-all hover:shadow-lg ${
selected ? 'scale-105' : ''
} max-w-[250px] min-w-[180px]`}
className={cn(
'rounded-full border shadow-md transition hover:shadow-lg',
bgColor,
borderColor,
selected && 'scale-105',
'max-w-[250px] min-w-[180px]',
)}
>
{handles.target && (
<Handle
@@ -64,7 +70,11 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
<div className="flex items-center gap-3 px-4 py-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${iconBg} ${iconColor}`}
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
iconBg,
iconColor,
)}
>
{icon}
</div>
@@ -1,10 +1,14 @@
import { Code2 } from 'lucide-react';
import { CodeXml } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { NodeProps } from 'reactflow';
import { CodeNodeConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
type CodeNodeData = {
title?: string;
label?: string;
@@ -12,7 +16,8 @@ type CodeNodeData = {
};
const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
const title = data.title || data.label || 'Code';
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.code');
const config = data.config || {};
const code = (config.code || '').trim();
const firstLine = code.split('\n').find((line) => line.trim() !== '') || '';
@@ -24,7 +29,7 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
title={title}
type="code"
selected={selected}
icon={<Code2 size={16} />}
icon={<CodeXml className="size-4" />}
handles={{ source: true, target: true }}
>
<div className="flex flex-col gap-1">
@@ -36,14 +41,22 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
{codeHint}
</div>
) : (
<div className="text-muted-foreground text-xs">No code yet</div>
<div className="text-muted-foreground text-xs">
{t('agents.workflow.nodes.noCode')}
</div>
)}
{config.output_variable && (
<div
className="text-muted-foreground truncate text-xs"
title={`Output: ${config.output_variable}`}
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
Output: {config.output_variable}
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
)}
</div>
@@ -1,7 +1,10 @@
import { GitBranch } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Handle, NodeProps, Position } from 'reactflow';
import { cn } from '@/lib/utils';
import { ConditionCase } from '../../types/workflow';
type ConditionNodeData = {
@@ -29,18 +32,21 @@ function getHandleTop(index: number, total: number): string {
}
const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
const title = data.title || data.label || 'If / Else';
const { t } = useTranslation();
const title =
data.title || data.label || t('agents.workflow.nodes.condition');
const cases = data.config?.cases || [];
const totalOutputs = cases.length + 1;
const height = getNodeHeight(cases.length);
return (
<div
className={`bg-card relative rounded-2xl border shadow-md transition-all ${
className={cn(
'bg-card relative rounded-2xl border shadow-md transition',
selected
? 'border-primary ring-primary scale-105 ring-2'
: 'border-border hover:shadow-lg'
}`}
: 'border-border hover:shadow-lg',
)}
style={{ minWidth: 180, maxWidth: 220, height }}
>
<Handle
@@ -51,8 +57,8 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
/>
<div className="flex items-center gap-3 px-3 py-2">
<div className="bg-warning/10 text-warning flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<GitBranch size={14} />
<div className="bg-warning/10 text-warning flex size-9 shrink-0 items-center justify-center rounded-full">
<GitBranch className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-2">
<div
@@ -61,8 +67,10 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
>
{title}
</div>
<div className="text-muted-foreground text-xs uppercase">
{data.config?.mode || 'simple'}
<div className="text-muted-foreground text-xs">
{data.config?.mode === 'advanced'
? t('agents.workflow.nodes.modeAdvanced')
: t('agents.workflow.nodes.modeSimple')}
</div>
</div>
</div>
@@ -75,7 +83,9 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
style={{ height: ROW_HEIGHT }}
>
<span className="text-warning shrink-0 text-xs font-medium">
{i === 0 ? 'If' : 'Else if'}
{i === 0
? t('agents.workflow.nodes.if')
: t('agents.workflow.nodes.elseIf')}
</span>
{c.name && (
<span
@@ -89,7 +99,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
))}
<div className="flex items-center gap-1" style={{ height: ROW_HEIGHT }}>
<span className="text-muted-foreground text-xs font-medium">
Else
{t('agents.workflow.nodes.else')}
</span>
</div>
</div>
@@ -1,10 +1,14 @@
import { Database } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { NodeProps } from 'reactflow';
import { StateOperationConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
type SetStateNodeData = {
label?: string;
title?: string;
@@ -16,7 +20,8 @@ type SetStateNodeData = {
};
const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
const title = data.title || data.label || 'Set State';
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.setState');
const operations = data.config?.operations || [];
const hasLegacy = !operations.length && data.variable;
@@ -25,29 +30,39 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
title={title}
type="state"
selected={selected}
icon={<Database size={16} />}
icon={<Database className="size-4" />}
handles={{ source: true, target: true }}
>
<div className="flex flex-col gap-1">
{operations.length > 0 ? (
<div
className="text-muted-foreground truncate text-xs"
title={`${operations.length} operation(s)`}
title={t('agents.workflow.nodes.operationCount', {
count: operations.length,
})}
>
{operations.length} variable{operations.length !== 1 ? 's' : ''}
{t('agents.workflow.nodes.variableCount', {
count: operations.length,
})}
</div>
) : hasLegacy ? (
<>
<div
className="text-muted-foreground truncate text-xs uppercase"
title={`Variable: ${data.variable}`}
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.variableTitle', {
...NO_ESCAPE,
name: data.variable,
})}
>
{data.variable}
</div>
{data.value && (
<div
className="text-info truncate text-xs"
title={`Value: ${data.value}`}
title={t('agents.workflow.nodes.valueTitle', {
...NO_ESCAPE,
value: data.value,
})}
>
{data.value}
</div>
+31 -15
View File
@@ -1,5 +1,6 @@
import { Bot, Flag, Play, StickyNote } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
@@ -8,21 +9,25 @@ import CodeNode from './CodeNode';
import ConditionNode from './ConditionNode';
import SetStateNode from './SetStateNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
export const StartNode = memo(function StartNode({
selected,
}: {
selected: boolean;
}) {
const { t } = useTranslation();
return (
<BaseNode
title="Start"
title={t('agents.workflow.nodes.start')}
type="start"
selected={selected}
handles={{ target: false, source: true }}
icon={<Play size={16} />}
icon={<Play className="size-4" />}
>
<div className="text-muted-foreground text-xs">
Entry point of the workflow
{t('agents.workflow.nodes.startHint')}
</div>
</BaseNode>
);
@@ -33,15 +38,18 @@ export const EndNode = memo(function EndNode({
}: {
selected: boolean;
}) {
const { t } = useTranslation();
return (
<BaseNode
title="End"
title={t('agents.workflow.nodes.end')}
type="end"
selected={selected}
handles={{ target: true, source: false }}
icon={<Flag size={16} />}
icon={<Flag className="size-4" />}
>
<div className="text-muted-foreground text-xs">Workflow completion</div>
<div className="text-muted-foreground text-xs">
{t('agents.workflow.nodes.endHint')}
</div>
</BaseNode>
);
});
@@ -62,19 +70,20 @@ export const AgentNode = memo(function AgentNode({
};
selected: boolean;
}) {
const title = data.title || data.label || 'Agent';
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.agent');
const config = data.config || {};
return (
<BaseNode
title={title}
type="agent"
selected={selected}
icon={<Bot size={16} />}
icon={<Bot className="size-4" />}
>
<div className="flex flex-col gap-1">
{config.agent_type && (
<div
className="text-muted-foreground truncate text-xs uppercase"
className="text-muted-foreground truncate text-xs"
title={config.agent_type}
>
{config.agent_type}
@@ -91,9 +100,15 @@ export const AgentNode = memo(function AgentNode({
{config.output_variable && (
<div
className="text-muted-foreground truncate text-xs"
title={`Output: ${config.output_variable}`}
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
Output: {config.output_variable}
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
)}
</div>
@@ -108,7 +123,8 @@ export const NoteNode = memo(function NoteNode({
data: { title?: string; label?: string; content?: string };
selected: boolean;
}) {
const title = data.title || data.label || 'Note';
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.note');
const maxContentLength = 120;
const displayContent =
data.content && data.content.length > maxContentLength
@@ -120,15 +136,15 @@ export const NoteNode = memo(function NoteNode({
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',
'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',
selected
? 'border-warning ring-warning scale-105 ring-2'
: 'border-warning/50 hover:shadow-lg',
)}
>
<div className="flex items-start gap-3">
<div className="bg-warning/15 text-warning flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
<StickyNote size={18} />
<div className="bg-warning/15 text-warning flex size-10 shrink-0 items-center justify-center rounded-full">
<StickyNote className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<div
@@ -76,9 +76,9 @@ export function collectCompletedAttachmentIds(
.map((att) => att.id);
}
/** User-facing message shown when a Preview send is blocked. */
/** Locale key of the message shown when a Preview send is blocked. */
export const UNSAVED_DRAFT_ATTACHMENTS_MESSAGE =
'Save the workflow before attaching documents in Preview.';
'agents.workflow.preview.unsavedDraftAttachments';
/**
* Reason a Preview send must be blocked, or null when it may proceed. An
+17 -15
View File
@@ -10,8 +10,9 @@ import {
setConversation,
updateConversationId,
} from '../conversation/conversationSlice';
import { Button } from './ui/button';
import { IconButton } from './ui/icon-button';
import ProfileButton from './ProfileButton';
import { cn } from '@/lib/utils';
interface ActionButtonsProps {
className?: string;
@@ -45,37 +46,38 @@ export default function ActionButtons({
};
return (
<div
className={`fixed top-0 z-10 flex h-16 flex-col justify-center transition-all duration-300 ${
isArtifactOpen ? 'right-[calc(50%+1rem)]' : 'right-4'
}`}
className={cn(
'fixed top-0 z-10 flex h-16 flex-col justify-center transition-[right] duration-300 ease-in-out',
isArtifactOpen ? 'right-[calc(50%+1rem)]' : 'right-4',
)}
>
<div className={`flex items-center gap-2 sm:gap-4 ${className}`}>
<div className={cn('flex items-center gap-2 sm:gap-4', className)}>
{showNewChat && (
<Button
type="button"
<IconButton
label={t('newChat')}
side="bottom"
variant="ghost-muted"
size="icon"
shape="pill"
title={t('actionButtons.openNewChat')}
onClick={newChat}
className="lg:hidden"
>
<Plus className="size-5" aria-label="NewChat" />
</Button>
<Plus aria-hidden />
</IconButton>
)}
{showShare && conversationId && (
<>
<Button
type="button"
<IconButton
label={t('actionButtons.share')}
side="bottom"
variant="ghost-muted"
size="icon"
shape="pill"
title={t('actionButtons.share')}
onClick={() => setShareModalState(true)}
>
<Share className="size-5" aria-label="share" />
</Button>
<Share aria-hidden />
</IconButton>
{isShareModalOpen && (
<ShareConversationModal
close={() => setShareModalState(false)}
+4 -1
View File
@@ -23,7 +23,10 @@ function renderWith(roles: string[] | null) {
describe('AdminRoute', () => {
it('hides content until roles are resolved', () => {
expect(renderWith(null)).not.toContain('admin-secret');
const html = renderWith(null);
expect(html).not.toContain('admin-secret');
expect(html).toContain('data-slot="loading-state"');
expect(html).toContain('data-fill="screen"');
});
it('redirects (renders nothing) for non-admins', () => {
+2 -6
View File
@@ -5,7 +5,7 @@ import {
selectIsAdmin,
selectRolesResolved,
} from '../preferences/preferenceSlice';
import { Spinner } from '@/components/ui/spinner';
import { LoadingState } from '@/components/ui/loading-state';
/**
* Cosmetic route guard — NOT a security boundary. The server enforces admin
@@ -22,11 +22,7 @@ export default function AdminRoute({
const rolesResolved = useSelector(selectRolesResolved);
if (!rolesResolved) {
return (
<div className="flex h-screen items-center justify-center">
<Spinner />
</div>
);
return <LoadingState fill="screen" />;
}
if (!isAdmin) {
return <Navigate to="/" replace />;
@@ -0,0 +1,108 @@
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: () => null,
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false],
}));
const documentMock = vi.fn();
const legacyMock = vi.fn();
vi.mock('../api/services/userService', () => ({
default: {
getDocumentArtifact: (...args: unknown[]) => documentMock(...args),
getArtifact: (...args: unknown[]) => legacyMock(...args),
},
}));
import ArtifactSidebar from './ArtifactSidebar';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('ArtifactSidebar', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
documentMock.mockReset();
legacyMock.mockReset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('shows a failed load as an alert with Retry that refetches', async () => {
documentMock.mockResolvedValue({ ok: false, status: 500 });
legacyMock.mockRejectedValue(new Error('offline'));
await act(async () => {
root.render(
<ArtifactSidebar
isOpen
onClose={vi.fn()}
artifactId="a1"
conversationId="c1"
variant="split"
/>,
);
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('components.artifact.fetchFailed');
expect(documentMock).toHaveBeenCalledTimes(1);
const retry = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'retry',
);
await act(async () => retry!.click());
expect(documentMock).toHaveBeenCalledTimes(2);
});
// L9: the raw-JSON fallback is a code block in the padded panel, so it is a
// filled Card around the mono recipe and scrolls inside the panel.
it('shows an unknown artifact type as a filled Card code block', async () => {
documentMock.mockResolvedValue({ ok: false, status: 404 });
legacyMock.mockResolvedValue({
success: true,
artifact: { artifact_type: 'mystery', data: { value: 42 } },
});
await act(async () => {
root.render(
<ArtifactSidebar
isOpen
onClose={vi.fn()}
artifactId="a2"
conversationId="c1"
variant="split"
/>,
);
});
const pre = container.querySelector('pre')!;
expect(pre.textContent).toContain('"mystery"');
expect(pre.className.split(' ')).toEqual(
expect.arrayContaining([
'font-mono',
'text-xs',
'whitespace-pre-wrap',
'wrap-break-word',
]),
);
const card = pre.parentElement!;
expect(card.getAttribute('data-slot')).toBe('card');
expect(card.getAttribute('data-variant')).toBe('filled');
expect(card.getAttribute('data-padding')).toBe('sm');
});
});
+95 -61
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import ReactMarkdown from 'react-markdown';
import { useSelector } from 'react-redux';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@@ -9,6 +11,7 @@ import {
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import remarkGfm from 'remark-gfm';
import { markdownHeadings } from '@/lib/markdown';
import { cn } from '@/lib/utils';
import userService from '../api/services/userService';
@@ -17,9 +20,12 @@ import { selectToken } from '../preferences/preferenceSlice';
import { isDocumentArtifact, type DocumentArtifact } from './artifactViewUtils';
import CopyButton from './CopyButton';
import DocumentArtifactView from './DocumentArtifactView';
import { Spinner } from '@/components/ui/spinner';
import { EmptyState } from '@/components/ui/empty-state';
import { LoadingState } from '@/components/ui/loading-state';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { IconButton } from './ui/icon-button';
import { Sheet, SheetContent } from './ui/sheet';
type TodoItem = {
@@ -61,15 +67,24 @@ type ArtifactSidebarProps = {
variant?: 'overlay' | 'split';
};
const ARTIFACT_TITLE_BY_TYPE: Record<ArtifactData['artifact_type'], string> = {
todo_list: 'Todo List',
note: 'Note',
memory: 'Memory',
const ARTIFACT_TITLE_KEY_BY_TYPE: Record<
ArtifactData['artifact_type'],
string
> = {
todo_list: 'components.artifact.types.todoList',
note: 'components.artifact.types.note',
memory: 'components.artifact.types.memory',
};
function getArtifactTitle(artifact: ArtifactData | null, toolName?: string) {
if (artifact)
return ARTIFACT_TITLE_BY_TYPE[artifact.artifact_type] ?? 'Artifact';
function getArtifactTitle(
t: TFunction,
artifact: ArtifactData | null,
toolName?: string,
) {
if (artifact) {
const key = ARTIFACT_TITLE_KEY_BY_TYPE[artifact.artifact_type];
return key ? t(key) : t('components.artifact.fallbackTitle');
}
const formattedToolName = (toolName ?? '')
.replace(/_/g, ' ')
@@ -77,22 +92,29 @@ function getArtifactTitle(artifact: ArtifactData | null, toolName?: string) {
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
return formattedToolName || 'Artifact';
return formattedToolName || t('components.artifact.fallbackTitle');
}
function TodoListView({ data }: { data: TodoArtifactData }) {
const { t } = useTranslation();
return (
<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">
<Badge variant="success">{data.completed_count} done</Badge>
<Badge variant="info">{data.open_count} open</Badge>
<Badge variant="success">
{t('components.artifact.todo.done', {
count: data.completed_count,
})}
</Badge>
<Badge variant="info">
{t('components.artifact.todo.open', { count: data.open_count })}
</Badge>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{data.items.length === 0 ? (
<p className="text-muted-foreground text-center text-sm">
No todos yet
{t('components.artifact.todo.empty')}
</p>
) : (
<ul className="space-y-2">
@@ -108,7 +130,7 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
>
<span
className={cn(
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2',
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2',
item.status === 'completed'
? 'border-success bg-success text-success-foreground'
: 'border-input',
@@ -116,7 +138,7 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
>
{item.status === 'completed' && (
<svg
className="h-3 w-3"
className="size-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -155,6 +177,7 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
}
function NoteView({ data }: { data: NoteArtifactData }) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
return (
@@ -162,9 +185,9 @@ function NoteView({ data }: { data: NoteArtifactData }) {
<div className="mb-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{data.line_count} lines
{t('components.artifact.note.lines', { count: data.line_count })}
</span>
<CopyButton textToCopy={data.content || ''} />
<CopyButton textToCopy={data.content || ''} side="bottom" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
@@ -173,6 +196,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
...markdownHeadings,
code(props) {
const {
children,
@@ -194,6 +218,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
</span>
<CopyButton
textToCopy={String(children).replace(/\n$/, '')}
side="bottom"
/>
</div>
<SyntaxHighlighter
@@ -236,28 +261,16 @@ function NoteView({ data }: { data: NoteArtifactData }) {
},
a({ children, href }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{children}
</a>
<Button variant="link" size="inline" asChild>
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
</Button>
);
},
p({ children }) {
return <p className="whitespace-pre-wrap">{children}</p>;
},
h1({ children }) {
return <h1 className="text-xl font-bold">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-lg font-bold">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-base font-bold">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote className="border-border border-l-4 pl-4 italic">
@@ -271,7 +284,9 @@ function NoteView({ data }: { data: NoteArtifactData }) {
</ReactMarkdown>
</div>
) : (
<p className="text-muted-foreground text-sm">Empty note</p>
<p className="text-muted-foreground text-sm">
{t('components.artifact.note.empty')}
</p>
)}
</div>
</div>
@@ -288,6 +303,7 @@ export default function ArtifactSidebar({
}: ArtifactSidebarProps) {
const lastSuccessfulTodoArtifactIdRef = React.useRef<string | null>(null);
const currentFetchIdRef = React.useRef<string | null>(null);
const { t } = useTranslation();
const token = useSelector(selectToken);
const [artifact, setArtifact] = useState<ArtifactData | null>(null);
const [documentArtifact, setDocumentArtifact] =
@@ -299,7 +315,8 @@ export default function ArtifactSidebar({
artifactId,
);
const title = documentArtifact?.title || getArtifactTitle(artifact, toolName);
const title =
documentArtifact?.title || getArtifactTitle(t, artifact, toolName);
// Reset last successful todo artifact ID when conversation changes
useEffect(() => {
@@ -419,15 +436,15 @@ export default function ArtifactSidebar({
// Ensure we show a visible error state instead of rendering nothing.
const message =
data?.message ||
(status === 404 ? 'Artifact not found' : null) ||
'Failed to load artifact';
(status === 404 ? t('components.artifact.notFound') : null) ||
t('components.artifact.loadFailed');
setError(message);
setLoading(false);
})
.catch(() => {
// Ignore if this is not the current fetch
if (currentFetchIdRef.current !== fetchId) return;
setError('Failed to fetch artifact');
setError(t('components.artifact.fetchFailed'));
setLoading(false);
});
}
@@ -442,17 +459,26 @@ export default function ArtifactSidebar({
const renderContent = () => {
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
return <LoadingState fill="parent" />;
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-destructive text-sm">{error}</p>
</div>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={error}
className="h-full"
action={
<Button
variant="outline"
size="sm"
onClick={() => setRefreshNonce((n) => n + 1)}
>
{t('retry')}
</Button>
}
/>
);
}
if (documentArtifact) {
@@ -467,7 +493,9 @@ export default function ArtifactSidebar({
if (!artifact) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Artifact not found</p>
<p className="text-muted-foreground text-sm">
{t('components.artifact.notFound')}
</p>
</div>
);
}
@@ -478,9 +506,15 @@ export default function ArtifactSidebar({
return <NoteView data={artifact.data} />;
default:
return (
<pre className="text-muted-foreground text-xs">
{JSON.stringify(artifact, null, 2)}
</pre>
<Card
variant="filled"
padding="sm"
className="max-h-full overflow-auto"
>
<pre className="text-muted-foreground font-mono text-xs wrap-break-word whitespace-pre-wrap">
{JSON.stringify(artifact, null, 2)}
</pre>
</Card>
);
}
};
@@ -498,16 +532,16 @@ export default function ArtifactSidebar({
<span className="text-muted-foreground text-sm font-medium">
{title}
</span>
<Button
type="button"
<IconButton
label={t('agents.close')}
side="bottom"
variant="ghost"
size="icon-sm"
shape="pill"
onClick={onClose}
aria-label="Close"
>
<X className="h-3 w-3" />
</Button>
<X aria-hidden="true" className="size-4" />
</IconButton>
</div>
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
</div>
@@ -525,7 +559,7 @@ export default function ArtifactSidebar({
<SheetContent
side="right"
showCloseButton={false}
title={title || 'Artifact preview'}
title={title || t('components.artifact.preview')}
className="h-full w-80 p-0 sm:w-96 sm:max-w-none"
>
<div className="flex min-h-0 flex-1 flex-col">
@@ -533,16 +567,16 @@ export default function ArtifactSidebar({
<span className="text-muted-foreground text-sm font-medium">
{title}
</span>
<Button
type="button"
<IconButton
label={t('agents.close')}
side="bottom"
variant="ghost"
size="icon"
shape="pill"
onClick={onClose}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
<X aria-hidden="true" className="size-4" />
</IconButton>
</div>
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
</div>
+160
View File
@@ -0,0 +1,160 @@
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false],
useDebouncedValue: (value: unknown) => value,
useLoaderState: (initial: boolean) => useState(initial),
useMediaQuery: () => ({ isMobile: false }),
useOutsideAlerter: () => undefined,
}));
vi.mock('../api/services/userService', () => ({
default: {
getDocumentChunks: vi.fn(async () => ({
ok: true,
json: async () => ({
page: 1,
per_page: 5,
total: 1,
chunks: [{ doc_id: 'c1', text: 'Late pickup clause', metadata: {} }],
}),
})),
},
}));
import Chunks from './Chunks';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
};
describe('Chunks', () => {
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 (onFileSelect = vi.fn()) => {
await act(async () => {
root.render(
<Chunks
documentId="doc"
documentName="Contracts"
handleGoBack={vi.fn()}
onFileSearch={() => [
{ name: 'msa.pdf', path: 'contracts/msa.pdf', isFile: true },
{ name: 'sla.docx', path: 'contracts/sla.docx', isFile: true },
]}
onFileSelect={onFileSelect}
/>,
);
});
return onFileSelect;
};
it('picks a file search result with the keyboard', async () => {
const onFileSelect = await render();
const input = container.querySelector<HTMLInputElement>(
'[data-slot="command-input"]',
)!;
expect(input).not.toBeNull();
await act(async () => setInputValue(input, 'contracts'));
const items = container.querySelectorAll('[data-slot="command-item"]');
expect(items).toHaveLength(2);
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
});
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(onFileSelect).toHaveBeenCalledWith('contracts/sla.docx');
});
it('renders each chunk tile as a button', async () => {
await render();
const tile = container.querySelector<HTMLButtonElement>(
'button[data-slot="card"]',
);
expect(tile).not.toBeNull();
expect(tile!.textContent).toContain('Late pickup clause');
expect(tile!.className).not.toContain('hover:scale-105');
});
const renderPath = async (props: {
onPathSelect?: (depth: number) => void;
handleGoBack?: () => void;
}) => {
await act(async () => {
root.render(
<Chunks
documentId="doc"
documentName="Contracts"
handleGoBack={props.handleGoBack ?? vi.fn()}
path="legal/2024/msa.pdf"
displayPath="legal/2024/msa.pdf"
onPathSelect={props.onPathSelect}
/>,
);
});
};
const crumbButton = (label: string) =>
Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-slot="breadcrumb-link"]',
),
).find((el) => el.textContent === label);
it('opens the root and parent folders from the path crumbs', async () => {
const onPathSelect = vi.fn();
await renderPath({ onPathSelect });
await act(async () => crumbButton('Contracts')!.click());
expect(onPathSelect).toHaveBeenLastCalledWith(0);
await act(async () => crumbButton('2024')!.click());
expect(onPathSelect).toHaveBeenLastCalledWith(2);
// The file is the current crumb, not a link.
expect(crumbButton('msa.pdf')).toBeUndefined();
});
it('without onPathSelect the root goes back and folders are text', async () => {
const handleGoBack = vi.fn();
await renderPath({ handleGoBack });
await act(async () => crumbButton('Contracts')!.click());
expect(handleGoBack).toHaveBeenCalledTimes(1);
expect(crumbButton('legal')).toBeUndefined();
expect(crumbButton('2024')).toBeUndefined();
});
});
+237 -239
View File
@@ -1,15 +1,12 @@
import { ArrowLeft, File, Folder, Search as SearchIcon } from 'lucide-react';
import { File, Folder } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { cn } from '@/lib/utils';
import { cn, fieldFrame } from '@/lib/utils';
import userService from '../api/services/userService';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import {
useDarkTheme,
useDebouncedValue,
useLoaderState,
useMediaQuery,
@@ -20,10 +17,29 @@ import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import { ChunkType } from '../settings/types';
import { formatChunkTokens } from './chunkUtils';
import Pagination from './DocumentPagination';
import SkeletonLoader from './SkeletonLoader';
import PathHeader from './tree/PathHeader';
import { Button } from './ui/button';
import { Card } from './ui/card';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command';
import { EmptyState } from './ui/empty-state';
import { Input } from './ui/input';
import { Pagination } from './ui/pagination';
// The search field's frame: the Input look around a CommandInput (38px,
// rounded-md, focus ring while the input has keyboard focus). CommandInput's
// row is 36px with a bottom rule; pt-px + overflow-hidden clips that rule.
const SEARCH_FRAME = cn(
fieldFrame,
'border-border has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-ring/50 h-9.5 overflow-hidden rounded-md pt-px has-[input:focus-visible]:ring-3',
);
interface LineNumberedTextareaProps {
value: string;
@@ -113,6 +129,12 @@ interface ChunksProps {
onFileSelect?: (path: string) => void;
/** Extra header control, rendered left of the chunk actions. */
headerAction?: React.ReactNode;
/**
* Opens a level of the path: 0 is the source root, n the folder made of
* the first n path segments. Without it the root crumb calls handleGoBack
* and folder crumbs are plain text.
*/
onPathSelect?: (depth: number) => void;
}
const Chunks: React.FC<ChunksProps> = ({
@@ -124,6 +146,7 @@ const Chunks: React.FC<ChunksProps> = ({
onFileSearch,
onFileSelect,
headerAction,
onPathSelect,
}) => {
const [fileSearchQuery, setFileSearchQuery] = useState('');
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>(
@@ -132,7 +155,6 @@ const Chunks: React.FC<ChunksProps> = ({
const searchDropdownRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
const [paginatedChunks, setPaginatedChunks] = useState<ChunkType[]>([]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(5);
@@ -304,159 +326,137 @@ const Chunks: React.FC<ChunksProps> = ({
const filteredChunks = paginatedChunks;
// The root crumb returns to the source (TreeBrowser: its top folder;
// standalone: back to the source list). Folder crumbs only navigate when
// the parent can open a folder (onPathSelect); the file is the current one.
const renderPathNavigation = () => {
const rootLabel = documentName ?? '';
const selectRoot = onPathSelect ? () => onPathSelect(0) : handleGoBack;
return (
<div className="mb-0 flex min-h-[38px] flex-col gap-2 text-base sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full items-center sm:w-auto">
<Button
type="button"
variant="outline"
size="icon-xs"
shape="pill"
className="mr-3"
aria-label={t('settings.sources.back')}
onClick={
editingChunk
? () => setEditingChunk(null)
: isAddingChunk
? () => setIsAddingChunk(false)
: handleGoBack
}
>
<ArrowLeft />
</Button>
<div className="flex flex-wrap items-center">
{/* Removed the directory icon */}
<span className="text-primary font-semibold wrap-break-word">
{documentName}
</span>
{pathParts.length > 0 && (
<>
<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-foreground'
}`}
>
{part}
</span>
{index < pathParts.length - 1 && (
<span className="text-muted-foreground mx-1 shrink-0">
/
</span>
)}
</React.Fragment>
))}
</>
)}
</div>
</div>
<div className="mt-2 flex w-full flex-row flex-nowrap items-center justify-end gap-2 overflow-x-auto sm:mt-0 sm:w-auto">
{headerAction}
{editingChunk ? (
!isEditing ? (
<>
<Button
type="button"
shape="pill"
className="min-w-[108px]"
onClick={() => setIsEditing(true)}
>
{t('modals.chunk.edit')}
</Button>
<Button
type="button"
variant="destructive-outline"
shape="pill"
className="min-w-[108px]"
onClick={() => {
confirmDeleteChunk(editingChunk);
}}
>
{t('modals.chunk.delete')}
</Button>
</>
) : (
<>
<Button
type="button"
variant="ghost"
onClick={() => {
setIsEditing(false);
}}
shape="pill"
className="min-w-[108px]"
>
{t('modals.chunk.cancel')}
</Button>
<Button
type="button"
onClick={() => {
if (editingText.trim()) {
const hasChanges =
editingTitle !==
(editingChunk?.metadata?.title || '') ||
editingText !== (editingChunk?.text || '');
if (hasChanges) {
handleUpdateChunk(
editingTitle,
editingText,
editingChunk,
);
}
setIsEditing(false);
setEditingChunk(null);
}
}}
disabled={
!editingText.trim() ||
(editingTitle === (editingChunk?.metadata?.title || '') &&
editingText === (editingChunk?.text || ''))
}
shape="pill"
className="min-w-[108px]"
>
{t('modals.chunk.save')}
</Button>
</>
)
) : isAddingChunk ? (
<PathHeader
root={{
label: rootLabel,
onSelect: pathParts.length > 0 ? selectRoot : undefined,
}}
segments={pathParts.map((part, index) => ({
label: part,
onSelect:
onPathSelect && index < pathParts.length - 1
? () => onPathSelect(index + 1)
: undefined,
}))}
backLabel={t('settings.sources.back')}
onBack={
editingChunk
? () => setEditingChunk(null)
: isAddingChunk
? () => setIsAddingChunk(false)
: handleGoBack
}
actions={
headerAction || editingChunk || isAddingChunk ? (
<>
<Button
type="button"
variant="ghost"
onClick={() => setIsAddingChunk(false)}
shape="pill"
className="min-w-[108px]"
>
{t('modals.chunk.cancel')}
</Button>
<Button
type="button"
onClick={() => {
if (editingText.trim()) {
handleAddChunk(editingTitle, editingText);
setIsAddingChunk(false);
}
}}
disabled={!editingText.trim()}
shape="pill"
className="min-w-[108px]"
>
{t('modals.chunk.add')}
</Button>
{headerAction}
{editingChunk ? (
!isEditing ? (
<>
<Button
type="button"
size="field"
shape="pill"
onClick={() => setIsEditing(true)}
>
{t('modals.chunk.edit')}
</Button>
<Button
type="button"
variant="destructive-outline"
size="field"
shape="pill"
onClick={() => {
confirmDeleteChunk(editingChunk);
}}
>
{t('modals.chunk.delete')}
</Button>
</>
) : (
<>
<Button
type="button"
variant="ghost"
onClick={() => {
setIsEditing(false);
}}
size="field"
shape="pill"
>
{t('modals.chunk.cancel')}
</Button>
<Button
type="button"
onClick={() => {
if (editingText.trim()) {
const hasChanges =
editingTitle !==
(editingChunk?.metadata?.title || '') ||
editingText !== (editingChunk?.text || '');
if (hasChanges) {
handleUpdateChunk(
editingTitle,
editingText,
editingChunk,
);
}
setIsEditing(false);
setEditingChunk(null);
}
}}
disabled={
!editingText.trim() ||
(editingTitle ===
(editingChunk?.metadata?.title || '') &&
editingText === (editingChunk?.text || ''))
}
size="field"
shape="pill"
>
{t('modals.chunk.save')}
</Button>
</>
)
) : isAddingChunk ? (
<>
<Button
type="button"
variant="ghost"
onClick={() => setIsAddingChunk(false)}
size="field"
shape="pill"
>
{t('modals.chunk.cancel')}
</Button>
<Button
type="button"
onClick={() => {
if (editingText.trim()) {
handleAddChunk(editingTitle, editingText);
setIsAddingChunk(false);
}
}}
disabled={!editingText.trim()}
size="field"
shape="pill"
>
{t('modals.chunk.add')}
</Button>
</>
) : null}
</>
) : null}
</div>
</div>
) : undefined
}
/>
);
};
@@ -498,55 +498,49 @@ const Chunks: React.FC<ChunksProps> = ({
const renderFileSearch = () => {
return (
<div className="relative" ref={searchDropdownRef}>
<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"
aria-label="Search"
{/* `contents`: the Command only scopes cmdk (the arrow keys move from
the input to the rows); the frame and the dropdown draw the look. */}
<Command shouldFilter={false} className="contents">
<div className={SEARCH_FRAME}>
<CommandInput
value={fileSearchQuery}
onValueChange={handleFileSearchChange}
placeholder={t('settings.sources.searchFiles')}
/>
}
/>
{fileSearchQuery && (
<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="text-muted-foreground py-2 text-center text-sm">
{t('settings.sources.noResults')}
</div>
) : (
fileSearchResults.map((result, index) => (
<div
key={index}
title={result.path}
onClick={() => handleSearchResultClick(result)}
className={`hover:bg-muted flex cursor-pointer items-center px-3 py-2 ${
index !== fileSearchResults.length - 1
? 'border-border border-b'
: ''
}`}
>
{result.isFile ? (
<File className="text-muted-foreground mr-2 size-4 shrink-0" />
) : (
<Folder className="text-primary mr-2 size-4 shrink-0" />
)}
<span className="truncate text-sm">
{result.name ||
result.path.split('/').pop() ||
result.path}
</span>
</div>
))
)}
</div>
</div>
)}
{fileSearchQuery && (
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 w-full overflow-hidden rounded-xl border shadow-md">
<CommandList className="max-h-[calc(100vh-200px)]">
{fileSearchResults.length === 0 ? (
<CommandEmpty>{t('settings.sources.noResults')}</CommandEmpty>
) : (
<CommandGroup>
{fileSearchResults.map((result) => (
<CommandItem
key={result.path}
value={result.path}
title={result.path}
onSelect={() => handleSearchResultClick(result)}
>
{result.isFile ? (
<File />
) : (
<Folder className="text-primary" />
)}
<span className="truncate">
{result.name ||
result.path.split('/').pop() ||
result.path}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</div>
)}
</Command>
</div>
);
};
@@ -564,7 +558,7 @@ const Chunks: React.FC<ChunksProps> = ({
{!editingChunk && !isAddingChunk ? (
<>
<div className="mb-3 flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div className="border-border flex h-[38px] w-full flex-1 items-center overflow-hidden rounded-md border">
<div className="border-border flex h-9.5 w-full flex-1 items-center overflow-hidden rounded-md border">
<div className="text-foreground flex h-full items-center px-4 font-medium whitespace-nowrap">
{totalChunks > 999999
? `${(totalChunks / 1000000).toFixed(2)}M`
@@ -587,9 +581,9 @@ const Chunks: React.FC<ChunksProps> = ({
</div>
<Button
type="button"
size="field"
shape="pill"
className="w-full min-w-[108px] shrink-0 whitespace-normal sm:w-auto"
title={t('settings.sources.addChunk')}
className="w-full shrink-0 sm:w-auto"
onClick={() => {
setIsAddingChunk(true);
setEditingTitle('');
@@ -606,39 +600,43 @@ 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="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')}
className="mx-auto mb-2 h-24 w-24"
/>
{t('settings.sources.noChunks')}
</div>
<EmptyState
size="sm"
title={t('settings.sources.noChunks')}
className="col-span-full min-h-[50vh] w-full"
/>
) : (
filteredChunks.map((chunk, index) => (
<div
<Card
key={index}
className="border-border relative flex h-[197px] w-full max-w-[487px] transform cursor-pointer flex-col justify-between overflow-hidden rounded-md border transition-transform duration-200 hover:scale-105"
onClick={() => {
setEditingChunk(chunk);
setEditingTitle(chunk.metadata?.title || '');
setEditingText(chunk.text || '');
}}
interactive
padding="none"
asChild
className="relative h-[197px] w-full max-w-[487px] justify-between gap-0 overflow-hidden"
>
<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="text-muted-foreground text-sm">
{formatChunkTokens(chunk.metadata)}{' '}
{t('settings.sources.tokensUnit')}
<button
type="button"
onClick={() => {
setEditingChunk(chunk);
setEditingTitle(chunk.metadata?.title || '');
setEditingText(chunk.text || '');
}}
>
<div className="w-full">
<div className="border-border bg-muted flex w-full items-center justify-between border-b px-4 py-3">
<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 text-sm leading-5 font-normal">
{chunk.text}
</p>
</div>
</div>
<div className="px-4 pt-3 pb-6">
<p className="text-foreground line-clamp-6 text-sm leading-5 font-normal">
{chunk.text}
</p>
</div>
</div>
</div>
</button>
</Card>
))
)}
</div>
@@ -659,7 +657,7 @@ const Chunks: React.FC<ChunksProps> = ({
editingChunk && (
<div className="w-full">
<div className="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="border-border bg-muted flex w-full items-center justify-between border-b px-4 py-3">
<div className="text-muted-foreground text-sm">
{formatChunkTokens(editingChunk.metadata)}{' '}
{t('settings.sources.tokensUnit')}
@@ -690,11 +688,11 @@ const Chunks: React.FC<ChunksProps> = ({
!editingChunk &&
!isAddingChunk && (
<Pagination
currentPage={page}
totalPages={Math.ceil(totalChunks / perPage)}
rowsPerPage={perPage}
page={page}
pageCount={Math.ceil(totalChunks / perPage)}
pageSize={perPage}
onPageChange={setPage}
onRowsPerPageChange={(rows) => {
onPageSizeChange={(rows) => {
setPerPage(rows);
setPage(1);
}}
@@ -711,7 +709,7 @@ const Chunks: React.FC<ChunksProps> = ({
handleSubmit={handleConfirmedDelete}
handleCancel={handleCancelDelete}
submitLabel={t('modals.chunk.delete')}
variant="danger"
variant="destructive"
/>
</div>
);
+8 -5
View File
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { ConfigRequirements } from '../modals/types';
import { FormField } from './ui/form-field';
import { FormField, type FormFieldProps } from './ui/form-field';
import { Input } from './ui/input';
import {
Select,
@@ -20,6 +20,8 @@ interface ConfigFieldsProps {
errors?: { [key: string]: string };
isEditing?: boolean;
hasEncryptedCredentials?: boolean;
/** Surface behind the fields, for the floating labels' notch. */
labelSurface?: FormFieldProps['labelSurface'];
}
function shouldShowField(
@@ -39,6 +41,7 @@ export default function ConfigFields({
errors = {},
isEditing = false,
hasEncryptedCredentials = false,
labelSurface,
}: ConfigFieldsProps) {
const sortedFields = useMemo(
() =>
@@ -51,7 +54,7 @@ export default function ConfigFields({
if (sortedFields.length === 0) return null;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-5">
{sortedFields.map(([key, spec]) => {
if (!shouldShowField(spec, values)) return null;
@@ -65,16 +68,16 @@ export default function ConfigFields({
return (
<FormField
key={key}
id={key}
label={spec.label || key}
required={!!spec.required}
error={error}
labelSurface={labelSurface}
>
<Select
value={value || spec.default || ''}
onValueChange={(v) => onChange(key, v)}
>
<SelectTrigger variant="ghost" size="lg" className="w-full">
<SelectTrigger size="field" className="w-full">
<SelectValue placeholder={spec.label || key} />
</SelectTrigger>
<SelectContent>
@@ -96,9 +99,9 @@ export default function ConfigFields({
label={spec.label || key}
required={!!spec.required}
error={error}
labelSurface={labelSurface}
>
<Input
id={key}
type={
spec.secret
? 'password'
+1 -1
View File
@@ -238,7 +238,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
</Alert>
) : (
<Button type="button" onClick={handleAuth} className="w-full">
<svg className="h-5 w-5" viewBox="0 0 24 24">
<svg className="size-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M6.28 3l5.72 10H24l-5.72-10H6.28zm11.44 0L12 13l5.72 10H24L18.28 3h-.56zM0 13l5.72 10h5.72L5.72 13H0z"
+9 -14
View File
@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
import { Check, RefreshCw } from 'lucide-react';
import userService from '../api/services/userService';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
@@ -106,14 +107,12 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
const topRightAction = (
<>
{headerAction}
<button
<Button
type="button"
size="field"
shape="pill"
onClick={() => setSyncConfirmationModal('ACTIVE')}
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
? 'bg-muted text-muted-foreground cursor-not-allowed'
: 'bg-primary hover:bg-primary/90 text-primary-foreground'
}`}
title={
isSyncing
? `${t('settings.sources.syncing')} ${syncProgress}%`
@@ -123,24 +122,20 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
}
>
{syncDone ? (
<Check className="mr-2 size-4" />
<Check />
) : isSyncing ? (
// The busy state shows its percentage, so it keeps the label and
// draws the app's ring spinner at icon size (DESIGN.md, Button).
<Spinner
size="sm"
className="mr-2 size-4"
label={t('settings.sources.syncing')}
/>
<Spinner size="xs" label={t('settings.sources.syncing')} />
) : (
<RefreshCw className="mr-2 size-4" />
<RefreshCw />
)}
{isSyncing
? `${syncProgress}%`
: syncDone
? 'Done'
: t('settings.sources.sync')}
</button>
</Button>
</>
);
@@ -50,6 +50,9 @@ describe('CopyButton', () => {
expect(button().dataset.variant).toBe('ghost-muted');
expect(button().dataset.size).toBe('icon-sm');
expect(button().dataset.shape).toBe('pill');
// IconButton: a tooltip trigger, never a native title.
expect(button().hasAttribute('title')).toBe(false);
expect(button().dataset.state).toBe('closed');
});
it('uses icon-xs when size is xs and xs with text', () => {
@@ -58,6 +61,7 @@ describe('CopyButton', () => {
act(() => root.render(<CopyButton textToCopy="hello" showText />));
expect(button().dataset.size).toBe('xs');
expect(button().textContent).toContain('conversation.copy');
expect(button().hasAttribute('title')).toBe(false);
});
it('copies the text and shows the copied state without disabling', async () => {
@@ -85,4 +89,32 @@ describe('CopyButton', () => {
expect(button().getAttribute('aria-label')).toBe('conversation.copy');
expect(button().dataset.variant).toBe('ghost-muted');
});
it('renders a text-only lg pill with custom labels that resets', async () => {
act(() =>
root.render(
<CopyButton
textToCopy="https://x/share/1"
size="lg"
copyLabel="Copy link"
copiedLabel="Link copied"
copiedDuration={1000}
/>,
),
);
expect(button().dataset.size).toBe('lg');
expect(button().dataset.shape).toBe('pill');
expect(button().dataset.variant).toBe('default');
expect(button().querySelector('svg')).toBeNull();
expect(button().textContent).toBe('Copy link');
await click();
expect(copyMock).toHaveBeenCalledWith('https://x/share/1');
expect(button().textContent).toContain('Link copied');
act(() => {
vi.advanceTimersByTime(1000);
});
expect(button().textContent).toBe('Copy link');
});
});
+65 -18
View File
@@ -4,15 +4,25 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from './ui/button';
import { IconButton, type IconButtonProps } from './ui/icon-button';
type CopyButtonProps = {
textToCopy: string;
/** Icon-only button size: `xs` is `icon-xs` (28px), `sm` is `icon-sm` (32px). */
size?: 'xs' | 'sm';
/**
* `xs` is `icon-xs` (28px) and `sm` is `icon-sm` (32px), icon only; `lg`
* is a text-only primary pill (Button `lg`, 40px) for a dialog footer.
*/
size?: 'xs' | 'sm' | 'lg';
/** Show a "Copy"/"Copied" label next to the icon (renders at Button `xs`). */
showText?: boolean;
copiedDuration?: number;
/** The idle label (default `conversation.copy`). */
copyLabel?: string;
/** The label after a copy (default `conversation.copied`). */
copiedLabel?: string;
className?: string;
/** Tooltip side of the icon-only button: `bottom` in a header or toolbar. */
side?: IconButtonProps['side'];
};
const DEFAULT_COPIED_DURATION = 2000;
@@ -22,7 +32,10 @@ export default function CopyButton({
size = 'sm',
showText = false,
copiedDuration = DEFAULT_COPIED_DURATION,
copyLabel,
copiedLabel,
className,
side,
}: CopyButtonProps) {
const { t } = useTranslation();
const [isCopied, setIsCopied] = useState(false);
@@ -35,11 +48,8 @@ export default function CopyButton({
const IconComponent = isCopied ? Check : Copy;
const buttonTitle = isCopied
? t('conversation.copied')
: t('conversation.copy');
const displayedText = isCopied
? t('conversation.copied')
: t('conversation.copy');
? (copiedLabel ?? t('conversation.copied'))
: (copyLabel ?? t('conversation.copy'));
const handleCopy = useCallback(async () => {
if (isCopied || copyInFlightRef.current) return;
@@ -75,22 +85,59 @@ export default function CopyButton({
}
};
}, []);
const liveRegion = (
<span className="sr-only" aria-live="polite" aria-atomic="true">
{isCopied ? t('conversation.copied', 'Copied to clipboard') : ''}
</span>
);
const variant = isCopied ? 'secondary' : 'ghost-muted';
if (size === 'lg') {
// The visible label is the name; the live region announces the copy.
return (
<Button
type="button"
size="lg"
shape="pill"
onClick={handleCopy}
className={className}
>
{buttonTitle}
{liveRegion}
</Button>
);
}
if (showText) {
return (
<Button
type="button"
variant={variant}
size="xs"
shape="pill"
onClick={handleCopy}
className={className}
aria-label={buttonTitle}
>
<IconComponent aria-hidden="true" />
<span>{buttonTitle}</span>
{liveRegion}
</Button>
);
}
return (
<Button
type="button"
variant={isCopied ? 'secondary' : 'ghost-muted'}
size={showText ? 'xs' : size === 'xs' ? 'icon-xs' : 'icon-sm'}
<IconButton
label={buttonTitle}
variant={variant}
size={size === 'xs' ? 'icon-xs' : 'icon-sm'}
shape="pill"
onClick={handleCopy}
className={className}
title={buttonTitle}
aria-label={buttonTitle}
side={side}
>
<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>
</Button>
{liveRegion}
</IconButton>
);
}
@@ -1,5 +1,12 @@
import { Download, FileText, History, RotateCcw } from 'lucide-react';
import {
CircleAlert,
Download,
FileText,
History,
RotateCcw,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
@@ -7,7 +14,7 @@ import { useDarkTheme } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import MarkdownPreview from './MarkdownPreview';
import { renderMermaidDiagram } from './mermaidSecurity';
import { Spinner } from '@/components/ui/spinner';
import { LoadingState } from '@/components/ui/loading-state';
import {
buildPreviewDocument,
bytesPreviewModeForMime,
@@ -21,6 +28,7 @@ import {
type BytesPreviewMode,
type DocumentArtifact,
} from './artifactViewUtils';
import { Alert, AlertDescription } from './ui/alert';
import { Button } from './ui/button';
import { useArtifactBytes } from './useArtifactBytes';
import {
@@ -49,6 +57,7 @@ function FramePreview({
artifactKind: string;
source: string;
}) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const [rendered, setRendered] = useState<string | null>(null);
const [renderError, setRenderError] = useState(false);
@@ -86,17 +95,13 @@ function FramePreview({
if (renderError) {
return (
<pre className="text-muted-foreground overflow-auto p-4 text-xs">
<pre className="text-muted-foreground overflow-auto p-4 font-mono text-xs wrap-break-word whitespace-pre-wrap">
{source}
</pre>
);
}
if (rendered === null) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
return <LoadingState fill="parent" />;
}
// For mermaid, `rendered` is mermaid's sandboxed-iframe wrapper; the 'mermaid'
@@ -106,7 +111,7 @@ function FramePreview({
<iframe
// No allow-same-origin: content is fully isolated from the app origin.
sandbox=""
title="Artifact preview"
title={t('components.artifact.preview')}
className="border-border h-full w-full rounded-md border bg-white"
srcDoc={buildPreviewDocument(previewKind, rendered)}
/>
@@ -124,10 +129,11 @@ function DownloadCard({
onDownload: () => void;
downloading: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<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" />
<FileText className="text-muted-foreground size-12" />
<div>
<p className="text-foreground text-sm font-medium break-all">
{filename}
@@ -143,8 +149,8 @@ function DownloadCard({
onClick={onDownload}
loading={downloading}
>
<Download className="h-4 w-4" />
Download
<Download />
{t('components.artifact.download')}
</Button>
</div>
</div>
@@ -171,14 +177,11 @@ function BytesPreview({
token: string | null;
fallback: ReactNode;
}) {
const { t } = useTranslation();
const state = useArtifactBytes(artifactId, version, mode, token);
if (state.status === 'loading') {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
return <LoadingState fill="parent" />;
}
if (state.status === 'error') {
return <>{fallback}</>;
@@ -188,7 +191,7 @@ function BytesPreview({
<div className="flex h-full items-center justify-center overflow-auto p-4">
<img
src={state.url}
alt="Artifact preview"
alt={t('components.artifact.preview')}
className="max-h-full max-w-full object-contain"
/>
</div>
@@ -200,7 +203,7 @@ function BytesPreview({
<iframe
// No allow-same-origin / no scripts: bytes are isolated from the app origin.
sandbox=""
title="Artifact preview"
title={t('components.artifact.preview')}
className="border-border h-full w-full rounded-md border bg-white"
srcDoc={buildPreviewDocument(
mode === 'iframe-svg' ? 'svg' : 'html',
@@ -213,7 +216,7 @@ function BytesPreview({
return <MarkdownPreview content={state.text} />;
}
return (
<pre className="text-foreground h-full overflow-auto p-4 text-xs whitespace-pre-wrap">
<pre className="text-foreground h-full overflow-auto p-4 font-mono text-xs wrap-break-word whitespace-pre-wrap">
{state.text}
</pre>
);
@@ -226,6 +229,7 @@ export default function DocumentArtifactView({
artifact: DocumentArtifact;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const versionsDesc = useMemo(
() => sortVersionsDesc(artifact.versions),
@@ -275,9 +279,11 @@ export default function DocumentArtifactView({
'url',
);
const ok = await triggerResponseDownload(response, filename);
if (!ok && isMountedRef.current) setActionError('Download failed');
if (!ok && isMountedRef.current)
setActionError(t('components.artifact.downloadFailed'));
} catch {
if (isMountedRef.current) setActionError('Download failed');
if (isMountedRef.current)
setActionError(t('components.artifact.downloadFailed'));
} finally {
if (isMountedRef.current) setDownloading(false);
}
@@ -293,12 +299,14 @@ export default function DocumentArtifactView({
token,
);
if (!response.ok) {
if (isMountedRef.current) setActionError('Restore failed');
if (isMountedRef.current)
setActionError(t('components.artifact.restoreFailed'));
return;
}
if (isMountedRef.current) onRefresh();
} catch {
if (isMountedRef.current) setActionError('Restore failed');
if (isMountedRef.current)
setActionError(t('components.artifact.restoreFailed'));
} finally {
if (isMountedRef.current) setRestoring(false);
}
@@ -334,7 +342,7 @@ export default function DocumentArtifactView({
: (selectedRow?.preview_text ?? '');
if (text) {
return (
<pre className="text-foreground h-full overflow-auto p-4 text-xs whitespace-pre-wrap">
<pre className="text-foreground h-full overflow-auto p-4 font-mono text-xs wrap-break-word whitespace-pre-wrap">
{text}
</pre>
);
@@ -374,14 +382,17 @@ export default function DocumentArtifactView({
onValueChange={(v) => setSelectedVersion(Number(v))}
>
<SelectTrigger size="sm" className="w-auto">
<History className="h-3.5 w-3.5" />
<History className="size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{versionsDesc.map((v) => (
<SelectItem key={v.version} value={String(v.version)}>
Version {v.version}
{v.version === artifact.current_version ? ' (current)' : ''}
{v.version === artifact.current_version
? t('components.artifact.versionCurrent', {
version: v.version,
})
: t('components.artifact.version', { version: v.version })}
</SelectItem>
))}
</SelectContent>
@@ -394,8 +405,8 @@ export default function DocumentArtifactView({
onClick={handleDownload}
loading={downloading}
>
<Download className="h-4 w-4" />
Download
<Download />
{t('components.artifact.download')}
</Button>
{!isCurrent && (
@@ -406,13 +417,18 @@ export default function DocumentArtifactView({
onClick={handleRestore}
loading={restoring}
>
<RotateCcw className="h-4 w-4" />
Restore
<RotateCcw />
{t('components.artifact.restore')}
</Button>
)}
</div>
{actionError && <p className="text-destructive text-xs">{actionError}</p>}
{actionError && (
<Alert variant="destructive">
<CircleAlert className="size-4" aria-hidden="true" />
<AlertDescription>{actionError}</AlertDescription>
</Alert>
)}
<div className="min-h-0 flex-1 overflow-hidden">{renderPreview()}</div>
</div>
@@ -1,132 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
import { Button } from './ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
interface PaginationProps {
currentPage: number;
totalPages: number;
rowsPerPage: number;
onPageChange: (page: number) => void;
onRowsPerPageChange: (rows: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
rowsPerPage,
onPageChange,
onRowsPerPageChange,
}) => {
const { t } = useTranslation();
const rowsPerPageOptions = [5, 10, 20, 50];
const handlePreviousPage = () => {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
};
const handleFirstPage = () => {
onPageChange(1);
};
const handleLastPage = () => {
onPageChange(totalPages);
};
const handleSelectRowsPerPage = (rows: number) => {
onRowsPerPageChange(rows);
};
return (
<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-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) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Pagination controls */}
<div className="text-foreground">
{t('pagination.pageOf', { currentPage, totalPages })}
</div>
<div className="text-foreground flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={handleFirstPage}
disabled={currentPage === 1}
aria-label={t('pagination.firstPage')}
>
<ChevronsLeft />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={handlePreviousPage}
disabled={currentPage === 1}
aria-label={t('pagination.previousPage')}
>
<ChevronLeft />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={handleNextPage}
disabled={currentPage === totalPages}
aria-label={t('pagination.nextPage')}
>
<ChevronRight />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={handleLastPage}
disabled={currentPage === totalPages}
aria-label={t('pagination.lastPage')}
>
<ChevronsRight />
</Button>
</div>
</div>
);
};
export default Pagination;
+3 -3
View File
@@ -57,7 +57,7 @@ describe('FilePicker', () => {
);
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Session expired');
expect(alert?.textContent).toContain('filePicker.sessionExpiredFor');
expect(alert?.querySelector('svg')).not.toBeNull();
});
@@ -108,8 +108,8 @@ describe('FilePicker', () => {
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(page?.textContent).toBe('filePicker.myFiles');
expect(page?.getAttribute('title')).toBe('filePicker.myFiles');
expect(trail?.querySelector('button[disabled]')).toBeNull();
});
});
+54 -53
View File
@@ -1,10 +1,4 @@
import {
Check,
File,
Folder,
Search as SearchIcon,
TriangleAlert,
} from 'lucide-react';
import { File, Folder, TriangleAlert } from 'lucide-react';
import React, {
Fragment,
useState,
@@ -22,6 +16,7 @@ import {
removeSessionToken,
} from '../utils/providerUtils';
import ConnectorAuth from '../components/ConnectorAuth';
import SearchInput from './SearchInput';
import { Alert, AlertDescription } from './ui/alert';
import {
Breadcrumb,
@@ -31,7 +26,8 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from './ui/breadcrumb';
import { Input } from './ui/input';
import { Checkbox } from './ui/checkbox';
import { Skeleton } from './ui/skeleton';
import {
Table,
TableContainer,
@@ -92,31 +88,31 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
token,
initialSelectedFiles = [],
}) => {
const { t } = useTranslation();
const PROVIDER_CONFIG = {
google_drive: {
displayName: 'Drive',
rootName: 'My Drive',
rootName: t('filePicker.myDrive'),
},
share_point: {
displayName: 'SharePoint',
rootName: 'My Files',
rootName: t('filePicker.myFiles'),
},
confluence: {
displayName: 'Confluence',
rootName: 'Spaces',
rootName: t('filePicker.spaces'),
},
} as const;
};
const getProviderConfig = (provider: string) => {
return (
PROVIDER_CONFIG[provider as keyof typeof PROVIDER_CONFIG] || {
displayName: provider,
rootName: 'Root',
rootName: t('filePicker.root'),
}
);
};
const { t } = useTranslation();
const [files, setFiles] = useState<CloudFile[]>([]);
const [selectedFiles, setSelectedFiles] =
useState<string[]>(initialSelectedFiles);
@@ -231,14 +227,19 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
removeSessionToken(provider);
setIsConnected(false);
setAuthError(
`Session expired. Please reconnect to ${getProviderConfig(provider).displayName}.`,
t('filePicker.sessionExpiredFor', {
provider: getProviderConfig(provider).displayName,
}),
);
return;
}
const validateData = await validateResponse.json();
if (validateData.success) {
setUserEmail(validateData.user_email || 'Connected User');
setUserEmail(
validateData.user_email ||
t('modals.uploadDoc.connectors.auth.connectedUser'),
);
setIsConnected(true);
setAuthError('');
if (provider === 'share_point') {
@@ -262,12 +263,12 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
setIsConnected(false);
setAuthError(
validateData.error ||
'Session expired. Please reconnect your account.',
t('modals.uploadDoc.connectors.googleDrive.sessionExpiredGeneric'),
);
}
} catch (error) {
console.error('Error validating session:', error);
setAuthError('Failed to validate session. Please reconnect.');
setAuthError(t('modals.uploadDoc.connectors.googleDrive.validateFailed'));
setIsConnected(false);
}
}, [provider, token, loadCloudFiles]);
@@ -391,7 +392,9 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
{
id: null,
name:
tab === 'shared' ? 'Shared' : getProviderConfig(provider).rootName,
tab === 'shared'
? t('filePicker.shared')
: getProviderConfig(provider).rootName,
},
]);
const sessionToken = getSessionToken(provider);
@@ -427,9 +430,14 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
<ConnectorAuth
provider={provider}
label={`Connect to ${getProviderConfig(provider).displayName}`}
label={t('filePicker.connectTo', {
provider: getProviderConfig(provider).displayName,
})}
onSuccess={(data) => {
setUserEmail(data.user_email || 'Connected User');
setUserEmail(
data.user_email ||
t('modals.uploadDoc.connectors.auth.connectedUser'),
);
setIsConnected(true);
setAuthError('');
@@ -523,19 +531,17 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
</Breadcrumb>
<div className="text-muted-foreground mb-3 text-sm">
Select Files from {getProviderConfig(provider).displayName}
{t('filePicker.selectFilesFrom', {
provider: getProviderConfig(provider).displayName,
})}
</div>
<div className="mb-3 max-w-md">
<Input
type="text"
<SearchInput
label={t('filePicker.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
labelSurface="muted"
leftIcon={
<SearchIcon className="text-muted-foreground size-4" />
}
/>
</div>
@@ -576,16 +582,16 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={`skeleton-${i}`}>
<TableCell width="40px" align="center">
<div className="bg-muted mx-auto h-5 w-5 animate-pulse rounded" />
<Skeleton className="mx-auto size-5" />
</TableCell>
<TableCell>
<div className="bg-muted h-4 w-48 animate-pulse rounded" />
<Skeleton className="h-4 w-48" />
</TableCell>
<TableCell>
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<div className="bg-muted h-4 w-16 animate-pulse rounded" />
<Skeleton className="h-4 w-16" />
</TableCell>
</TableRow>
))
@@ -601,27 +607,22 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
}}
>
<TableCell width="40px" align="center">
<div
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();
<Checkbox
size="sm"
className="align-middle"
aria-label={file.name}
checked={(isFolder(file)
? selectedFolders
: selectedFiles
).includes(file.id)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={() =>
handleFileSelect(
file.id,
isFolder(file),
);
}}
>
{(isFolder(file)
? selectedFolders
: selectedFiles
).includes(file.id) && (
<Check
role="img"
aria-label="Selected"
className="text-success size-4"
/>
)}
</div>
)
}
/>
</TableCell>
<TableCell>
<div className="flex min-w-0 items-center gap-3">
@@ -650,16 +651,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="bg-muted mx-auto h-5 w-5 animate-pulse rounded" />
<Skeleton className="mx-auto size-5" />
</TableCell>
<TableCell>
<div className="bg-muted h-4 w-48 animate-pulse rounded" />
<Skeleton className="h-4 w-48" />
</TableCell>
<TableCell>
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<div className="bg-muted h-4 w-16 animate-pulse rounded" />
<Skeleton className="h-4 w-16" />
</TableCell>
</TableRow>
))}
+4 -7
View File
@@ -7,6 +7,7 @@ import { SOURCE_FILE_TREE_ACCEPT_ATTR } from '../constants/fileUpload';
import ConfirmationModal from '../modals/ConfirmationModal';
import { selectToken } from '../preferences/preferenceSlice';
import TreeBrowser from './tree/TreeBrowser';
import { Button } from './ui/button';
import type { MenuOption } from './ui/dropdown-menu';
import type { RowMenuContext, TreeBrowserController } from './tree/types';
import { useReingestSseWaiter } from './tree/useReingestWait';
@@ -232,13 +233,9 @@ const FileTree: React.FC<FileTreeProps> = ({
<>
{headerAction}
{!isProcessing ? (
<button
onClick={handleAddFile}
className="bg-primary hover:bg-primary/90 flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-sm font-medium whitespace-nowrap text-white"
title={t('settings.sources.addFile')}
>
<Button type="button" size="field" shape="pill" onClick={handleAddFile}>
{t('settings.sources.addFile')}
</button>
</Button>
) : null}
</>
);
@@ -257,7 +254,7 @@ const FileTree: React.FC<FileTreeProps> = ({
handleSubmit={handleConfirmedDelete}
handleCancel={handleCancelDelete}
submitLabel={t('convTile.delete')}
variant="danger"
variant="destructive"
/>
);
+2 -2
View File
@@ -146,7 +146,7 @@ export const FileUpload = ({
>
<img
src={preview ?? undefined}
alt="preview"
alt={t('components.fileUpload.preview')}
className="h-full w-full rounded-md object-cover"
/>
<button
@@ -156,7 +156,7 @@ export const FileUpload = ({
handleRemove();
}}
className="bg-primary hover:bg-primary/90 absolute -top-2 -right-2 rounded-full p-1 transition-colors"
aria-label="remove"
aria-label={t('components.fileUpload.remove')}
>
<X className="text-primary-foreground size-3" />
</button>
+28 -17
View File
@@ -19,6 +19,7 @@ import {
} from '../utils/providerUtils';
import SkeletonLoader from './SkeletonLoader';
import { Button } from './ui/button';
import { SectionHeader } from './ui/section-header';
interface PickerFile {
id: string;
@@ -267,9 +268,13 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
<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">
{t('modals.uploadDoc.connectors.googleDrive.selectedFiles')}
</h3>
<SectionHeader
as="h3"
size="xs"
title={t(
'modals.uploadDoc.connectors.googleDrive.selectedFiles',
)}
/>
<Button
type="button"
size="sm"
@@ -289,21 +294,25 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
) : (
<div className="max-h-60 overflow-y-auto">
{selectedFolders.length > 0 && (
<div className="mb-2">
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
{t('modals.uploadDoc.connectors.googleDrive.folders')}
</h4>
<div className="mb-2 flex flex-col gap-1">
<SectionHeader
as="h4"
size="xs"
title={t(
'modals.uploadDoc.connectors.googleDrive.folders',
)}
/>
{selectedFolders.map((folder) => (
<div
key={folder.id}
className="border-border flex items-center border-b p-2"
className="border-border flex items-center gap-2 border-b p-2"
>
<img
src={folder.iconUrl}
alt={t(
'modals.uploadDoc.connectors.googleDrive.folderAlt',
)}
className="mr-2 h-5 w-5"
className="size-5"
/>
<span className="flex-1 truncate text-sm">
{folder.name}
@@ -323,7 +332,6 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
newSelectedFolders.map((f) => f.id),
);
}}
className="ml-2"
>
{t(
'modals.uploadDoc.connectors.googleDrive.remove',
@@ -335,21 +343,25 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
)}
{selectedFiles.length > 0 && (
<div>
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
{t('modals.uploadDoc.connectors.googleDrive.files')}
</h4>
<div className="flex flex-col gap-1">
<SectionHeader
as="h4"
size="xs"
title={t(
'modals.uploadDoc.connectors.googleDrive.files',
)}
/>
{selectedFiles.map((file) => (
<div
key={file.id}
className="border-border flex items-center border-b p-2"
className="border-border flex items-center gap-2 border-b p-2"
>
<img
src={file.iconUrl}
alt={t(
'modals.uploadDoc.connectors.googleDrive.fileAlt',
)}
className="mr-2 h-5 w-5"
className="size-5"
/>
<span className="flex-1 truncate text-sm">
{file.name}
@@ -368,7 +380,6 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
selectedFolders.map((f) => f.id),
);
}}
className="ml-2"
>
{t(
'modals.uploadDoc.connectors.googleDrive.remove',
+71 -47
View File
@@ -7,7 +7,10 @@ import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d';
import userService from '../api/services/userService';
import { selectToken } from '../preferences/preferenceSlice';
import { Button } from './ui/button';
import { useThemeVersion } from '../utils/chartUtils';
import { Badge } from './ui/badge';
import { IconButton } from './ui/icon-button';
import { SectionHeader } from './ui/section-header';
import SkeletonLoader from './SkeletonLoader';
import {
ForceGraphData,
@@ -18,6 +21,7 @@ import {
maxDegree,
nodeAtPoint,
nodeRadius,
readGraphPalette,
toForceGraphData,
} from './graphViewUtils';
@@ -52,6 +56,11 @@ const GraphView: React.FC<GraphViewProps> = ({
const hoveredNodeIdRef = useRef<string | null>(null);
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
const [size, setSize] = useState({ width: 0, height: 480 });
// The canvas can't read CSS variables: resolve the tokens, and re-read them
// whenever the theme changes.
const themeVersion = useThemeVersion();
// themeVersion is not used inside the factory: it only signals a change.
const palette = useMemo(() => readGraphPalette(), [themeVersion]);
useEffect(() => {
let cancelled = false;
@@ -128,6 +137,11 @@ const GraphView: React.FC<GraphViewProps> = ({
if (fg) fg.zoom(fg.zoom());
};
// A settled simulation stops drawing, so paint the new colours once.
useEffect(() => {
repaint();
}, [palette]);
const handlePointerMove = (event: React.MouseEvent<HTMLDivElement>) => {
const node = pickNodeAt(event.clientX, event.clientY);
if (containerRef.current) {
@@ -158,25 +172,24 @@ const GraphView: React.FC<GraphViewProps> = ({
return (
<div className="flex flex-col">
<div className="mb-4 flex items-center">
<Button
type="button"
<IconButton
variant="outline"
size="icon-xs"
shape="pill"
className="mr-3"
onClick={onBackToDocuments}
aria-label={t('settings.sources.backToAll')}
>
<ArrowLeft />
</Button>
label={t('settings.sources.backToAll')}
icon={ArrowLeft}
side="bottom"
/>
<span className="text-primary font-semibold wrap-break-word">
{sourceName}
</span>
{headerAction ? <div className="ml-auto">{headerAction}</div> : null}
</div>
<div className="bg-muted/60 text-muted-foreground dark:bg-accent/40 mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
<Network size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
<div className="bg-muted text-muted-foreground mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
<Network className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>
<span className="text-foreground font-medium">
{t('settings.sources.graphrag.view.title')}
@@ -189,7 +202,7 @@ const GraphView: React.FC<GraphViewProps> = ({
<SkeletonLoader count={4} />
) : isEmpty ? (
<div className="border-border text-muted-foreground flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center text-sm">
<Network size={28} aria-hidden="true" />
<Network className="size-7" aria-hidden="true" />
<p>{t('settings.sources.graphrag.view.empty')}</p>
</div>
) : (
@@ -220,7 +233,7 @@ const GraphView: React.FC<GraphViewProps> = ({
nodeVal={(node) =>
nodeRadius((node as GraphNode).degree, maxNodeDegree)
}
linkColor={() => 'rgba(150,150,150,0.35)'}
linkColor={() => palette.link}
cooldownTicks={80}
nodeCanvasObject={(node, ctx, globalScale) => {
const graphNode = node as GraphNode & {
@@ -232,8 +245,13 @@ const GraphView: React.FC<GraphViewProps> = ({
const hovered = hoveredNodeIdRef.current === graphNode.id;
ctx.beginPath();
ctx.arc(graphNode.x, graphNode.y ?? 0, r, 0, 2 * Math.PI);
ctx.fillStyle = hovered ? '#7aa7d9' : '#4a7fb5';
ctx.fillStyle = palette.node;
ctx.fill();
if (hovered) {
ctx.lineWidth = 2 / globalScale;
ctx.strokeStyle = palette.hoverStroke;
ctx.stroke();
}
if (globalScale >= 1.2) {
const label = graphNode.name ?? '';
const x = graphNode.x;
@@ -243,11 +261,9 @@ const GraphView: React.FC<GraphViewProps> = ({
ctx.textBaseline = 'top';
ctx.lineWidth = 3 / globalScale;
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(10,10,10,0.85)';
ctx.strokeStyle = palette.halo;
ctx.strokeText(label, x, y);
ctx.fillStyle = hovered
? 'rgba(255,255,255,0.98)'
: 'rgba(210,210,210,0.95)';
ctx.fillStyle = palette.label;
ctx.fillText(label, x, y);
}
}}
@@ -262,23 +278,27 @@ const GraphView: React.FC<GraphViewProps> = ({
<div className="flex flex-col">
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-foreground text-sm font-semibold wrap-break-word">
{selectedNode.name}
</h3>
<SectionHeader
as="h3"
size="xs"
className="wrap-break-word"
title={selectedNode.name}
/>
{selectedNode.type && (
<span className="bg-muted-foreground/10 text-muted-foreground mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium">
<Badge variant="neutral" className="mt-1">
{selectedNode.type}
</span>
</Badge>
)}
</div>
<button
type="button"
<IconButton
variant="ghost-muted"
size="icon-xs"
className="shrink-0"
onClick={() => setSelectedNode(null)}
aria-label={t('settings.sources.graphrag.view.close')}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X size={16} aria-hidden="true" />
</button>
label={t('settings.sources.graphrag.view.close')}
icon={X}
side="bottom"
/>
</div>
{selectedNode.description && (
@@ -287,25 +307,29 @@ const GraphView: React.FC<GraphViewProps> = ({
</p>
)}
<h4 className="text-foreground mb-2 text-xs font-semibold">
{t('settings.sources.graphrag.view.linkedChunks')}
</h4>
{selectedNode.chunks.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.noChunks')}
</p>
) : (
<ul className="flex flex-col gap-2">
{selectedNode.chunks.map((chunk) => (
<li
key={chunk.chunk_id}
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
>
{chunk.text}
</li>
))}
</ul>
)}
<div className="flex flex-col gap-2">
<SectionHeader
as="h4"
size="xs"
title={t('settings.sources.graphrag.view.linkedChunks')}
/>
{selectedNode.chunks.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.noChunks')}
</p>
) : (
<ul className="flex flex-col gap-2">
{selectedNode.chunks.map((chunk) => (
<li
key={chunk.chunk_id}
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
>
{chunk.text}
</li>
))}
</ul>
)}
</div>
</div>
) : (
<p className="text-muted-foreground py-2 text-sm">
+45 -81
View File
@@ -1,96 +1,60 @@
import { useState, useRef, useEffect } from 'react';
import { Info, Mail, ShieldCheck } from 'lucide-react';
import { FileText, Info, Mail, ShieldCheck } from 'lucide-react';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import PageIcon from '../assets/documentation.svg';
import { useTranslation } from 'react-i18next';
import { selectIsAdmin } from '../preferences/preferenceSlice';
import { Button } from './ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
const Help = () => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const { t } = useTranslation();
const isAdmin = useSelector(selectIsAdmin);
const toggleDropdown = () => {
setIsOpen((prev) => !prev);
};
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="relative inline-block text-sm" ref={dropdownRef}>
<Button
type="button"
variant="sidebar-item"
ref={buttonRef}
onClick={toggleDropdown}
className="mx-4 my-auto w-full"
>
<Info className="text-muted-foreground size-5 shrink-0" />
{t('help')}
</Button>
{isOpen && (
<div
className={`dark:bg-card bg-card absolute z-10 w-48 translate-x-4 ${
isAdmin ? '-translate-y-40' : '-translate-y-28'
} rounded-xl shadow-lg`}
>
<div className="inline-block text-sm">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="sidebar-item"
className="mx-4 my-auto w-full"
>
<Info className="text-muted-foreground size-5 shrink-0" />
{t('help')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-48">
{isAdmin && (
<Link
to="/admin"
onClick={() => setIsOpen(false)}
className="hover:bg-muted text-foreground flex items-center gap-4 rounded-t-xl px-4 py-2"
>
<ShieldCheck
size={20}
className="text-muted-foreground shrink-0"
/>
{t('admin.label', 'Admin')}
</Link>
<DropdownMenuItem asChild>
<Link to="/admin">
<ShieldCheck />
{t('admin.label', 'Admin')}
</Link>
</DropdownMenuItem>
)}
<a
href="https://docs.docsgpt.cloud/"
target="_blank"
rel="noopener noreferrer"
className={`hover:bg-muted text-foreground flex items-start gap-4 px-4 py-2 ${
isAdmin ? '' : 'rounded-t-xl'
}`}
>
<img
src={PageIcon}
alt="Documentation"
className="filter dark:invert"
width={20}
/>
{t('documentation')}
</a>
<a
href="mailto:support@docsgpt.cloud"
className="hover:bg-muted text-foreground flex items-start gap-4 rounded-b-xl px-4 py-2"
>
<Mail className="size-5 shrink-0" />
{t('emailUs')}
</a>
</div>
)}
<DropdownMenuItem asChild>
<a
href="https://docs.docsgpt.cloud/"
target="_blank"
rel="noopener noreferrer"
>
<FileText />
{t('documentation')}
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="mailto:support@docsgpt.cloud">
<Mail />
{t('emailUs')}
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
+10 -17
View File
@@ -1,6 +1,10 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { markdownHeadings } from '@/lib/markdown';
import { Button } from './ui/button';
/**
* Minimal GFM markdown renderer for artifact text previews. ReactMarkdown
* parses to React elements (no `dangerouslySetInnerHTML`), so untrusted
@@ -13,16 +17,14 @@ export default function MarkdownPreview({ content }: { content: string }) {
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
...markdownHeadings,
a({ children, href }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{children}
</a>
<Button variant="link" size="inline" asChild>
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
</Button>
);
},
ul({ children }) {
@@ -39,15 +41,6 @@ export default function MarkdownPreview({ content }: { content: string }) {
</ol>
);
},
h1({ children }) {
return <h1 className="text-xl font-bold">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-lg font-bold">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-base font-bold">{children}</h3>;
},
code({ children, ...rest }) {
return (
<code
@@ -6,7 +6,10 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const renderMermaidDiagramMock = vi.hoisted(() => vi.fn());
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, opts?: { format?: string }) =>
opts?.format ? `${key} ${opts.format}` : key,
}),
}));
vi.mock('react-redux', () => ({ useSelector: () => 'idle' }));
vi.mock('../hooks', () => ({ useDarkTheme: () => [false] }));
@@ -64,9 +67,11 @@ describe('MermaidRenderer', () => {
});
const trigger = container.querySelector<HTMLButtonElement>(
'button[title="mermaid.downloadOptions"]',
'button[aria-haspopup="menu"]',
);
expect(trigger).not.toBeNull();
expect(trigger?.textContent).toContain('mermaid.download');
expect(trigger?.hasAttribute('title')).toBe(false);
expect(trigger?.getAttribute('aria-haspopup')).toBe('menu');
expect(document.querySelector('[role="menu"]')).toBeNull();
@@ -81,9 +86,9 @@ describe('MermaidRenderer', () => {
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).map((item) => item.textContent);
expect(items).toEqual([
'Download as SVG',
'Download as PNG',
'Download as MMD',
'mermaid.downloadAs SVG',
'mermaid.downloadAs PNG',
'mermaid.downloadAs MMD',
]);
});
});
+36 -33
View File
@@ -14,6 +14,7 @@ import CopyButton from './CopyButton';
import { renderMermaidDiagram } from './mermaidSecurity';
import { Alert, AlertDescription } from './ui/alert';
import { Button } from './ui/button';
import { IconButton } from './ui/icon-button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -120,7 +121,9 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
if (cancelled) return;
console.error('Error rendering mermaid diagram:', err);
setError(
`Failed to render diagram: ${err instanceof Error ? err.message : String(err)}`,
t('mermaid.renderFailed', {
message: err instanceof Error ? err.message : String(err),
}),
);
}
}
@@ -255,9 +258,9 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
};
const downloadOptions = [
{ label: 'Download as SVG', action: downloadSvg },
{ label: 'Download as PNG', action: downloadPng },
{ label: 'Download as MMD', action: downloadMmd },
{ label: t('mermaid.downloadAs', { format: 'SVG' }), action: downloadSvg },
{ label: t('mermaid.downloadAs', { format: 'PNG' }), action: downloadPng },
{ label: t('mermaid.downloadAs', { format: 'MMD' }), action: downloadMmd },
];
const showDiagramOptions = !isCurrentlyLoading && !error;
@@ -268,18 +271,16 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
<div className="bg-muted flex items-center justify-between px-2 py-1">
<span className="text-foreground text-xs font-medium">mermaid</span>
<div className="flex items-center gap-2">
<CopyButton textToCopy={String(code).replace(/\n$/, '')} />
<CopyButton
textToCopy={String(code).replace(/\n$/, '')}
side="bottom"
/>
{showDiagramOptions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost-muted"
size="xs"
title={t('mermaid.downloadOptions')}
>
Download <span className="ml-1">▼</span>
<Button type="button" variant="ghost-muted" size="xs">
{t('mermaid.download')} <span className="ml-1">▼</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
@@ -301,9 +302,8 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
variant={showCode ? 'secondary' : 'ghost-muted'}
size="xs"
onClick={() => setShowCode(!showCode)}
title={t('mermaid.viewCode')}
>
Code
{t('mermaid.code')}
</Button>
)}
</div>
@@ -312,7 +312,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
{isCurrentlyLoading ? (
<div className="bg-card flex items-center justify-center p-4">
<div className="text-muted-foreground text-sm">
Loading diagram...
{t('mermaid.loading')}
</div>
</div>
) : errorRender ? (
@@ -343,43 +343,46 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
{isHovering && (
<>
<div className="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 px-2 py-1 text-xs text-white">
<Button
type="button"
<IconButton
label={t('mermaid.decreaseZoom')}
side="bottom"
variant="ghost"
size="icon-xs"
onClick={() =>
setZoomFactor((prev) => Math.max(1, prev - 0.5))
}
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"
className="hover:bg-white/20 hover:text-white"
>
-
</Button>
<span
className="cursor-pointer hover:underline"
onClick={() => {
setZoomFactor(2);
}}
title={t('mermaid.resetZoom')}
>
{zoomFactor.toFixed(1)}x
</span>
</IconButton>
<Button
type="button"
variant="link"
size="inline"
onClick={() => setZoomFactor(2)}
title={t('mermaid.resetZoom')}
/* eslint-disable-next-line shadcn/no-restyle --
on the bg-black/70 zoom overlay, like its − / + siblings: keeps the overlay's white 12px regular */
className="text-xs font-normal text-current"
>
{zoomFactor.toFixed(1)}x
</Button>
<IconButton
label={t('mermaid.increaseZoom')}
side="bottom"
variant="ghost"
size="icon-xs"
onClick={() =>
setZoomFactor((prev) => Math.min(6, prev + 0.5))
}
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"
className="hover:bg-white/20 hover:text-white"
>
+
</Button>
</IconButton>
</div>
</>
)}
@@ -404,7 +407,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
<div className="border-border border-t">
<div className="bg-muted p-2">
<span className="text-foreground text-xs font-medium">
Mermaid Code
{t('mermaid.codeTitle')}
</span>
</div>
<SyntaxHighlighter
+52 -43
View File
@@ -11,6 +11,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { useDropzone } from 'react-dropzone';
import i18n from 'i18next';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector, useStore } from 'react-redux';
@@ -38,6 +39,7 @@ import type { RootState } from '../store';
import Upload from '../upload/Upload';
import { isTouchDevice } from '../utils/browserUtils';
import { Button } from './ui/button';
import { IconButton } from './ui/icon-button';
import { type MultiSelectPopoverItem } from './MultiSelectPopover';
import ToolIcon from './ToolIcon';
import {
@@ -125,19 +127,19 @@ const getLegacyGetUserMedia = () => {
const getVoiceInputSupportError = (): string | null => {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return 'Voice input is unavailable right now.';
return i18n.t('conversation.voice.unavailable');
}
if (!window.isSecureContext) {
return 'Voice input requires a secure connection (HTTPS or localhost).';
return i18n.t('conversation.voice.insecure');
}
if (!navigator.mediaDevices?.getUserMedia && !getLegacyGetUserMedia()) {
return 'Voice input is not available in this browser.';
return i18n.t('conversation.voice.unsupported');
}
if (!getAudioContextConstructor()) {
return 'Voice input requires Web Audio support in this browser.';
return i18n.t('conversation.voice.noWebAudio');
}
return null;
@@ -152,9 +154,7 @@ const getUserMediaStream = (
const legacyGetUserMedia = getLegacyGetUserMedia();
if (!legacyGetUserMedia) {
return Promise.reject(
new Error('Voice input is not available in this browser.'),
);
return Promise.reject(new Error(i18n.t('conversation.voice.unsupported')));
}
return new Promise((resolve, reject) => {
@@ -164,7 +164,7 @@ const getUserMediaStream = (
const getVoiceInputErrorMessage = (error: unknown): string => {
if (typeof window !== 'undefined' && !window.isSecureContext) {
return 'Voice input requires a secure connection (HTTPS or localhost).';
return i18n.t('conversation.voice.insecure');
}
if (error instanceof DOMException) {
@@ -172,15 +172,15 @@ const getVoiceInputErrorMessage = (error: unknown): string => {
case 'NotAllowedError':
case 'PermissionDeniedError':
case 'SecurityError':
return 'Microphone access was blocked. Allow microphone permission and try again.';
return i18n.t('conversation.voice.micBlocked');
case 'NotFoundError':
case 'DevicesNotFoundError':
return 'No microphone was found on this device.';
return i18n.t('conversation.voice.micNotFound');
case 'NotReadableError':
case 'TrackStartError':
return 'The microphone is unavailable or already in use.';
return i18n.t('conversation.voice.micBusy');
case 'AbortError':
return 'Microphone access was interrupted before recording started.';
return i18n.t('conversation.voice.micInterrupted');
default:
break;
}
@@ -190,7 +190,7 @@ const getVoiceInputErrorMessage = (error: unknown): string => {
return error.message;
}
return 'Microphone access was denied.';
return i18n.t('conversation.voice.micDenied');
};
const downsampleFloat32Buffer = (
@@ -1060,7 +1060,9 @@ export default function MessageInput({
const data = await response.json();
if (!response.ok || !data?.success) {
throw new Error(data?.message || 'Failed to transcribe audio.');
throw new Error(
data?.message || t('conversation.voice.transcribeFailed'),
);
}
if (typeof data.text !== 'string' || !data.text.trim()) {
@@ -1078,7 +1080,9 @@ export default function MessageInput({
console.error('Uploaded audio transcription failed', error);
setRecordingState('error');
setVoiceError(
error instanceof Error ? error.message : 'Failed to transcribe audio.',
error instanceof Error
? error.message
: t('conversation.voice.transcribeFailed'),
);
}
};
@@ -1155,7 +1159,7 @@ export default function MessageInput({
if (!response.ok || !data?.success) {
throw new Error(
data?.message || 'Failed to finalize live transcription.',
data?.message || t('conversation.voice.finalizeFailed'),
);
}
@@ -1175,7 +1179,7 @@ export default function MessageInput({
setVoiceError(
error instanceof Error
? error.message
: 'Failed to finalize live transcription.',
: t('conversation.voice.finalizeFailed'),
);
} finally {
resetLiveTranscriptionState();
@@ -1227,7 +1231,9 @@ export default function MessageInput({
const data = await response.json();
if (!response.ok || !data?.success) {
throw new Error(data?.message || 'Failed to transcribe audio.');
throw new Error(
data?.message || t('conversation.voice.transcribeFailed'),
);
}
if (typeof data.transcript_text === 'string') {
@@ -1235,7 +1241,9 @@ export default function MessageInput({
}
} catch (error) {
await failLiveTranscription(
error instanceof Error ? error.message : 'Failed to transcribe audio.',
error instanceof Error
? error.message
: t('conversation.voice.transcribeFailed'),
);
return;
} finally {
@@ -1308,7 +1316,7 @@ export default function MessageInput({
const AudioContextConstructor = getAudioContextConstructor();
if (!AudioContextConstructor) {
setRecordingState('error');
setVoiceError('Voice input requires Web Audio support in this browser.');
setVoiceError(t('conversation.voice.noWebAudio'));
return;
}
@@ -1326,7 +1334,7 @@ export default function MessageInput({
const liveStartData = await liveStartResponse.json();
if (!liveStartResponse.ok || !liveStartData?.success) {
throw new Error(
liveStartData?.message || 'Failed to start live transcription.',
liveStartData?.message || t('conversation.voice.startFailed'),
);
}
@@ -1410,7 +1418,7 @@ export default function MessageInput({
setVoiceError(
error instanceof Error
? error.message
: 'Failed to start live transcription.',
: t('conversation.voice.startFailed'),
);
}
};
@@ -1541,7 +1549,7 @@ export default function MessageInput({
const toolItems: MultiSelectPopoverItem[] = userTools.map((tool) => ({
id: tool.id,
label: tool.customName || tool.displayName,
icon: <ToolIcon name={tool.name} className="h-5 w-5" />,
icon: <ToolIcon name={tool.name} className="size-5" />,
}));
const selectedToolIds = userTools
@@ -1678,7 +1686,7 @@ export default function MessageInput({
controls (dead Attach button, see AttachFileButton). */}
<div
translate="no"
className="border-border bg-card relative flex w-full flex-col rounded-3xl border dark:bg-transparent"
className="border-border bg-card relative flex w-full flex-col rounded-3xl border"
>
<AttachmentChipList
attachments={attachments}
@@ -1701,13 +1709,17 @@ export default function MessageInput({
count: sendReadiness.pendingCount,
})}
</span>
<button
<Button
type="button"
variant="link"
size="inline"
onClick={cancelArmedSend}
className="underline hover:opacity-80"
/* eslint-disable-next-line shadcn/no-restyle --
The queued-send Cancel sits inline in the composer's 12px status line; link inline keeps the base text-sm, so it takes the line's size. */
className="text-xs"
>
{t('conversation.attachments.cancelQueuedSend')}
</button>
</Button>
</div>
)}
{sendArmed && sendReadiness.state === 'blocked' && (
@@ -1742,7 +1754,7 @@ export default function MessageInput({
}
tabIndex={1}
placeholder={t('inputPlaceholder')}
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"
className="text-foreground placeholder:text-muted-foreground w-full resize-none 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')}
@@ -1787,28 +1799,26 @@ export default function MessageInput({
</div>
{loading ? (
<Button
type="button"
<IconButton
label={t('cancel')}
variant="default"
size="icon"
shape="pill"
onClick={handleCancel}
aria-label={t('cancel')}
className="ml-auto h-7 w-7 shrink-0 sm:h-9 sm:w-9"
className="ml-auto size-7 shrink-0 sm:size-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" />
</Button>
<div className="flex size-3 items-center justify-center rounded-sm bg-white sm:size-3.5" />
</IconButton>
) : (
<Button
type="button"
<IconButton
label={t('conversation.send')}
variant="default"
size="icon"
shape="pill"
onClick={handleSubmit}
aria-label={t('send')}
className={cn(
'ml-auto h-7 w-7 shrink-0 sm:h-9 sm:w-9',
'ml-auto size-7 shrink-0 sm:size-9',
!canSubmit &&
/* eslint-disable-next-line shadcn/no-restyle --
The empty composer's send button is a grey circle, not a
@@ -1819,11 +1829,10 @@ export default function MessageInput({
disabled={!canSubmit}
>
<SendArrow
className="mx-auto my-auto block h-3.5 w-3.5 sm:h-4 sm:w-4"
aria-label={t('send')}
role="img"
className="mx-auto my-auto block size-3.5 sm:size-4"
aria-hidden="true"
/>
</Button>
</IconButton>
)}
</div>
</div>
@@ -1840,7 +1849,7 @@ export default function MessageInput({
{handleDragActive &&
createPortal(
<div className="dark:bg-background/85 pointer-events-none fixed top-0 left-0 z-50 flex size-full flex-col items-center justify-center bg-white/85">
<div className="bg-background/85 pointer-events-none fixed top-0 left-0 z-50 flex size-full flex-col items-center justify-center">
<CloudUpload className="size-18" />
<span className="text-muted-foreground px-2 text-2xl font-bold">
{t('modals.uploadDoc.drag.title')}
@@ -1,7 +1,7 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const media = { isMobile: false, isTablet: false, isDesktop: true };
const media = { isMobile: false, isDesktop: true };
vi.mock('../hooks', () => ({
useMediaQuery: () => media,
useDarkTheme: () => [false, () => undefined],
+16 -29
View File
@@ -2,11 +2,10 @@ import { Check } from 'lucide-react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import { useDarkTheme, useMediaQuery } from '../hooks';
import { useMediaQuery } from '../hooks';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { EmptyState } from '@/components/ui/empty-state';
import { LoadingState } from '@/components/ui/loading-state';
import {
Command,
CommandEmpty,
@@ -16,6 +15,7 @@ import {
CommandList,
} from './ui/command';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { SectionHeader } from './ui/section-header';
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from './ui/sheet';
export type MultiSelectPopoverItem = {
@@ -53,16 +53,11 @@ function renderIcon(icon: MultiSelectPopoverItem['icon']) {
if (!icon) return null;
if (typeof icon === 'string') {
return (
<img
src={icon}
alt=""
aria-hidden="true"
className="mr-3 h-5 w-5 shrink-0"
/>
<img src={icon} alt="" aria-hidden="true" className="size-5 shrink-0" />
);
}
return (
<span className="mr-3 flex h-5 w-5 shrink-0 items-center justify-center">
<span className="flex size-5 shrink-0 items-center justify-center">
{icon}
</span>
);
@@ -87,7 +82,6 @@ export function MultiSelectPopover({
title,
}: MultiSelectPopoverProps) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const { isMobile } = useMediaQuery();
const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
@@ -133,7 +127,7 @@ export function MultiSelectPopover({
className="cursor-pointer justify-between"
aria-selected={isSelected}
>
<div className="mr-3 flex grow items-center overflow-hidden">
<div className="mr-3 flex grow items-center gap-3 overflow-hidden">
{renderIcon(item.icon)}
<div className="overflow-hidden">
<p
@@ -152,7 +146,7 @@ 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"
className="border-border bg-card flex size-4 shrink-0 items-center justify-center rounded-xs border-2"
aria-hidden="true"
>
{isSelected && (
@@ -164,24 +158,19 @@ export function MultiSelectPopover({
};
const renderEmptyState = () => (
<div className="flex flex-col items-center justify-center px-4 py-8 text-center">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt=""
aria-hidden="true"
className="mb-3 h-16 w-16"
/>
<p className="text-muted-foreground text-sm">
{emptyMessage || t('settings.tools.noToolsFound', 'No results found')}
</p>
</div>
<EmptyState
size="xs"
title={
emptyMessage || t('settings.tools.noToolsFound', 'No results found')
}
/>
);
const commandBody = (
<Command shouldFilter={searchable}>
{title && (
<div className="shrink-0 px-4 pt-4">
<h3 className="text-foreground text-lg font-medium">{title}</h3>
<SectionHeader as="h3" size="xs" title={title} />
</div>
)}
{searchable && (
@@ -191,9 +180,7 @@ export function MultiSelectPopover({
)}
{loading ? (
<div className="text-foreground flex items-center justify-center py-8">
<Spinner size="sm" />
</div>
<LoadingState fill="block" size="sm" />
) : (
<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">
+6 -8
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
import { Button } from './ui/button';
import { IconButton } from './ui/icon-button';
interface NotificationProps {
notificationText: string;
@@ -93,21 +93,19 @@ export default function Notification({
</svg>
</span>
<Button
type="button"
<IconButton
label={t('notification.closeAriaLabel')}
icon={X}
variant="ghost"
size="icon-xs"
/* eslint-disable-next-line shadcn/no-restyle -- the close X on the promo gradient: any fill would be a grey square on the purple, so hover dims it instead; primary-foreground keeps it white because the button inherits the page colour */
className="text-primary-foreground absolute top-1 right-1 z-30 hover:bg-transparent hover:opacity-70 dark:hover:bg-transparent"
aria-label={t('notification.closeAriaLabel')}
className="text-primary-foreground absolute top-1 right-1 z-20 hover:bg-transparent hover:opacity-70"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCloseNotification();
}}
>
<X />
</Button>
/>
</a>
);
}
@@ -0,0 +1,46 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import PageToolbar from './PageToolbar';
describe('PageToolbar', () => {
it('stacks intro, the search/action row and an opt-in rule', () => {
const html = renderToStaticMarkup(
<PageToolbar
intro="Manage tools"
search={<input />}
action={<button type="button">Add Tool</button>}
divider
/>,
);
expect(html).toContain('text-muted-foreground mb-5 text-sm leading-6');
expect(html).toContain(
'mb-6 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between',
);
expect(html).toContain('w-full max-w-md');
expect(html).toContain('data-slot="separator"');
expect(html).toContain('mb-8');
});
it('puts the intro in the left slot when there is no search', () => {
const html = renderToStaticMarkup(
<PageToolbar
intro="Tokens"
action={<button type="button">Create</button>}
/>,
);
expect(html).toContain('max-w-2xl');
expect(html).not.toContain('data-slot="separator"');
});
it('renders notices between the row and the rule', () => {
const html = renderToStaticMarkup(
<PageToolbar intro="x" action={<span />} divider>
<p>limit reached</p>
</PageToolbar>,
);
expect(html.indexOf('limit reached')).toBeLessThan(
html.indexOf('data-slot="separator"'),
);
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { ReactNode } from 'react';
import { Separator } from './ui/separator';
/**
* The block under a section page's title: an intro line, then a row with the
* page search on the left and the page action (an Add pill) on the right,
* optionally closed by a rule. Notices that belong to the action (a limit
* warning) go in `children`, under the row.
*/
export default function PageToolbar({
intro,
search,
action,
divider = false,
children,
}: {
intro?: ReactNode;
search?: ReactNode;
action?: ReactNode;
divider?: boolean;
children?: ReactNode;
}) {
// With no search, the intro takes the row's left slot beside the action.
const introInRow = !search && intro;
return (
<div data-slot="page-toolbar">
{intro && !introInRow ? (
<p className="text-muted-foreground mb-5 text-sm leading-6">{intro}</p>
) : null}
<div className="mb-6 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
{introInRow ? (
<p className="text-muted-foreground max-w-2xl text-sm leading-6">
{intro}
</p>
) : search ? (
<div className="w-full max-w-md">{search}</div>
) : null}
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
{divider ? <Separator className="mb-8" /> : null}
</div>
);
}
+22 -17
View File
@@ -3,7 +3,9 @@ import { useTranslation } from 'react-i18next';
import useTokenAuth from '../hooks/useTokenAuth';
import { Avatar } from './ui/avatar';
import { Button } from './ui/button';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Separator } from './ui/separator';
/**
* Top-right account menu for OIDC sessions: an avatar that opens a popover
@@ -17,40 +19,42 @@ export default function ProfileButton() {
if (authType !== 'oidc' || (!userName && !userEmail)) return null;
const initial = (userName || userEmail || '?').charAt(0).toUpperCase();
// `default` is size-8/text-sm, `xl` is size-10/text-base; the size variant
// `sm` is size-8/text-sm, `lg` 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') =>
const renderAvatar = (size: 'sm' | 'lg') =>
userPicture ? (
<Avatar
size={size}
shape="circle"
src={userPicture}
alt={userName || userEmail || 'User avatar'}
alt={userName || userEmail || t('components.profile.avatarAlt')}
imgClassName="size-full object-cover"
/>
) : (
<Avatar size={size} shape="circle">
<span className="bg-primary text-primary-foreground flex size-full items-center justify-center font-medium">
{initial}
</span>
<Avatar size={size} shape="circle" variant="primary">
{initial}
</Avatar>
);
return (
<Popover>
<PopoverTrigger asChild>
<button
{/* inline + pill: the avatar sets the size and the Button brings
only the round focus ring; ghost's hover fill sits under it. */}
<Button
type="button"
variant="ghost"
size="inline"
shape="pill"
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('default')}
</button>
{renderAvatar('sm')}
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-64 p-0">
<div className="flex items-center gap-3 p-4">
{renderAvatar('xl')}
{renderAvatar('lg')}
<span className="flex min-w-0 flex-col">
{userName && (
<p className="text-foreground truncate text-sm font-medium">
@@ -64,17 +68,18 @@ export default function ProfileButton() {
)}
</span>
</div>
<div className="dark:border-sidebar-border border-t" />
<Separator />
<div className="p-1">
<button
<Button
type="button"
variant="ghost"
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"
className="w-full justify-start"
>
<LogOut className="text-muted-foreground size-4 shrink-0" />
<LogOut className="text-muted-foreground" aria-hidden />
{t('auth.signOut')}
</button>
</Button>
</div>
</PopoverContent>
</Popover>
@@ -0,0 +1,24 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import SearchInput from './SearchInput';
describe('SearchInput', () => {
it('is a 38px pill with a search icon and a floating label', () => {
const html = renderToStaticMarkup(<SearchInput label="Search tools..." />);
expect(html).toContain('rounded-full');
expect(html).toContain('h-9.5');
expect(html).toContain('pl-10');
expect(html).toContain('<label');
expect(html).toContain('bg-background');
expect(html).toContain('lucide-search');
});
it('takes a placeholder with an accessible name instead of a label', () => {
const html = renderToStaticMarkup(
<SearchInput placeholder="Search logs..." />,
);
expect(html).not.toContain('<label');
expect(html).toContain('aria-label="Search logs..."');
});
});
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff. Show more