mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 18:15:33 +00:00
Phase 2
This commit is contained in:
1 parent
0dd379c271
commit
f66dc50eed
289 files changed
+17148
-8081
No files matched your search
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Generated
-6
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,4 +5,5 @@ module.exports = {
|
||||
singleQuote: true,
|
||||
printWidth: 80,
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
tailwindFunctions: ['cn'],
|
||||
};
|
||||
+9
-14
@@ -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
@@ -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
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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/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's menu on the
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user