mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 06:14:51 +00:00
pre app check
This commit is contained in:
1 parent
8a9f3a75d6
commit
d5f1e47593
217 files changed
+13133
-3644
No files matched your search
@@ -0,0 +1,502 @@
|
||||
# DocsGPT frontend design system
|
||||
|
||||
The UI is Tailwind v4 plus the shadcn-style components in
|
||||
`src/components/ui/`. `@shadcn/lint` enforces the rules below through
|
||||
`npm run lint`; its messages point here. Everything in this file is either a
|
||||
token in `src/index.css` or a variant in a `ui/` component, so an agent or a
|
||||
contributor can always find the concrete thing to use.
|
||||
|
||||
## Rules in one paragraph
|
||||
|
||||
Pages compose `ui/` components and choose their look through props
|
||||
(`variant`, `size`, `shape`). `className` on a component is for placement
|
||||
only: margin, width, flex and grid, positioning. Colours come from the theme
|
||||
tokens, never from the raw Tailwind palette. Spacing, type and radii come
|
||||
from the Tailwind scale, never from arbitrary `[...]` values. Runtime values
|
||||
go in CSS custom properties or, when unavoidable, an inline style with a
|
||||
disable comment.
|
||||
|
||||
## Colour tokens
|
||||
|
||||
Defined in `src/index.css` (`:root` for light, `.dark` for dark) and exposed
|
||||
through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and
|
||||
`stroke-` all accept them, including opacity modifiers such as
|
||||
`bg-success/10`. One token class replaces a light + dark pair.
|
||||
|
||||
| Token | Use for | Replaces |
|
||||
| --------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `background`, `foreground` | page surface and primary text | `bg-white`, `text-gray-700/800/900`, `dark:text-gray-200/300` |
|
||||
| `card`, `card-foreground` | raised surfaces (cards, dialogs, dropdowns) | `bg-white dark:bg-[#2b2c31]` |
|
||||
| `popover`, `popover-foreground` | floating menus and popovers | same as card |
|
||||
| `muted`, `muted-foreground` | quiet fills and secondary text, icons, placeholders, timestamps | `bg-gray-100/200`, `text-gray-400/500/600`, `dark:text-gray-400` |
|
||||
| `accent`, `accent-foreground` | hover and selected fills | `hover:bg-gray-100`, `dark:hover:bg-gray-700` |
|
||||
| `border`, `input`, `ring` | dividers, field borders, focus rings | `border-gray-200/300`, `dark:border-gray-600/700` |
|
||||
| `primary`, `primary-foreground` | brand purple: primary actions, links, selected states | `bg-purple-*`, `text-purple-*`, `#7D54D1`, `#A076F6` |
|
||||
| `secondary`, `secondary-foreground` | pressed and active toggles (a brand tint), secondary buttons, the question bubble | `bg-accent` on an active icon button |
|
||||
| `destructive`, `destructive-foreground` | errors, failed states, dangerous actions | `text-red-*`, `bg-red-50/100`, `#B42318`, `#E60000` |
|
||||
| `success`, `success-foreground` | completed, active, healthy | `text-green-*`, `text-emerald-*`, `bg-green-50/100` |
|
||||
| `warning`, `warning-foreground` | paused, pending review, degraded | `text-amber-*`, `text-yellow-*`, `text-orange-*`, `bg-amber-50/100` |
|
||||
| `info`, `info-foreground` | running, informational | `text-blue-*`, `bg-blue-50/100` |
|
||||
| `sidebar-*` | the navigation rail | |
|
||||
| `chart-1` to `chart-5` | data series only, never UI chrome (see below) | |
|
||||
| `answer-bubble` | assistant message background | |
|
||||
|
||||
Status colours are tuned to stay vivid rather than turning brown or olive,
|
||||
so their contrast is low. Against white in light mode, `destructive` and
|
||||
`success` are about 3.8:1, `warning` is 2.9:1 and `info` is 5.2:1. On the
|
||||
dark card, `destructive` is 2.9:1 and the others are 5.5:1 or more. Do not
|
||||
use them for long body text; they are for badges, icons, short labels and
|
||||
fills.
|
||||
|
||||
Chart colours are not a separate palette. `chart-1` to `chart-5` alias
|
||||
`primary`, `info`, `success`, `warning` and `destructive`, in that order,
|
||||
so the charts use the same colours as the rest of the app in both themes.
|
||||
A single series uses `chart-1`, the brand colour. When a series has a
|
||||
meaning, use the token for that meaning, not the next one in order:
|
||||
failed tool calls use `destructive`, succeeded ones `success`, pending
|
||||
ones `warning`. Series with no meaning (models, agents, sources) take
|
||||
`chart-1` to `chart-5` in order. When there are more than five, the four
|
||||
largest keep `chart-1` to `chart-4` and the rest are summed into one
|
||||
"Other" series in `chart-5`, so no colour repeats (`settings/foldSeries.ts`).
|
||||
`secondary` is never a chart colour.
|
||||
|
||||
Patterns:
|
||||
|
||||
- Status pill: `<Badge variant="success">`. Status box: `<Alert variant="warning">`.
|
||||
Reach for `bg-success/10 text-success` directly only on dots and borders.
|
||||
- Solid status fill: `bg-warning text-warning-foreground`.
|
||||
- Guardrail outcomes: block `destructive`, flag `warning`, redact `info`,
|
||||
not evaluated `neutral`.
|
||||
- Status border: `border-destructive/50`.
|
||||
- De-emphasised text: `text-muted-foreground`; go lighter with an opacity
|
||||
modifier (`text-muted-foreground/70`) rather than a lighter grey.
|
||||
- Text on the brand colour is `text-primary-foreground`, not `text-white`.
|
||||
- `secondary` is a brand tint, not a grey: primary at 10% (light) or 15%
|
||||
(dark) over whatever sits behind it, with `primary` text in light and a
|
||||
lighter purple (#b89cf8) in dark. So a pressed toggle (CopyButton's copied
|
||||
state, text-to-speech while speaking) shows on card, background and muted
|
||||
alike, and never reads as the neutral ghost hover. Use it as
|
||||
`variant={active ? 'secondary' : 'ghost-muted'}`.
|
||||
- The user's question bubble is `bg-secondary text-foreground`: the brand
|
||||
tint as the fill, with body text in `foreground` (15.7:1 light, 12.4:1
|
||||
dark) rather than `secondary-foreground`, which is only 4.5:1 in light.
|
||||
Controls on it (the collapse chevron) are plain `ghost` with a lucide icon
|
||||
in `currentColor`.
|
||||
- `white`, `black`, `transparent`, `current` and `inherit` are allowed; use
|
||||
them only for overlays and imagery, not for text or surfaces.
|
||||
|
||||
## Components and their variants
|
||||
|
||||
### Button (`ui/button.tsx`)
|
||||
|
||||
| Prop | Values |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `variant` | `default`, `secondary`, `outline`, `outline-primary`, `ghost`, `ghost-muted`, `ghost-destructive`, `link`, `destructive`, `destructive-outline`, `combobox`, `sidebar-item`, `tab`, `section-toggle` |
|
||||
| `size` | `xs`, `sm`, `default`, `lg`, `field`, `icon-xs`, `icon-sm`, `icon`, `icon-lg`, `inline` |
|
||||
| `shape` | `default` (rounded-md), `pill` (rounded-full, wider padding) |
|
||||
|
||||
- Round brand buttons (`rounded-3xl px-5`, `rounded-full px-6`): `shape="pill"`,
|
||||
plus `size="lg"` if they were `px-6`.
|
||||
- Muted icon buttons (`text-muted-foreground hover:text-foreground`):
|
||||
`variant="ghost-muted"`.
|
||||
- Icon actions that remove something (a trash can on a member or shared
|
||||
resource row) are `variant="ghost-destructive"`: muted at rest, red on
|
||||
hover, so the warning shows even where the click deletes without asking.
|
||||
- Bordered purple buttons (`border-primary text-primary hover:bg-primary`):
|
||||
`variant="outline-primary"`.
|
||||
- Tiny inline actions (`h-auto px-2 py-1 text-xs`): `size="xs"`.
|
||||
- A link inside running text (an artifact link in an answer) is
|
||||
`variant="link" size="inline"`: no height or padding, so it wraps with the
|
||||
sentence. Standalone links (toggles, crumbs) keep a normal size.
|
||||
- The composer controls under the chat field (Attach, Voice, Tools,
|
||||
Sources) are `size="sm" shape="pill"`. Their icons are `size-3.5 sm:size-4`
|
||||
with no margin; the size's `gap-1.5` spaces them, and the label span keeps
|
||||
`text-xs sm:text-sm` so the row still fits on a phone.
|
||||
- Popover comboboxes (`role="combobox"` + `Command`) use `variant="combobox"`,
|
||||
which matches `SelectTrigger`: card fill, normal weight, and muted text
|
||||
while `data-placeholder` is set (`data-placeholder={value ? undefined : ''}`).
|
||||
Pass only layout (`w-full justify-between`) and keep the chevron as the
|
||||
last child.
|
||||
- A button or picker that sits in a row of fields is `size="field"`: 42px,
|
||||
the height of `Input` (default) and `SelectTrigger size="lg"`, so a form
|
||||
column has one height. With `shape="pill"` its text starts 21px in, like
|
||||
the Input and Select pills beside it. The agent form's pickers are
|
||||
`combobox field pill`, its Add button `outline-primary field pill`.
|
||||
- Rows in the navigation sidebar (`hover:bg-sidebar-accent … pl-3 gap-2.5
|
||||
rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal
|
||||
weight, `bg-sidebar-accent` on hover and while `aria-current="page"`. Use it
|
||||
with `asChild` around a `<Link>` for navigation rows.
|
||||
- Inline disclosure toggles ("Advanced settings", "Show advanced options")
|
||||
are `variant="link" size="sm"` with only `-ml-3 w-fit justify-start`;
|
||||
a chevron, when the toggle has one, is a lucide `ChevronRight` (so it
|
||||
takes the link colour) as the first child.
|
||||
- Underline tabs (FilePicker's My Files / Shared with Me, the agent page
|
||||
sub-nav) are `variant="tab"`: muted text on a transparent 2px bottom border,
|
||||
square corners, no hover fill. Mark the current tab with `data-active`, which
|
||||
gives it `foreground` text and a `primary` underline. Padding-free tabs (a
|
||||
sub-nav with `gap-6`) add `size="inline"`, which keeps 4px above the line;
|
||||
the row draws the 1px baseline, and `-mb-px` lays the underline over it.
|
||||
Tabs that switch a panel in place use `ui/tabs` instead.
|
||||
- A section panel's disclosure header (NewAgent's Advanced and Guardrails
|
||||
panels) is `variant="section-toggle" size="sm"` with `-ml-3 w-fit
|
||||
justify-start` and `aria-expanded`: a lucide `ChevronRight` first
|
||||
(`rotate-90` while open) in primary, then the foreground title, with a
|
||||
primary underline on hover. The button draws no focus ring; the panel does,
|
||||
so keyboard focus outlines the whole white block:
|
||||
`has-[[data-variant=section-toggle]:focus-visible]:ring-3 …:ring-ring/50
|
||||
…:ring-inset` on the panel `<div>` (inset, because a scrolling column clips
|
||||
an outset ring). Status badges go beside the button, not inside it, or the
|
||||
hover underline runs under them.
|
||||
- Drop `text-white` on default and destructive buttons; the foreground token
|
||||
already provides it. Drop `disabled:cursor-not-allowed`; the base disables
|
||||
pointer events.
|
||||
|
||||
### Card (`ui/card.tsx`)
|
||||
|
||||
| Prop | Values |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `variant` | `outline` (border + card surface, the default), `filled` (muted fill, no border, for tiles on a card-coloured page), `subtle` (border on the page background) |
|
||||
| `padding` | `none`, `sm` (p-3, stat tiles and panels), `default` (p-4), `lg` (p-6) |
|
||||
| `interactive` | whole card is the target: hover, focus ring and `selected` highlight; pair with `asChild` around a `<button>` or `<Link>` |
|
||||
|
||||
Parts: `CardHeader` (title left, `CardAction` top-right), `CardTitle`,
|
||||
`CardDescription`, `CardContent`, `CardFooter` (meta row, sticks to the
|
||||
bottom). One radius for every card (`rounded-2xl`); pass only layout and
|
||||
`gap-*` on `Card`. Replaces every hand-rolled
|
||||
`rounded-(md|lg|xl|2xl|3xl|4xl) border bg-(card|muted) p-*` box: source and
|
||||
tool tiles, team and agent cards, analytics stat tiles and chart panels,
|
||||
source-type and agent-type picker tiles.
|
||||
|
||||
### OptionCard (`ui/option-card.tsx`)
|
||||
|
||||
The picker tile: a whole-card `<button>` with an icon in a tinted square, a
|
||||
title and an optional description; `selected` fills the icon square and
|
||||
outlines the card. Use it wherever the user picks one of several ways to
|
||||
proceed (agent type, source type). Title-only tiles keep the same layout.
|
||||
Pass `selected` (true or false) only in a
|
||||
single-select picker inside a `role="radiogroup"`; the tile is then a radio.
|
||||
Tiles that advance a step or navigate (Upload's source types, the agent-type
|
||||
modal) leave it out and stay plain buttons. Only layout classes on it.
|
||||
|
||||
### Badge (`ui/badge.tsx`)
|
||||
|
||||
`variant`: `default` (brand), `neutral`, `success`, `warning`, `destructive`,
|
||||
`info`, `outline`. Replaces every hand-rolled
|
||||
`rounded-full bg-<colour>-100 px-2 py-0.5 text-xs text-<colour>-700 dark:...`
|
||||
pill, including schedule and run status pills. HTTP method pills take their variant from
|
||||
`getMethodBadgeVariant` (`utils/httpMethodColors.ts`): GET `success`, POST
|
||||
`info`, PUT `warning`, DELETE `destructive`, PATCH `default`, anything else
|
||||
`neutral`.
|
||||
|
||||
### Input (`ui/input.tsx`)
|
||||
|
||||
| Prop | Values |
|
||||
| ------- | ---------------------------------------------------------------- |
|
||||
| `size` | `sm` (h-8), `default` (h-10.5, the historical 42px), `lg` (h-12) |
|
||||
| `shape` | `default`, `pill` |
|
||||
| `variant` | `default`, `bare` (no border, padding, radius, shadow or ring), `filled` (card fill) |
|
||||
|
||||
Text alignment classes (`text-right`) and `font-mono` (code fields) are
|
||||
allowed on Input and Textarea. A floating
|
||||
`label` sits on the field's border, so its background must match the surface
|
||||
behind the field: `labelSurface` is `card` (default, forms in cards and
|
||||
modals), `background` (fields straight on a page) or `muted` (fields on a
|
||||
muted panel). Never pass a background class for it. The chat and hero
|
||||
fields (`rounded-3xl px-5 py-3`) are `size="lg" shape="pill"`. A
|
||||
default-size pill (42px, the form-row height) pads `px-5` like the large
|
||||
one, so its text lines up with the Select pills. Compact table
|
||||
filters (`h-auto px-2 py-1 text-sm`) are `size="sm"`. An inset icon (a
|
||||
search glass) is `leftIcon`, with or without a `label`; it pads the field
|
||||
`pl-10`, so never hand-place an icon over an Input. A field inside a host
|
||||
that already draws the frame (the renaming sidebar row, a search strip in a
|
||||
bordered panel) is `variant="bare"`; the host shows focus, and any inset
|
||||
padding goes on the host, not the field. A field on a muted panel (the
|
||||
ImportSpec Base URL box) is `variant="filled"`, so it keeps the card fill
|
||||
instead of showing the panel through; never pass `bg-card` for it.
|
||||
|
||||
### SelectTrigger (`ui/select.tsx`)
|
||||
|
||||
`size`: `sm`, `default`, `lg`; `variant`: `default`, `ghost`; `shape`:
|
||||
`default`, `pill`. The large rounded selects (`rounded-3xl px-5 py-3`) are
|
||||
`size="lg" shape="pill"`.
|
||||
|
||||
### Textarea (`ui/textarea.tsx`)
|
||||
|
||||
`size`: `sm`, `default`, `lg` (rounded-2xl, for prompt editors); `resize`:
|
||||
`none`, `vertical` (default), `both`. Same border, ring and invalid styling
|
||||
as Input. `variant`: `default` (transparent), `filled` (card fill), with
|
||||
the same rule as Input: a textarea on a muted panel is `variant="filled"`,
|
||||
never `bg-card`. Replaces the 21 raw `<textarea>` elements.
|
||||
|
||||
### Tooltip (`ui/tooltip.tsx`)
|
||||
|
||||
`Tooltip` + `TooltipTrigger asChild` + `TooltipContent side`. Each tooltip
|
||||
carries its own provider. Use it on every icon-only button and wherever a
|
||||
`title=` attribute was standing in for a hint; keep `aria-label` on the
|
||||
button as well.
|
||||
|
||||
### Spinner and Skeleton (`ui/spinner.tsx`, `ui/skeleton.tsx`)
|
||||
|
||||
`Spinner size="sm | default | lg"` draws in `currentColor`, so colour it
|
||||
with a `text-*` token on it or its parent. `components/Spinner.tsx` is a
|
||||
compatibility wrapper (`small | medium | large`) for existing imports.
|
||||
`Skeleton` is a pulsing muted block sized with layout classes and rounded
|
||||
as needed; `components/SkeletonLoader.tsx` should compose it.
|
||||
|
||||
### Progress (`ui/progress.tsx`)
|
||||
|
||||
`value` 0 to 100, `variant`: `default`, `success`, `warning`,
|
||||
`destructive`, `info`; `size`: `sm`, `default`, `lg`. Replaces the
|
||||
width-percent divs in quota, indexing and guardrail views.
|
||||
|
||||
### Avatar (`ui/avatar.tsx`)
|
||||
|
||||
`size`: `none` (image decides, the default), `sm`, `default`, `lg`, `xl`;
|
||||
`shape`: `none`, `circle`, `square`; `variant`: `default`, `primary` (brand
|
||||
initials), `muted` (grey initials). Initials boxes pass the letters as
|
||||
children.
|
||||
|
||||
### Dropzone (`ui/dropzone.tsx`)
|
||||
|
||||
`size`: `default` (tall target), `compact` (one row, for forms and modals).
|
||||
Props: `onDrop`, `accept`, `multiple`, `maxFiles`, `maxSize`, `disabled`,
|
||||
`title`, `description`, `icon`, `error`. Border and fill follow the drag
|
||||
state through `data-drag-active` and `data-drag-reject`. Replaces every
|
||||
hand-rolled `border-2 border-dashed rounded-3xl` target around
|
||||
`useDropzone`; `components/FileUpload.tsx` keeps its preview and validation
|
||||
logic and should render this underneath.
|
||||
|
||||
### Toast (`ui/toast.tsx`)
|
||||
|
||||
Feedback on anything the user did (uploads, runs, approvals, team events)
|
||||
is a toast in the bottom-right stack, never an inline box. The app has one
|
||||
`ToastViewport`, mounted in `App.tsx`; it is the live region
|
||||
(`role="status"`, `aria-live="polite"`) and the fixed stack, so `Toast`
|
||||
cards carry no role and no toast renders its own rail or positioning. Top
|
||||
to bottom it holds `TeamNotificationToast`, `ToolApprovalToast`,
|
||||
`UploadToast` and `ActionToast`, and it moves to the bottom-left while the
|
||||
workflow Preview drawer is open. A new toast component returns only its
|
||||
`Toast` cards and is added to that viewport. A page that reports the result
|
||||
of an action (the admin Users actions) dispatches
|
||||
`showActionToast({ variant: 'success' | 'destructive', message })` from
|
||||
`notifications/actionToastSlice.ts`; `ActionToast` shows it and dismisses
|
||||
it after 4.5s, and a new result replaces the previous one.
|
||||
|
||||
Compose `Toast` > `ToastHeader variant` (`default`, `success`, `warning`,
|
||||
`destructive`, `info`) with `ToastTitle` and `ToastActions` (collapse and
|
||||
close as `Button variant="ghost-muted" size="icon-sm"`), then
|
||||
`ToastContent` (add `scrollable` for long lists) with `ToastItem label meta`
|
||||
rows (optional `icon` before the label), a `ToastStatus status` circle per
|
||||
row (`pending`, `success`, `warning`, `destructive`, `info`),
|
||||
`ToastMessage variant` for an explanation and `ToastFooter` for action
|
||||
buttons. `ToastTitle` truncates to one line; `wrap` lets a long title (a
|
||||
localised string, a team name) wrap instead. `ToastMessage size` is `xs`
|
||||
(default, the note under a row) or `sm` (`text-sm leading-4.5`, the whole
|
||||
body of a notice). A message that is the only content under the header
|
||||
gets its top padding on its own, and a message right after a `ToastItem`
|
||||
shares that row's divider. Toasts own their width, radius and shadow; pass
|
||||
no width or colour to them.
|
||||
|
||||
### Alert (`ui/alert.tsx`)
|
||||
|
||||
Inline notice inside a form or modal only, for something the user must
|
||||
read before acting (an expiring token above the field, a warning in a
|
||||
settings panel). Not for feedback on an action; that is a Toast.
|
||||
`variant`: `default`, `success`, `warning`, `info`, `destructive`. Every
|
||||
coloured variant is the same shape: `border-<role>/50 bg-<role>/10
|
||||
text-<role>`, icon in the role colour; `default` sits on `bg-background`.
|
||||
Icon first, then `AlertTitle` and `AlertDescription`. Every variant is
|
||||
`role="alert"` except `success`, which is `role="status"` so a confirmation
|
||||
is announced politely; pass `role` only to override that. Replaces the hand-rolled
|
||||
`rounded-lg border bg-amber-50 text-amber-800` boxes.
|
||||
|
||||
### Breadcrumb (`ui/breadcrumb.tsx`)
|
||||
|
||||
`BreadcrumbPage`, the current crumb, is always one line and truncates with
|
||||
an ellipsis. Pages pass only its width (`w-[16ch]`, `max-w-[32ch]`) and a
|
||||
`title` with the full text; never `truncate` or typography. Give every
|
||||
current crumb a width cap so a long name cannot push the row past the
|
||||
screen. A crumb that runs a handler instead of navigating is
|
||||
`BreadcrumbLink asChild` around a `<button type="button">`; the link carries
|
||||
the focus ring. The current crumb is never a disabled button.
|
||||
|
||||
### Accordion (`ui/accordion.tsx`)
|
||||
|
||||
`AccordionTrigger` carries its own inset and type (`px-4 py-3 text-sm
|
||||
font-medium`) and an inset focus ring, since `AccordionItem` clips anything
|
||||
outside it. Pages pass nothing to the trigger; put the frame (a bordered,
|
||||
rounded box) on a wrapper around `Accordion`.
|
||||
|
||||
### Modal, not Dialog
|
||||
|
||||
`ui/dialog.tsx` is the Radix primitive and is private to `ui/`; ESLint
|
||||
rejects imports of it elsewhere. App code uses `Modal` (sizes `sm` to
|
||||
`full`, `mobileVariant="sheet"`) or `CommandDialog`.
|
||||
|
||||
A search palette is `CommandDialog` on desktop. Pass cmdk options to its
|
||||
inner `Command` through `commandProps` (`shouldFilter={false}` when results
|
||||
come from a server search, a controlled `value`). On phones the same palette
|
||||
goes in `Modal mobileVariant="sheet"` as `<Command variant="palette">`, which
|
||||
gives it the dialog's 48px input row and row spacing, so both widths look
|
||||
the same (`modals/SearchConversationsModal.tsx`).
|
||||
|
||||
In a picker list, mark the item that is currently chosen with
|
||||
`CommandItem checked` (a `secondary` brand tint through `data-checked`), not
|
||||
with `bg-accent`: cmdk's own `data-selected` highlight is `bg-accent` and
|
||||
follows the pointer and arrow keys, so an accent fill would look like hover.
|
||||
While a checked item is also highlighted it keeps its tint and text and gains
|
||||
a 1px inset `primary` ring, so the chosen row never turns plain grey.
|
||||
|
||||
### Table, Label, dialog text
|
||||
|
||||
`TableCell` and `TableHeader` accept typography and alignment classes and
|
||||
`text-muted-foreground`. `Label`, `DialogTitle`, `DialogDescription`,
|
||||
`SheetTitle` and `SheetDescription` accept typography plus
|
||||
`text-foreground` and `text-muted-foreground`. `DialogContent`,
|
||||
`PopoverContent`, `SheetContent`, `DropdownMenuContent` and `Modal` accept
|
||||
`p-0` for edge-to-edge content (MoveToFolderModal's banded folder picker);
|
||||
Modal merges it after its own `p-8`, so no `!` is needed. Everything else on these components is layout only.
|
||||
|
||||
## Spacing, type and radii
|
||||
|
||||
Use the Tailwind scale. Tailwind v4 accepts fractional steps, so
|
||||
`h-10.5` is 42px and `ring-3` is a 3px ring; neither needs brackets. Layout
|
||||
values (`h-[calc(100dvh-64px)]`, `max-w-[520px]`) and motion values
|
||||
(`transition-[color,box-shadow]`, custom easings) are allowed. Font sizes,
|
||||
padding, colours and radii in brackets are not; pick the nearest scale step
|
||||
or add a token.
|
||||
|
||||
## Focus, elevation and stacking
|
||||
|
||||
- **Focus ring**: `focus-visible:ring-3 focus-visible:ring-ring/50` plus
|
||||
`focus-visible:border-ring` on fields. Never `focus:` (mouse users see it)
|
||||
and never `ring-2` or `ring-[3px]`; the `ui/` components already carry it,
|
||||
so plain elements should become components rather than copy the classes.
|
||||
- **Elevation**: cards have no shadow; fields and outline buttons
|
||||
`shadow-xs`; popovers, menus and select lists `shadow-md`; sheets
|
||||
`shadow-lg`; modals `shadow-modal`; toasts `shadow-toast`. Nothing else
|
||||
uses a shadow class.
|
||||
- **Stacking**: `z-10` sticky headers and table heads inside a page;
|
||||
`z-20` in-page floating chrome (banners, scroll-to-bottom, drag
|
||||
handles); `z-50` everything portalled (overlay, modal, sheet, popover,
|
||||
menu, tooltip); `z-200` only for select lists that must open above a
|
||||
modal. Do not invent values in between.
|
||||
|
||||
## Inline styles
|
||||
|
||||
Allowed without comment in `src/components/MermaidRenderer.tsx` and
|
||||
`src/agents/workflow/nodes/**` (third-party renderers and canvas
|
||||
positions). Elsewhere, prefer a CSS custom property
|
||||
(`style={{ '--progress': pct }}` with `w-(--progress)`), and when a value
|
||||
truly is computed at runtime, keep the inline style and add
|
||||
`// eslint-disable-next-line shadcn/no-inline-styles -- <reason>`.
|
||||
|
||||
## Approved exceptions
|
||||
|
||||
Places where a rule is knowingly disabled. Each one carries the same reason
|
||||
as a comment at the call site; add a row here when you add a disable so the
|
||||
list stays reviewable.
|
||||
|
||||
| Where | Rule | Why |
|
||||
| ----------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `components/Notification.tsx` | `shadcn/no-restyle` | The promo banner's close control is a bare 16px image on the purple gradient: no `size` fits it, and `ghost`'s accent hover fill would paint a grey square on the gradient. |
|
||||
| `components/ui/calendar.tsx` | `shadcn/require-static-classes` | The day button merges `defaultClassNames.day`, which react-day-picker returns from `getDefaultClassNames()` at runtime; no static form exists. |
|
||||
| `navigation/SidebarLevel.tsx` | `shadcn/no-arbitrary-values` | The incoming sidebar panel casts a strong shadow off its left edge while it slides (`shadow-[-12px_0_24px_-6px_rgba(0,0,0,0.45)]`); no scale shadow is horizontal, and the container clips it once the panel comes to rest. |
|
||||
| `components/ui/modal.tsx` | `shadcn/no-arbitrary-values` | The mobile sheet's `pb-[max(env(safe-area-inset-bottom),1rem)]` keeps its footer clear of the iPhone home indicator (about 34px, since `index.html` sets `viewport-fit=cover`) and equals `pb-4` everywhere else; no scale step can express `env()`. |
|
||||
| `components/MessageInput.tsx` | `shadcn/no-restyle` | The empty composer's send button is a grey circle (`bg-muted`, `dark:bg-accent`), not a faded brand one; no variant is neutral while disabled, and `secondary` is the brand-tinted pressed state. |
|
||||
| `Hero.tsx` | `shadcn/no-restyle` | The landing page's model picker keeps its hero look: a borderless muted pill at 16px (`rounded-4xl px-6 py-4 text-base`) whose menu hangs from it as one shape. Three disables: `SelectTrigger`, `SelectContent`, `SelectItem`. |
|
||||
| `agents/AgentsList.tsx` | `shadcn/no-restyle` | Inside a folder, the breadcrumb trail replaces the section `<h2>`, so its `BreadcrumbList` keeps heading typography (`text-foreground text-lg font-semibold gap-2`). It's the only breadcrumb that does. |
|
||||
| `settings/Prompts.tsx` | `shadcn/no-restyle` | The row icon buttons (edit, duplicate, share, delete) sit in a cmdk row that is already `bg-accent` while highlighted, so ghost's accent hover would be invisible; they keep a `foreground/15` (delete: `destructive/15`) hover tint. Four disables. |
|
||||
| `Navigation.tsx` | `shadcn/no-restyle` | The agent pin is a bare 16px image on a sidebar row that already fills on hover; an accent fill would be invisible on it, so hover dims the icon (`hover:opacity-75`) instead. |
|
||||
| `conversation/MarkdownAnswer.tsx`, `components/ArtifactSidebar.tsx` | `shadcn/no-inline-styles` | SyntaxHighlighter's `style` prop is its Prism theme object (`oneLight` / `vscDarkPlus`), picked by theme at runtime. It is not CSS, so no class or custom property can replace it. One disable per file. |
|
||||
| `components/MultiSelectPopover.tsx` | `shadcn/no-restyle`, `shadcn/no-arbitrary-values` | The phone bottom sheet (`SheetContent side="bottom"`) keeps its sheet shape: a rounded top, no gap, a card fill in dark so it matches the `Command` inside it, and `pb-[env(safe-area-inset-bottom)]` to clear the iPhone home indicator (the same inset as `ui/modal.tsx`). One disable. |
|
||||
| `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | The Preview minimap's node rows are status tiles: the fill, border and ring follow the step (success, primary running + pulse, destructive, muted pending; a ring on the active row) and stay pinned on hover, pending and running rows stay unfaded while disabled, and clickable rows dim to 80% on hover. No Button variant is status-tinted. The rule reports each string inside `cn(...)`, so it's a `/* eslint-disable */` … `/* eslint-enable */` pair around the `className` attribute. |
|
||||
| `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom − / + buttons sit on the diagram's `bg-black/70` overlay, where ghost's accent hover paints a light square with dark text; they hover to `white/20` with white text instead (`dark:hover:bg-white/20` too, to beat ghost's dark hover). Two disables. |
|
||||
| `conversation/ConversationMessages.tsx`, `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | MessageScroller padding. The primitive measures `MessageScrollerContent`'s padding-block for its scroll and spacer math, and the Viewport's top gap must scroll with the messages, so neither can move to a margin or a wrapper. The two callers use different values. Five disables. |
|
||||
|
||||
Note that a multi-line reason has to be a `/* ... */` block comment;
|
||||
consecutive `//` lines only disable the next comment line, not the code.
|
||||
|
||||
## Known dead classes
|
||||
|
||||
The lint reports classes Tailwind cannot generate. In this codebase they are
|
||||
bugs, not missing configuration:
|
||||
|
||||
- `xs:` has never been a breakpoint here (not in the old Tailwind v3 config
|
||||
either), so `xs:px-3`, `xs:text-xs` never applied. Remove them rather than
|
||||
declaring the breakpoint, which would change layouts that were never seen.
|
||||
- `text-s`, `dark:text-gray`, `w-inherit` are typos for `text-sm`,
|
||||
`dark:text-gray-*` and `w-[inherit]`.
|
||||
- `agents-container`, `conversations-container`, `loader` and `mermaid` are
|
||||
unused hooks; nothing selects them. Before deleting a class like these,
|
||||
grep for it in `querySelector` and `classList` calls too. `star` was
|
||||
defined at runtime by a `<style>` element in `components/Notification.tsx`;
|
||||
it is now the `notification-star` utility in `src/index.css`. `msc-spacer`
|
||||
was a lookup hook for `ConversationMessages`' `querySelector`; the lookup
|
||||
now uses the primitive's `[data-message-scroller-spacer]` attribute.
|
||||
|
||||
## Live style guide
|
||||
|
||||
`npm run dev`, then open `/design`. The page (`src/design/DesignSystem.tsx`)
|
||||
renders every `ui/` component with the variants above, shows the resolved
|
||||
value of each colour token and has a theme toggle. It is registered only
|
||||
when Vite runs in development mode, so it never ships. When you add a
|
||||
variant or token, add it there too.
|
||||
|
||||
## Cleanup playbook
|
||||
|
||||
The design lint is at `warn` while the app is migrated. Work one file at a
|
||||
time, smallest first, and keep every step green.
|
||||
|
||||
1. **See the state.** `npm run lint:design` prints warnings by rule and the
|
||||
worst files. `npm run lint:design -- --file src/settings/Teams.tsx` lists
|
||||
every warning in one file with the fix the message proposes;
|
||||
`-- --rule no-raw-colors` lists files for one rule.
|
||||
2. **Pick a file, read it whole**, then fix all of its design warnings in
|
||||
one pass. Map raw colours to tokens (table above), replace hand-rolled
|
||||
boxes, pills, tiles, spinners and dropzones with the `ui/` components,
|
||||
and move Button/Input/Select overrides onto `variant`, `size`, `shape`.
|
||||
Delete the `dark:` twin of every class you tokenise. Remove classes the
|
||||
lint calls unknown (`xs:*`, `text-s`, `dark:text-gray`, unused hooks).
|
||||
3. **Do not restyle `ui/` components from a page.** If a treatment is used
|
||||
in more than one place and no variant fits, add the variant in the
|
||||
component file, add it to `src/design/DesignSystem.tsx`, and document it
|
||||
here. A one-off gets `// eslint-disable-next-line shadcn/<rule> -- reason`.
|
||||
4. **Keep behaviour identical.** Same DOM order, same handlers, same
|
||||
translations (`t(...)` keys stay). Only classes and component choice
|
||||
change. Prettier with the Tailwind plugin reorders classes; run
|
||||
`npm run lint-fix` rather than fighting it.
|
||||
5. **Verify per file**: `npx eslint <file>` (zero `shadcn/*` warnings),
|
||||
`npx tsc --noEmit -p tsconfig.json`, `npm test`, and look at the screen
|
||||
in the running app (`npm run dev`, then the page that renders the file)
|
||||
in both themes.
|
||||
6. **Promote rules** in `eslint.config.js` from `warn` to `error` when
|
||||
`npm run lint:design -- --rule <rule>` reports zero, in this order:
|
||||
`no-unknown-classes`, `require-static-classes`, `no-inline-styles`,
|
||||
`no-arbitrary-values`, `no-raw-colors`, `no-restyle`.
|
||||
|
||||
Known traps:
|
||||
|
||||
- `npm run lint` also reports 187 pre-existing Prettier errors in nine
|
||||
files unrelated to design; `npm run lint-fix` clears them.
|
||||
- Port 5173 may be held by another checkout; check the Vite banner shows
|
||||
this repo's path before trusting what you see.
|
||||
- `components/Spinner.tsx` and `components/StatusBadge.tsx` are transitional
|
||||
wrappers; migrate their callers to `ui/spinner` and `ui/badge`, then
|
||||
delete them. `components/SkeletonLoader.tsx` and
|
||||
`components/FileUpload.tsx` keep their logic but should render `Skeleton`
|
||||
and `Dropzone`.
|
||||
|
||||
## Working with the lint
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint
|
||||
```
|
||||
|
||||
All `shadcn/*` rules are `warn` while the backlog is cleaned up. Promote a
|
||||
rule to `error` in `eslint.config.js` once `npm run lint` reports zero
|
||||
warnings for it; the intended order is `no-unknown-classes`,
|
||||
`require-static-classes`, `no-inline-styles`, `no-arbitrary-values`,
|
||||
`no-raw-colors`, `no-restyle`. Add a variant or token only when a treatment
|
||||
is used in more than one place and none of the existing ones fits; a single
|
||||
special case gets a disable comment with a reason.
|
||||
+195
-8
@@ -1,10 +1,11 @@
|
||||
import js from '@eslint/js'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin'
|
||||
import react from 'eslint-plugin-react'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import prettier from 'eslint-plugin-prettier'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js';
|
||||
import { plugin as shadcn } from '@shadcn/lint';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import react from 'eslint-plugin-react';
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{
|
||||
@@ -49,6 +50,7 @@ export default [
|
||||
react,
|
||||
'unused-imports': unusedImports,
|
||||
prettier,
|
||||
shadcn,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
@@ -68,11 +70,196 @@ export default [
|
||||
endOfLine: 'auto',
|
||||
},
|
||||
],
|
||||
// The Radix Dialog wrapper is an internal primitive; app code uses
|
||||
// <Modal> (or <CommandDialog>). Only src/components/ui may import it.
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['**/components/ui/dialog'],
|
||||
message:
|
||||
'Use <Modal> from @/components/ui/modal; the Dialog primitive is internal to ui/.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// Design-system rules (@shadcn/lint). Tokens, variants and the
|
||||
// approved exceptions are documented in DESIGN.md. Every rule starts
|
||||
// at `warn`; promote a rule to `error` once its count reaches zero.
|
||||
'shadcn/no-restyle': [
|
||||
'warn',
|
||||
{
|
||||
allow: ['layout'],
|
||||
contracts: [
|
||||
{
|
||||
pattern: '^Button$',
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
shape:
|
||||
'"{{className}}" is not allowed on <Button>: use shape="pill" for round buttons. Other shapes need a new variant in {{file}}.',
|
||||
spacing:
|
||||
'"{{className}}" is not allowed on <Button>: use a size ({{sizes}}). shape="pill" already widens the padding; put space around the button on the parent (gap) or as margin here.',
|
||||
color:
|
||||
'"{{className}}" is not allowed on <Button>: use a variant ({{variants}}). Primary buttons already have white text; muted icon buttons are variant="ghost-muted"; bordered brand buttons are variant="outline-primary".',
|
||||
typography:
|
||||
'"{{className}}" is not allowed on <Button>: size="xs" gives text-xs; the default size is text-sm. Add a size in {{file}} only if the design explicitly calls for one.',
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Button>: {{category}} belongs to the component. Use a variant ({{variants}}), a size ({{sizes}}) or a shape (default, pill). See DESIGN.md.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Input$',
|
||||
// font-mono: code fields (JSON, keys) keep a monospace face.
|
||||
allow: [
|
||||
'layout',
|
||||
'text-left',
|
||||
'text-center',
|
||||
'text-right',
|
||||
'font-mono',
|
||||
],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Input>: use size (default, sm, lg) and shape (default, pill); alignment classes are allowed. Add a variant in {{file}} only if the design explicitly calls for one.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^SelectTrigger$',
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <SelectTrigger>: use size (sm, default, lg), variant (default, ghost) and shape (default, pill) from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Avatar$',
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Avatar>: use size (sm, default, lg, xl), shape (circle, square) and variant (primary, muted) from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Card$',
|
||||
allow: ['layout', 'gap-*'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Card>: use variant (outline, filled, subtle), padding (none, sm, default, lg) and interactive/selected from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Textarea$',
|
||||
// font-mono: code editors (JSON schema, workflow code) keep a
|
||||
// monospace face.
|
||||
allow: [
|
||||
'layout',
|
||||
'text-left',
|
||||
'text-center',
|
||||
'text-right',
|
||||
'font-mono',
|
||||
],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Textarea>: use size (sm, default, lg) and resize (none, vertical, both) from {{file}}; min-height and width are layout and allowed.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Skeleton$',
|
||||
allow: ['layout', 'rounded-*'],
|
||||
},
|
||||
{
|
||||
pattern: '^Spinner$',
|
||||
allow: ['layout', 'color'],
|
||||
},
|
||||
{
|
||||
pattern: '^Progress$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(Toast|ToastViewport)$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(ToastContent|ToastItem|ToastFooter)$',
|
||||
allow: ['layout', 'spacing'],
|
||||
},
|
||||
{
|
||||
pattern: '^OptionCard$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(CardHeader|CardContent|CardFooter|CardAction)$',
|
||||
allow: ['layout', 'spacing', 'gap-*', 'bg-muted/*'],
|
||||
},
|
||||
{
|
||||
pattern: '^(CardTitle|CardDescription)$',
|
||||
allow: ['layout', 'typography', 'line-clamp-*'],
|
||||
},
|
||||
{
|
||||
pattern: '^(TableCell|TableHeader)$',
|
||||
allow: ['layout', 'typography', 'text-muted-foreground'],
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
'^(Label|DialogTitle|DialogDescription|SheetTitle|SheetDescription)$',
|
||||
allow: [
|
||||
'layout',
|
||||
'typography',
|
||||
'text-muted-foreground',
|
||||
'text-foreground',
|
||||
],
|
||||
},
|
||||
{
|
||||
// Modal wraps DialogContent and merges className after its p-8.
|
||||
pattern: '^((Dialog|Popover|Sheet|DropdownMenu)Content|Modal)$',
|
||||
allow: ['layout', 'p-0'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'shadcn/no-raw-colors': [
|
||||
'warn',
|
||||
{
|
||||
message:
|
||||
'"{{className}}" uses the raw Tailwind palette. Map it to a token from {{file}}: grey text -> text-foreground or text-muted-foreground; grey borders -> border-border; grey fills -> bg-muted, bg-accent or bg-card; red -> destructive; green -> success; amber, yellow and orange -> warning; blue -> info; purple -> primary. Soft badge or alert fills use bg-<token>/10 and a single class replaces the light+dark pair. Status pills should be <Badge variant="...">.',
|
||||
},
|
||||
],
|
||||
'shadcn/no-arbitrary-values': [
|
||||
'warn',
|
||||
// Motion values (`transition-[color,box-shadow]`, custom easings)
|
||||
// have no token scale; everything else must come from the theme.
|
||||
{ allow: ['layout', 'transition-*', 'ease-*'] },
|
||||
],
|
||||
'shadcn/no-inline-styles': 'warn',
|
||||
'shadcn/no-unknown-classes': 'warn',
|
||||
'shadcn/require-static-classes': 'warn',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
shadcn: {
|
||||
note: 'See frontend/DESIGN.md for tokens, component variants and approved exceptions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
{
|
||||
// The design-system components define the styles; they are not restyled.
|
||||
files: ['src/components/ui/**'],
|
||||
rules: {
|
||||
'shadcn/no-restyle': 'off',
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Third-party renderers and canvas-positioned nodes take computed
|
||||
// runtime values that Tailwind classes cannot express.
|
||||
files: [
|
||||
'src/components/MermaidRenderer.tsx',
|
||||
'src/agents/workflow/nodes/**',
|
||||
],
|
||||
rules: {
|
||||
'shadcn/no-inline-styles': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
Generated
+417
-4
@@ -48,6 +48,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@shadcn/lint": "^0.2.0",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/lodash": "^4.17.25",
|
||||
@@ -847,6 +848,329 @@
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm-eabi": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.148.0.tgz",
|
||||
"integrity": "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.148.0.tgz",
|
||||
"integrity": "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-arm64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.148.0.tgz",
|
||||
"integrity": "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-x64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.148.0.tgz",
|
||||
"integrity": "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-freebsd-x64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.148.0.tgz",
|
||||
"integrity": "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.148.0.tgz",
|
||||
"integrity": "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.148.0.tgz",
|
||||
"integrity": "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.148.0.tgz",
|
||||
"integrity": "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.148.0.tgz",
|
||||
"integrity": "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-x64-musl": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.148.0.tgz",
|
||||
"integrity": "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-openharmony-arm64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.148.0.tgz",
|
||||
"integrity": "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.148.0.tgz",
|
||||
"integrity": "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.148.0.tgz",
|
||||
"integrity": "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.148.0.tgz",
|
||||
"integrity": "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.149.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
|
||||
@@ -2786,6 +3110,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@shadcn/lint": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@shadcn/lint/-/lint-0.2.0.tgz",
|
||||
"integrity": "sha512-DTYr427BR8cDc0J0Y++u1Uh12NAYGmdPiqgy4wKSogi7RNEhFanIcfBMPRPUvkunt9QiJUfI7B28TW39MrPCFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.17.0",
|
||||
"cn": "0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oxc-parser": "^0.148.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": ">=8.40.0",
|
||||
"eslint": ">=9.30.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@typescript-eslint/parser": {
|
||||
"optional": true
|
||||
},
|
||||
"eslint": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@shadcn/react": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@shadcn/react/-/react-0.3.1.tgz",
|
||||
@@ -3703,7 +4056,6 @@
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3713,7 +4065,7 @@
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.3.0"
|
||||
@@ -4811,6 +5163,19 @@
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/cn": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/cn/-/cn-0.3.2.tgz",
|
||||
"integrity": "sha512-RKzY5Edavc8B2Ka6tjJecKFC4nm0p7lADY72xzICmiJSf4TkaxWXt0fHsib2P2G4FkFyXfSDi9oDtIBuCZvY2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cn": "bin/cn.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -4938,7 +5303,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
@@ -9848,6 +10212,55 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-parser": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.148.0.tgz",
|
||||
"integrity": "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.148.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxc-parser/binding-android-arm-eabi": "0.148.0",
|
||||
"@oxc-parser/binding-android-arm64": "0.148.0",
|
||||
"@oxc-parser/binding-darwin-arm64": "0.148.0",
|
||||
"@oxc-parser/binding-darwin-x64": "0.148.0",
|
||||
"@oxc-parser/binding-freebsd-x64": "0.148.0",
|
||||
"@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0",
|
||||
"@oxc-parser/binding-linux-arm-musleabihf": "0.148.0",
|
||||
"@oxc-parser/binding-linux-arm64-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-arm64-musl": "0.148.0",
|
||||
"@oxc-parser/binding-linux-ppc64-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-riscv64-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-riscv64-musl": "0.148.0",
|
||||
"@oxc-parser/binding-linux-s390x-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-x64-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-x64-musl": "0.148.0",
|
||||
"@oxc-parser/binding-openharmony-arm64": "0.148.0",
|
||||
"@oxc-parser/binding-win32-arm64-msvc": "0.148.0",
|
||||
"@oxc-parser/binding-win32-ia32-msvc": "0.148.0",
|
||||
"@oxc-parser/binding-win32-x64-msvc": "0.148.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-parser/node_modules/@oxc-project/types": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
|
||||
"integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -11746,7 +12159,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint ./src --ext .jsx,.js,.ts,.tsx",
|
||||
"lint-fix": "eslint ./src --ext .jsx,.js,.ts,.tsx --fix",
|
||||
"lint:design": "eslint ./src -f json | node scripts/design-lint-report.mjs",
|
||||
"format": "prettier ./src --write",
|
||||
"prepare": "cd .. && husky install frontend/.husky"
|
||||
},
|
||||
@@ -64,6 +65,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@shadcn/lint": "^0.2.0",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/lodash": "^4.17.25",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Summarise @shadcn/lint warnings from ESLint JSON output.
|
||||
*
|
||||
* npm run lint:design rules × counts, then the 25 worst files
|
||||
* npm run lint:design -- --rule no-raw-colors files for one rule
|
||||
* npm run lint:design -- --file src/settings/Tools.tsx every message in a file
|
||||
*
|
||||
* Reads ESLint's JSON from stdin; the npm script pipes it in.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (name) => {
|
||||
const i = args.indexOf(name);
|
||||
return i === -1 ? undefined : args[i + 1];
|
||||
};
|
||||
const onlyRule = opt('--rule');
|
||||
const onlyFile = opt('--file');
|
||||
|
||||
const input = readFileSync(0, 'utf8');
|
||||
const start = input.indexOf('[');
|
||||
const results = JSON.parse(input.slice(start));
|
||||
|
||||
const rows = [];
|
||||
for (const file of results) {
|
||||
const rel = file.filePath.replace(`${process.cwd()}/`, '');
|
||||
for (const m of file.messages) {
|
||||
if (!m.ruleId?.startsWith('shadcn/')) continue;
|
||||
rows.push({
|
||||
file: rel,
|
||||
rule: m.ruleId.slice(7),
|
||||
line: m.line,
|
||||
msg: m.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const count = (key) => {
|
||||
const c = new Map();
|
||||
for (const r of rows) c.set(r[key], (c.get(r[key]) ?? 0) + 1);
|
||||
return [...c.entries()].sort((a, b) => b[1] - a[1]);
|
||||
};
|
||||
const pad = (s, n) => String(s).padStart(n);
|
||||
|
||||
if (onlyFile) {
|
||||
const mine = rows.filter(
|
||||
(r) => r.file === onlyFile || r.file.endsWith(onlyFile),
|
||||
);
|
||||
console.log(`${mine.length} design warnings in ${onlyFile}\n`);
|
||||
for (const r of mine.sort((a, b) => a.line - b.line)) {
|
||||
console.log(
|
||||
`${pad(r.line, 5)} ${r.rule}\n ${r.msg.split(' See frontend/DESIGN.md')[0]}\n`,
|
||||
);
|
||||
}
|
||||
} else if (onlyRule) {
|
||||
const mine = rows.filter((r) => r.rule === onlyRule);
|
||||
console.log(`${mine.length} warnings for shadcn/${onlyRule}\n`);
|
||||
const byFile = new Map();
|
||||
for (const r of mine) byFile.set(r.file, (byFile.get(r.file) ?? 0) + 1);
|
||||
for (const [f, n] of [...byFile.entries()].sort((a, b) => b[1] - a[1]))
|
||||
console.log(`${pad(n, 5)} ${f}`);
|
||||
} else {
|
||||
console.log(
|
||||
`${rows.length} design warnings in ${new Set(rows.map((r) => r.file)).size} files\n`,
|
||||
);
|
||||
console.log('By rule:');
|
||||
for (const [rule, n] of count('rule')) console.log(`${pad(n, 5)} ${rule}`);
|
||||
console.log('\nWorst files:');
|
||||
for (const [file, n] of count('file').slice(0, 25))
|
||||
console.log(`${pad(n, 5)} ${file}`);
|
||||
console.log(
|
||||
'\nNext: npm run lint:design -- --file <path> or -- --rule <rule>',
|
||||
);
|
||||
}
|
||||
+40
-5
@@ -1,13 +1,17 @@
|
||||
import { envVar } from '@/env';
|
||||
import './locale/i18n';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import Admin from './admin';
|
||||
import Agents from './agents';
|
||||
import SharedAgentGate from './agents/SharedAgentGate';
|
||||
import { selectWorkflowPreviewOpen } from './agents/workflow/workflowPreviewSlice';
|
||||
import DocsGPTMark from './assets/logo-b.svg';
|
||||
import DocsGPTMarkWhite from './assets/logo-w.svg';
|
||||
import ActionButtons from './components/ActionButtons';
|
||||
@@ -15,6 +19,7 @@ import AdminRoute from './components/AdminRoute';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import Spinner from './components/Spinner';
|
||||
import { Button } from './components/ui/button';
|
||||
import { ToastViewport } from './components/ui/toast';
|
||||
import UploadToast from './components/UploadToast';
|
||||
import Conversation from './conversation/Conversation';
|
||||
import { SharedConversation } from './conversation/SharedConversation';
|
||||
@@ -26,11 +31,18 @@ import Navigation from './Navigation';
|
||||
import { getSectionForPath } from './navigation/sections';
|
||||
import { SidebarLevelProvider } from './navigation/SidebarLevelProvider';
|
||||
import PageNotFound from './PageNotFound';
|
||||
|
||||
// Dev-only style guide (see frontend/DESIGN.md). The DEV guard around the
|
||||
// import lets the bundler drop the chunk from production builds entirely.
|
||||
const DesignSystem = import.meta.env.DEV
|
||||
? lazy(() => import('./design/DesignSystem'))
|
||||
: null;
|
||||
import Setting from './settings';
|
||||
import Teams from './settings/Teams';
|
||||
import Notification from './components/Notification';
|
||||
import ToolApprovalToast from './notifications/ToolApprovalToast';
|
||||
import TeamNotificationToast from './notifications/TeamNotificationToast';
|
||||
import ActionToast from './notifications/ActionToast';
|
||||
|
||||
function AuthWrapper({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -64,7 +76,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={retryOidcLogin}
|
||||
className="rounded-3xl px-5"
|
||||
shape="pill"
|
||||
data-testid="oidc-signin"
|
||||
>
|
||||
{t('auth.signInWith', { provider: oidcProviderName || 'SSO' })}
|
||||
@@ -90,6 +102,10 @@ function MainLayout() {
|
||||
// the conversation now survives the trip, so "share" would target a chat
|
||||
// that isn't on screen.
|
||||
const inSection = Boolean(getSectionForPath(location.pathname));
|
||||
// The workflow Preview drawer occupies the right edge; move the toast
|
||||
// stack to the bottom-left while it's open so it stays visible without
|
||||
// covering the drawer's attach/send controls.
|
||||
const previewOpen = useSelector(selectWorkflowPreviewOpen);
|
||||
|
||||
return (
|
||||
<SidebarLevelProvider>
|
||||
@@ -109,9 +125,18 @@ function MainLayout() {
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<UploadToast />
|
||||
<ToolApprovalToast />
|
||||
<TeamNotificationToast />
|
||||
{/* The one toast stack (and live region) for the app. Each toast
|
||||
renders only its cards, top to bottom: team notifications, tool
|
||||
approvals, uploads, action results. */}
|
||||
<ToastViewport
|
||||
className={cn(previewOpen && 'right-auto left-4')}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TeamNotificationToast />
|
||||
<ToolApprovalToast />
|
||||
<UploadToast />
|
||||
<ActionToast />
|
||||
</ToastViewport>
|
||||
</div>
|
||||
</SidebarLevelProvider>
|
||||
);
|
||||
@@ -178,6 +203,16 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="/share/:identifier" element={<SharedConversation />} />
|
||||
<Route path="/shared/agent/:agentId" element={<SharedAgentGate />} />
|
||||
{DesignSystem && (
|
||||
<Route
|
||||
path="/design"
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<DesignSystem />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Route path="/*" element={<PageNotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
+12
-2
@@ -93,17 +93,27 @@ function HeroModelSelect() {
|
||||
disabled={!hasModels}
|
||||
>
|
||||
<SelectTrigger
|
||||
/* eslint-disable-next-line shadcn/no-restyle --
|
||||
The landing page's one focal control keeps its own look: a
|
||||
borderless muted pill at 16px whose menu joins it (DESIGN.md,
|
||||
Approved exceptions). */
|
||||
className="bg-muted dark:bg-card text-foreground hover:bg-muted dark:hover:bg-card w-full justify-between rounded-4xl border-0 px-6 py-4 text-base shadow-none data-[state=open]:rounded-b-none"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue placeholder="Select Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-muted dark:bg-card rounded-t-none rounded-b-4xl border-0 shadow-md data-[side=bottom]:translate-y-0">
|
||||
<SelectContent
|
||||
/* eslint-disable-next-line shadcn/no-restyle --
|
||||
The hero picker's menu hangs from its trigger as one muted
|
||||
shape (DESIGN.md, Approved exceptions). */
|
||||
className="bg-muted dark:bg-card rounded-t-none rounded-b-4xl border-0 shadow-md data-[side=bottom]:translate-y-0"
|
||||
>
|
||||
{hasModels ? (
|
||||
availableModels?.map((model: Model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
/* eslint-disable-next-line shadcn/no-restyle -- hero menu rows match its 16px trigger */
|
||||
className="px-5 py-3 text-base [&_[data-slot=select-item-indicator]]:right-5"
|
||||
>
|
||||
{model.display_name}
|
||||
@@ -169,7 +179,7 @@ export default function Hero({
|
||||
<p className="text-foreground mb-2 font-semibold">
|
||||
{demo.header}
|
||||
</p>
|
||||
<span className="line-clamp-2 text-gray-700 opacity-60 dark:text-gray-300">
|
||||
<span className="text-muted-foreground line-clamp-2">
|
||||
{demo.query}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
+102
-95
@@ -400,11 +400,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
onClick={() => setNavOpen(true)}
|
||||
aria-label="Open navigation menu"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<PanelLeftOpen className="size-5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
@@ -421,37 +420,34 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
{queries?.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
onClick={() => newChat()}
|
||||
aria-label="Start new chat"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
dispatch(setSelectedAgent(null));
|
||||
goToLevel(AGENTS_MANAGE_ROOT);
|
||||
}}
|
||||
aria-label={t('manageAgents')}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<LayoutGrid className="size-5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
{conversations?.data && conversations.data.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
aria-label={t('modals.searchConversations.searchPlaceholder')}
|
||||
title={t('modals.searchConversations.searchPlaceholder')}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<SearchIcon className="size-5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
@@ -459,11 +455,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
onClick={() => goToLevel('/settings')}
|
||||
aria-label={t('settings.label')}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<SettingsIcon className="size-5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
@@ -473,9 +468,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
!navOpen && '-ml-96 md:-ml-72'
|
||||
} bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white`}
|
||||
className={cn(
|
||||
'bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white',
|
||||
!navOpen && '-ml-96 md:-ml-72',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
@@ -493,9 +489,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
@@ -562,27 +558,29 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
{t('navigation.agents')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="agents-container">
|
||||
<div>
|
||||
<div>
|
||||
{recentAgents.map((agent, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 ${
|
||||
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-3 ${
|
||||
agent.id === selectedAgent?.id && !conversationId
|
||||
? 'bg-sidebar-accent'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handleAgentClick(agent)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex w-6 justify-center">
|
||||
<Avatar
|
||||
src={agent.image}
|
||||
alt="agent-logo"
|
||||
imgClassName="h-6 w-6 rounded-full object-contain"
|
||||
shape="circle"
|
||||
className="overflow-hidden"
|
||||
imgClassName="h-6 w-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||
{agent.name}
|
||||
</p>
|
||||
</div>
|
||||
@@ -593,6 +591,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
/* eslint-disable-next-line shadcn/no-restyle -- the pin is a bare 16px image on a sidebar row that already fills on hover; an accent fill would be invisible on it, so hover dims the icon instead */
|
||||
className="h-auto w-auto rounded-full p-0 hover:bg-transparent hover:opacity-75"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -611,63 +610,65 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<NavLink
|
||||
to={AGENTS_MANAGE_ROOT}
|
||||
end
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey)
|
||||
return;
|
||||
event.preventDefault();
|
||||
dispatch(setSelectedAgent(null));
|
||||
closeNavOnMobile();
|
||||
goToLevel(AGENTS_MANAGE_ROOT);
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 ${
|
||||
isActive ? 'bg-sidebar-accent' : ''
|
||||
}`
|
||||
}
|
||||
<Button
|
||||
variant="sidebar-item"
|
||||
asChild
|
||||
className="mx-4 my-auto mt-2 flex"
|
||||
>
|
||||
<div className="flex w-6 justify-center">
|
||||
<LayoutGrid
|
||||
className="text-muted-foreground size-5"
|
||||
strokeWidth={1.75}
|
||||
aria-label="manage-agents"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||
{t('manageAgents')}
|
||||
</p>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={AGENTS_MANAGE_ROOT}
|
||||
end
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey)
|
||||
return;
|
||||
event.preventDefault();
|
||||
dispatch(setSelectedAgent(null));
|
||||
closeNavOnMobile();
|
||||
goToLevel(AGENTS_MANAGE_ROOT);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-6 justify-center">
|
||||
<LayoutGrid
|
||||
className="text-muted-foreground size-5"
|
||||
strokeWidth={1.75}
|
||||
aria-label="manage-agents"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||
{t('manageAgents')}
|
||||
</p>
|
||||
</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NavLink
|
||||
to={AGENTS_MANAGE_ROOT}
|
||||
end
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey)
|
||||
return;
|
||||
event.preventDefault();
|
||||
closeNavOnMobile();
|
||||
dispatch(setSelectedAgent(null));
|
||||
goToLevel(AGENTS_MANAGE_ROOT);
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${
|
||||
isActive ? 'bg-sidebar-accent' : ''
|
||||
}`
|
||||
}
|
||||
<Button
|
||||
variant="sidebar-item"
|
||||
asChild
|
||||
className="mx-4 my-auto mt-2 flex"
|
||||
>
|
||||
<LayoutGrid
|
||||
className="text-muted-foreground size-5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
aria-label="manage-agents"
|
||||
/>
|
||||
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||
{t('manageAgents')}
|
||||
</p>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={AGENTS_MANAGE_ROOT}
|
||||
end
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey)
|
||||
return;
|
||||
event.preventDefault();
|
||||
closeNavOnMobile();
|
||||
dispatch(setSelectedAgent(null));
|
||||
goToLevel(AGENTS_MANAGE_ROOT);
|
||||
}}
|
||||
>
|
||||
<LayoutGrid
|
||||
className="text-muted-foreground size-5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
aria-label="manage-agents"
|
||||
/>
|
||||
<p className="text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||
{t('manageAgents')}
|
||||
</p>
|
||||
</NavLink>
|
||||
</Button>
|
||||
)}
|
||||
{conversations?.data && conversations.data.length > 0 ? (
|
||||
<div className="mt-7">
|
||||
@@ -677,10 +678,11 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-sidebar-accent mr-1 rounded-full"
|
||||
className="mr-1"
|
||||
aria-label={t(
|
||||
'modals.searchConversations.searchPlaceholder',
|
||||
)}
|
||||
@@ -693,7 +695,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="conversations-container">
|
||||
<div>
|
||||
{(conversations.data ?? []).map((conversation) => (
|
||||
<ConversationTile
|
||||
key={conversation.id}
|
||||
@@ -763,25 +765,30 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
inSection && 'hidden',
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
closeNavOnMobile();
|
||||
goToLevel('/settings');
|
||||
}}
|
||||
className="hover:bg-sidebar-accent mx-4 my-auto flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3"
|
||||
<Button
|
||||
variant="sidebar-item"
|
||||
asChild
|
||||
className="mx-4 my-auto flex"
|
||||
>
|
||||
<SettingsIcon
|
||||
className="text-muted-foreground size-5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
aria-label="Settings"
|
||||
/>
|
||||
<p className="text-foreground text-sm dark:text-white">
|
||||
{t('settings.label')}
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
closeNavOnMobile();
|
||||
goToLevel('/settings');
|
||||
}}
|
||||
>
|
||||
<SettingsIcon
|
||||
className="text-muted-foreground size-5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
aria-label="Settings"
|
||||
/>
|
||||
<p className="text-foreground text-sm dark:text-white">
|
||||
{t('settings.label')}
|
||||
</p>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-foreground flex flex-col justify-end dark:text-white">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
@@ -836,9 +843,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
<div className="relative flex h-full items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground ml-4 size-9 lg:hidden"
|
||||
className="ml-4 size-9 lg:hidden"
|
||||
onClick={() => setNavOpen(true)}
|
||||
aria-label="Toggle mobile menu"
|
||||
>
|
||||
|
||||
@@ -6,12 +6,12 @@ export default function PageNotFound() {
|
||||
|
||||
return (
|
||||
<div className="bg-background grid min-h-screen">
|
||||
<p className="text-foreground dark:bg-card mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl bg-gray-100 p-6 lg:p-10 xl:p-16">
|
||||
<p className="text-foreground bg-muted mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl p-6 lg:p-10 xl:p-16">
|
||||
<h1>{t('pageNotFound.title')}</h1>
|
||||
<p>{t('pageNotFound.message')}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="bg-primary hover:bg-primary/90 mr-4 inline-flex cursor-pointer items-center justify-center rounded-full px-4 py-2 text-white transition-colors duration-100"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground mr-4 inline-flex cursor-pointer items-center justify-center rounded-full px-4 py-2 transition-colors duration-100"
|
||||
>
|
||||
{t('pageNotFound.goHome')}
|
||||
</Link>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import adminService, {
|
||||
type ActivityFilters,
|
||||
} from '../api/services/adminService';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { MultiSelect } from '../components/ui/multi-select';
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
Loading,
|
||||
Pill,
|
||||
categoryTone,
|
||||
eventLabel,
|
||||
eventTone,
|
||||
@@ -75,7 +75,7 @@ function DetailRow({ label, value }: { label: string; value: unknown }) {
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
return (
|
||||
<div className="flex gap-2 text-[13px] break-all">
|
||||
<div className="flex gap-2 text-xs break-all">
|
||||
<span className="text-muted-foreground min-w-32 shrink-0">{label}</span>
|
||||
<span className="font-mono">{text}</span>
|
||||
</div>
|
||||
@@ -275,7 +275,7 @@ export default function Activity() {
|
||||
key={range.label}
|
||||
variant={range.days === rangeDays ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
shape="pill"
|
||||
onClick={() => withPageReset(setRangeDays)(range.days)}
|
||||
>
|
||||
{range.label}
|
||||
@@ -347,7 +347,7 @@ export default function Activity() {
|
||||
const repeat =
|
||||
idx > 0 && rows[idx - 1].actor_id === row.actor_id;
|
||||
return [
|
||||
<TableRow key={key} className="hover:bg-muted/40">
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
@@ -369,29 +369,29 @@ export default function Activity() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone={eventTone(row.event)}>
|
||||
<Badge variant={eventTone(row.event)}>
|
||||
{eventLabel(row.event)}
|
||||
</Pill>
|
||||
</Badge>
|
||||
{row.outcome ? (
|
||||
<Pill tone={outcomeTone(row.outcome)}>
|
||||
<Badge variant={outcomeTone(row.outcome)}>
|
||||
{outcomeLabel(row.outcome)}
|
||||
</Pill>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={categoryTone(row.category)}>
|
||||
<Badge variant={categoryTone(row.category)}>
|
||||
{row.category}
|
||||
</Pill>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`font-mono text-[13px] ${
|
||||
className={`font-mono text-xs ${
|
||||
repeat ? 'text-muted-foreground/50' : ''
|
||||
}`}
|
||||
>
|
||||
{repeat ? '〃' : (row.actor_id ?? '—')}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">
|
||||
<TableCell className="font-mono text-xs">
|
||||
{row.target_id && row.target_id !== row.actor_id
|
||||
? row.target_id
|
||||
: '—'}
|
||||
@@ -406,9 +406,7 @@ export default function Activity() {
|
||||
local
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-[13px]">
|
||||
{row.ip}
|
||||
</span>
|
||||
<span className="font-mono text-xs">{row.ip}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
@@ -419,7 +417,7 @@ export default function Activity() {
|
||||
</TableRow>,
|
||||
isOpen ? (
|
||||
<TableRow key={`${key}:detail`}>
|
||||
<TableCell colSpan={7} className="py-2">
|
||||
<TableCell colSpan={7}>
|
||||
<RowDetail row={row} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -23,8 +23,8 @@ describe('eventLabel', () => {
|
||||
|
||||
describe('eventTone', () => {
|
||||
it('flags removals and refusals', () => {
|
||||
expect(eventTone('oidc_login_denied')).toBe('danger');
|
||||
expect(eventTone('agent.deleted')).toBe('danger');
|
||||
expect(eventTone('oidc_login_denied')).toBe('destructive');
|
||||
expect(eventTone('agent.deleted')).toBe('destructive');
|
||||
});
|
||||
|
||||
it('flags revocations as a warning, not a failure', () => {
|
||||
@@ -32,18 +32,22 @@ describe('eventTone', () => {
|
||||
expect(eventTone('agent.key_regenerated')).toBe('warning');
|
||||
});
|
||||
|
||||
it('marks an admin grant with the brand tone', () => {
|
||||
expect(eventTone('role_granted')).toBe('brand');
|
||||
it('marks an admin grant with the brand (default) variant', () => {
|
||||
expect(eventTone('role_granted')).toBe('default');
|
||||
});
|
||||
|
||||
it('leaves routine events muted', () => {
|
||||
expect(eventTone('oidc_login')).toBe('muted');
|
||||
expect(eventTone('anything_else')).toBe('muted');
|
||||
expect(eventTone('oidc_login')).toBe('neutral');
|
||||
expect(eventTone('anything_else')).toBe('neutral');
|
||||
});
|
||||
});
|
||||
|
||||
describe('categoryTone', () => {
|
||||
it('has a tone for every category the API declares', () => {
|
||||
it('has a variant for every category the API declares', () => {
|
||||
expect(categoryTone('identity')).toBe('outline');
|
||||
expect(categoryTone('access')).toBe('default');
|
||||
expect(categoryTone('safety')).toBe('destructive');
|
||||
expect(categoryTone('other')).toBe('neutral');
|
||||
for (const category of [
|
||||
'identity',
|
||||
'access',
|
||||
@@ -58,7 +62,7 @@ describe('categoryTone', () => {
|
||||
});
|
||||
|
||||
it('falls back for an unknown category', () => {
|
||||
expect(categoryTone('invented')).toBe('muted');
|
||||
expect(categoryTone('invented')).toBe('neutral');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,11 +70,11 @@ describe('outcomeTone', () => {
|
||||
// The values below are the only ones the writers produce: guardrails emit
|
||||
// triggered/not_evaluated, the device feed emits dispatched.
|
||||
it('flags a guardrail that fired', () => {
|
||||
expect(outcomeTone('triggered')).toBe('danger');
|
||||
expect(outcomeTone('triggered')).toBe('destructive');
|
||||
});
|
||||
|
||||
it('leaves a check that never ran neutral', () => {
|
||||
expect(outcomeTone('not_evaluated')).toBe('muted');
|
||||
expect(outcomeTone('not_evaluated')).toBe('neutral');
|
||||
});
|
||||
|
||||
it('reads a dispatched device command as a success', () => {
|
||||
@@ -78,7 +82,7 @@ describe('outcomeTone', () => {
|
||||
});
|
||||
|
||||
it('leaves an unfamiliar outcome muted', () => {
|
||||
expect(outcomeTone('something-new')).toBe('muted');
|
||||
expect(outcomeTone('something-new')).toBe('neutral');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
import Spinner from '../components/Spinner';
|
||||
import type { badgeVariants } from '../components/ui/badge';
|
||||
import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils';
|
||||
|
||||
export function Loading() {
|
||||
@@ -37,36 +40,8 @@ export function StatCard({
|
||||
);
|
||||
}
|
||||
|
||||
type Tone = 'default' | 'success' | 'danger' | 'muted' | 'brand' | 'warning';
|
||||
|
||||
const TONES: Record<Tone, string> = {
|
||||
default: 'bg-muted text-foreground',
|
||||
success:
|
||||
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
// brand violet — matches the chart's --primary; used for the Admin role.
|
||||
brand:
|
||||
'bg-[#7D54D1]/15 text-[#7D54D1] dark:bg-violet-900/40 dark:text-violet-300',
|
||||
warning:
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
};
|
||||
|
||||
export function Pill({
|
||||
tone = 'default',
|
||||
children,
|
||||
}: {
|
||||
tone?: Tone;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${TONES[tone]}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Audit-feed pills render as Badge; these helpers pick its variant.
|
||||
type Tone = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
|
||||
|
||||
export function fmtDate(value?: string | null): string {
|
||||
return value ? formatDateTime(value) : '—';
|
||||
@@ -202,25 +177,25 @@ const WARNING_EVENTS = new Set([
|
||||
]);
|
||||
|
||||
export function eventTone(event: string): Tone {
|
||||
if (DANGER_EVENTS.has(event)) return 'danger';
|
||||
if (event === 'role_granted') return 'brand';
|
||||
if (DANGER_EVENTS.has(event)) return 'destructive';
|
||||
if (event === 'role_granted') return 'default';
|
||||
if (WARNING_EVENTS.has(event)) return 'warning';
|
||||
return 'muted';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
// Category facet colors. Mirrors docsgpt/audit_events.py.
|
||||
const CATEGORY_TONES: Record<string, Tone> = {
|
||||
identity: 'default',
|
||||
access: 'brand',
|
||||
identity: 'outline',
|
||||
access: 'default',
|
||||
config: 'warning',
|
||||
data: 'success',
|
||||
device: 'warning',
|
||||
safety: 'danger',
|
||||
other: 'muted',
|
||||
safety: 'destructive',
|
||||
other: 'neutral',
|
||||
};
|
||||
|
||||
export function categoryTone(category: string): Tone {
|
||||
return CATEGORY_TONES[category] ?? 'muted';
|
||||
return CATEGORY_TONES[category] ?? 'neutral';
|
||||
}
|
||||
|
||||
// Outcome values the two side journals actually write.
|
||||
@@ -232,13 +207,13 @@ export function categoryTone(category: string): Tone {
|
||||
// produced, so every outcome rendered neutral grey -- including a guardrail
|
||||
// that fired, which is the one signal the merged feed exists to surface.
|
||||
const OUTCOME_TONES: Record<string, Tone> = {
|
||||
triggered: 'danger',
|
||||
not_evaluated: 'muted',
|
||||
triggered: 'destructive',
|
||||
not_evaluated: 'neutral',
|
||||
dispatched: 'success',
|
||||
};
|
||||
|
||||
export function outcomeTone(outcome: string): Tone {
|
||||
return OUTCOME_TONES[outcome] ?? 'muted';
|
||||
return OUTCOME_TONES[outcome] ?? 'neutral';
|
||||
}
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import adminService from '../api/services/adminService';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { Loading, Pill, fmtDate } from './AdminUI';
|
||||
import { Loading, fmtDate } from './AdminUI';
|
||||
|
||||
type Admin = { user_id: string; granted_at?: string; sources?: string[] };
|
||||
|
||||
@@ -69,9 +70,9 @@ export default function Admins() {
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(a.sources ?? []).map((s) => (
|
||||
<Pill key={s} tone="muted">
|
||||
<Badge key={s} variant="neutral">
|
||||
{s}
|
||||
</Pill>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -88,16 +88,14 @@ export default function Overview() {
|
||||
<div
|
||||
className={`rounded-2xl border px-6 py-5 ${
|
||||
failed > 0
|
||||
? 'border-red-300 bg-red-50 dark:border-red-900/50 dark:bg-red-950/30'
|
||||
? 'border-destructive/50 bg-destructive/10'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">Failed logins (7d)</p>
|
||||
<p
|
||||
className={`mt-1 text-2xl font-bold tabular-nums ${
|
||||
failed > 0
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-foreground'
|
||||
failed > 0 ? 'text-destructive' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{fmtNumber(failed)}
|
||||
@@ -105,7 +103,7 @@ export default function Overview() {
|
||||
{failed > 0 ? (
|
||||
<Link
|
||||
to="/admin/audit?event=oidc_login_denied"
|
||||
className="mt-2 inline-block text-xs text-red-600 hover:underline dark:text-red-400"
|
||||
className="text-destructive mt-2 inline-block text-xs hover:underline"
|
||||
>
|
||||
View in Audit →
|
||||
</Link>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
|
||||
import adminService, { type QuotaScope } from '../api/services/adminService';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Progress } from '../components/ui/progress';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -45,11 +46,7 @@ export function UsageBar({
|
||||
const fmt = (n: number) => (kind === 'cost' ? fmtUsd(n) : fmtNumber(n));
|
||||
const percent = usagePercent(budget.used, budget.limit);
|
||||
const tone =
|
||||
percent >= 100
|
||||
? 'bg-red-500'
|
||||
: percent >= 80
|
||||
? 'bg-amber-500'
|
||||
: 'bg-[#7D54D1]';
|
||||
percent >= 100 ? 'destructive' : percent >= 80 ? 'warning' : 'default';
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
@@ -60,19 +57,16 @@ export function UsageBar({
|
||||
</span>
|
||||
</div>
|
||||
{budget.limit !== null ? (
|
||||
<div
|
||||
className="bg-muted mt-1 h-1.5 w-full overflow-hidden rounded-full"
|
||||
role="progressbar"
|
||||
<Progress
|
||||
className="mt-1"
|
||||
size="sm"
|
||||
variant={tone}
|
||||
value={percent}
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(percent)}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full ${tone}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
) : null}
|
||||
{caption ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">{caption}</p>
|
||||
@@ -197,7 +191,7 @@ export default function QuotaEditor({
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-xs">{inheritHint}</p>
|
||||
{policy && !policy.enabled ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
<p className="text-warning text-xs">
|
||||
This policy is disabled and is not enforced. Saving keeps it disabled.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -231,7 +225,7 @@ export default function QuotaEditor({
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
|
||||
|
||||
import adminService, { type QuotaScope } from '../api/services/adminService';
|
||||
import teamsService from '../api/services/teamsService';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import {
|
||||
@@ -22,14 +23,7 @@ import {
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
LoadError,
|
||||
Loading,
|
||||
Pill,
|
||||
fmtDate,
|
||||
fmtNumber,
|
||||
fmtRelative,
|
||||
} from './AdminUI';
|
||||
import { LoadError, Loading, fmtDate, fmtNumber, fmtRelative } from './AdminUI';
|
||||
import QuotaEditor from './QuotaEditor';
|
||||
import { describeBudget, type QuotaPolicy } from './quotaUtils';
|
||||
|
||||
@@ -118,8 +112,10 @@ export default function Quotas() {
|
||||
|
||||
const bucketPill = (policy: QuotaPolicy) => (
|
||||
<>
|
||||
{isAll(policy) ? null : <Pill tone="muted">{policy.bucket} traffic</Pill>}
|
||||
{policy.enabled ? null : <Pill tone="muted">Disabled</Pill>}
|
||||
{isAll(policy) ? null : (
|
||||
<Badge variant="neutral">{policy.bucket} traffic</Badge>
|
||||
)}
|
||||
{policy.enabled ? null : <Badge variant="neutral">Disabled</Badge>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -132,7 +128,7 @@ export default function Quotas() {
|
||||
</p>
|
||||
|
||||
{(data.unpriced_models ?? []).length > 0 ? (
|
||||
<div className="rounded-2xl border border-amber-300 bg-amber-50 px-5 py-4 text-sm dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<div className="border-warning/50 bg-warning/10 rounded-2xl border px-5 py-4 text-sm">
|
||||
<p className="text-foreground font-medium">
|
||||
Models without a price are invisible to cost limits
|
||||
</p>
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function Usage() {
|
||||
key={range}
|
||||
variant={range === days ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
shape="pill"
|
||||
onClick={() => setDays(range)}
|
||||
>
|
||||
{range}d
|
||||
@@ -130,7 +130,7 @@ export default function Usage() {
|
||||
<Button
|
||||
variant={metric === 'tokens' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
shape="pill"
|
||||
onClick={() => setMetric('tokens')}
|
||||
>
|
||||
Tokens
|
||||
@@ -138,7 +138,7 @@ export default function Usage() {
|
||||
<Button
|
||||
variant={metric === 'cost' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
shape="pill"
|
||||
onClick={() => setMetric('cost')}
|
||||
>
|
||||
Cost
|
||||
@@ -221,8 +221,8 @@ export default function Usage() {
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topUsers.map((user) => (
|
||||
<TableRow key={user.user_id} className="hover:bg-muted/40">
|
||||
<TableCell className="font-mono text-[13px] break-all">
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell className="font-mono text-xs break-all">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus-visible:ring-ring cursor-pointer rounded text-left underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
||||
|
||||
@@ -74,7 +74,7 @@ function SplitTable({
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell className="text-[13px] break-all">
|
||||
<TableCell className="text-xs break-all">
|
||||
{labels?.[row.key] ?? row.key}
|
||||
</TableCell>
|
||||
<TableCell align="right" className="tabular-nums">
|
||||
@@ -170,7 +170,7 @@ export default function UserUsageModal({
|
||||
key={range}
|
||||
variant={range === days ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
shape="pill"
|
||||
onClick={() => setDays(range)}
|
||||
>
|
||||
{range}d
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import adminService from '../api/services/adminService';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,10 +33,10 @@ import {
|
||||
} from '../components/ui/table';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
Loading,
|
||||
Pill,
|
||||
eventLabel,
|
||||
fmtDateShort,
|
||||
fmtNumber,
|
||||
@@ -62,6 +63,7 @@ type Action = {
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function Users() {
|
||||
const dispatch = useDispatch();
|
||||
const token = useSelector(selectToken);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [adminIds, setAdminIds] = useState<Set<string>>(new Set());
|
||||
@@ -75,10 +77,6 @@ export default function Users() {
|
||||
const [detail, setDetail] = useState<any | null>(null);
|
||||
const [usageFor, setUsageFor] = useState<string | null>(null);
|
||||
const [quotaUserId, setQuotaUserId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [confirm, setConfirm] = useState<{
|
||||
message: string;
|
||||
submitLabel: string;
|
||||
@@ -112,12 +110,15 @@ export default function Users() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Auto-dismiss the inline feedback banner.
|
||||
useEffect(() => {
|
||||
if (!feedback) return;
|
||||
const id = setTimeout(() => setFeedback(null), 4500);
|
||||
return () => clearTimeout(id);
|
||||
}, [feedback]);
|
||||
// The result shows as a toast in the app's shared ToastViewport
|
||||
// (ActionToast), which auto-dismisses it.
|
||||
const setFeedback = (feedback: { ok: boolean; message: string }) =>
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: feedback.ok ? 'success' : 'destructive',
|
||||
message: feedback.message,
|
||||
}),
|
||||
);
|
||||
|
||||
const run =
|
||||
(fn: () => Promise<Response>, userId: string, successMsg: string) =>
|
||||
@@ -249,18 +250,6 @@ export default function Users() {
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{feedback ? (
|
||||
<div
|
||||
className={`mb-4 rounded-xl border px-4 py-2 text-sm ${
|
||||
feedback.ok
|
||||
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-950/30 dark:text-green-300'
|
||||
: 'border-red-300 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{feedback.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Filter by user id"
|
||||
@@ -313,12 +302,11 @@ export default function Users() {
|
||||
return (
|
||||
<TableRow
|
||||
key={u.user_id}
|
||||
className="hover:bg-muted/40 cursor-pointer"
|
||||
onClick={() => openDetail(u.user_id)}
|
||||
>
|
||||
<TableCell className="max-w-[280px]">
|
||||
<span
|
||||
className="block truncate font-mono text-[13px]"
|
||||
className="block truncate font-mono text-xs"
|
||||
title={u.user_id}
|
||||
>
|
||||
{u.user_id}
|
||||
@@ -326,9 +314,11 @@ export default function Users() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{isAdmin ? <Pill tone="brand">Admin</Pill> : null}
|
||||
{isAdmin ? (
|
||||
<Badge variant="default">Admin</Badge>
|
||||
) : null}
|
||||
{!u.active ? (
|
||||
<Pill tone="danger">Inactive</Pill>
|
||||
<Badge variant="destructive">Inactive</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -356,10 +346,10 @@ export default function Users() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className="text-muted-foreground hover:text-foreground h-[35px] w-7"
|
||||
className="h-[35px] w-7"
|
||||
aria-label="User actions"
|
||||
>
|
||||
<img
|
||||
@@ -473,7 +463,7 @@ export default function Users() {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Close the detail dialog before any confirm dialog opens
|
||||
// (avoids stacked modals); the list + banner reflect the result.
|
||||
// (avoids stacked modals); the list + toast reflect the result.
|
||||
setDetail(null);
|
||||
act.perform();
|
||||
}}
|
||||
@@ -494,14 +484,17 @@ export default function Users() {
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(detail.roles ?? []).map((r: string) => (
|
||||
<Pill key={r} tone={r === 'admin' ? 'brand' : 'muted'}>
|
||||
<Badge
|
||||
key={r}
|
||||
variant={r === 'admin' ? 'default' : 'neutral'}
|
||||
>
|
||||
{r}
|
||||
</Pill>
|
||||
</Badge>
|
||||
))}
|
||||
{detail.user?.active ? (
|
||||
<Pill tone="success">Active</Pill>
|
||||
<Badge variant="success">Active</Badge>
|
||||
) : (
|
||||
<Pill tone="danger">Inactive</Pill>
|
||||
<Badge variant="destructive">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import Trash from '../assets/red-trash.svg';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
import UnPin from '../assets/unpin.svg';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -388,7 +389,7 @@ export default function AgentCard({
|
||||
role={agent.status === 'published' ? 'button' : undefined}
|
||||
tabIndex={agent.status === 'published' ? 0 : undefined}
|
||||
aria-label={agent.status === 'published' ? agent.name : undefined}
|
||||
className={`bg-muted hover:bg-accent focus-visible:ring-ring/50 focus-visible:border-ring relative flex h-44 flex-col justify-between rounded-2xl px-4 py-5 outline-none focus-visible:ring-[3px] sm:w-48 sm:px-6 ${agent.status === 'published' && 'cursor-pointer'}`}
|
||||
className={`bg-muted hover:bg-accent focus-visible:ring-ring/50 focus-visible:border-ring relative flex h-44 flex-col justify-between rounded-2xl px-4 py-5 outline-none focus-visible:ring-3 sm:w-48 sm:px-6 ${agent.status === 'published' && 'cursor-pointer'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
@@ -453,19 +454,21 @@ export default function AgentCard({
|
||||
{/* Team access badge — pinned to the top row, left of the ⋯ menu
|
||||
(right-11 clears the 19px trigger at right-4) so the two align. */}
|
||||
{agent.ownership === 'team' && (
|
||||
<span className="bg-muted dark:bg-accent text-muted-foreground absolute top-4 right-11 z-10 flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
<Badge variant="neutral" className="absolute top-4 right-11 z-10">
|
||||
<Users size={11} strokeWidth={2} aria-hidden="true" />
|
||||
{agent.team_access === 'editor'
|
||||
? t('agents.teamBadge.editor')
|
||||
: t('agents.teamBadge.viewer')}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center gap-1 px-1">
|
||||
<Avatar
|
||||
src={agent.image}
|
||||
alt={`${agent.name}`}
|
||||
imgClassName="h-7 w-7 rounded-full object-contain"
|
||||
size="sm"
|
||||
shape="circle"
|
||||
imgClassName="h-7 w-7 object-contain"
|
||||
/>
|
||||
{agent.status === 'draft' && (
|
||||
<p className="text-foreground text-xs opacity-50">
|
||||
@@ -508,7 +511,7 @@ export default function AgentCard({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setExportError(null)}
|
||||
className="rounded-3xl px-5"
|
||||
shape="pill"
|
||||
>
|
||||
{t('agents.close')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import AgentPageHeader from './AgentPageHeader';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('AgentPageHeader sub-nav', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders the tabs as underline tab buttons, marking the current page', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentPageHeader
|
||||
agentId="a1"
|
||||
agentName="Renewals"
|
||||
currentPage="logs"
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const nav = container.querySelector(
|
||||
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
|
||||
);
|
||||
const tabs = Array.from(nav?.children ?? []);
|
||||
expect(tabs).toHaveLength(3);
|
||||
for (const tab of tabs) {
|
||||
expect(tab.getAttribute('data-variant')).toBe('tab');
|
||||
expect(tab.getAttribute('data-size')).toBe('inline');
|
||||
}
|
||||
const current = tabs[1];
|
||||
expect(current.tagName).toBe('SPAN');
|
||||
expect(current.getAttribute('aria-current')).toBe('page');
|
||||
expect(current.getAttribute('data-active')).toBe('true');
|
||||
expect(tabs[0].tagName).toBe('A');
|
||||
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
@@ -100,7 +101,7 @@ export default function AgentPageHeader({
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{currentPage === 'overview' ? (
|
||||
<BreadcrumbPage title={displayName} className="w-[16ch] truncate">
|
||||
<BreadcrumbPage title={displayName} className="w-[16ch]">
|
||||
{displayName}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
@@ -133,35 +134,31 @@ export default function AgentPageHeader({
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentPage;
|
||||
// Always render a 2px bottom border so row height stays constant
|
||||
// between active/inactive; only the color changes.
|
||||
const baseClasses =
|
||||
'whitespace-nowrap border-b-2 pb-1 text-sm font-medium transition-colors';
|
||||
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
|
||||
if (isActive) {
|
||||
return (
|
||||
<span
|
||||
<Button
|
||||
key={tab.id}
|
||||
aria-current="page"
|
||||
className={cn(
|
||||
baseClasses,
|
||||
'border-primary text-foreground -mb-px',
|
||||
)}
|
||||
asChild
|
||||
variant="tab"
|
||||
size="inline"
|
||||
data-active
|
||||
className="-mb-px"
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
<span aria-current="page">{tab.label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
<Button
|
||||
key={tab.id}
|
||||
to={tab.href}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
'text-muted-foreground hover:text-foreground hover:border-border/60 -mb-px border-transparent',
|
||||
)}
|
||||
asChild
|
||||
variant="tab"
|
||||
size="inline"
|
||||
className="-mb-px"
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
<Link to={tab.href}>{tab.label}</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -189,8 +189,8 @@ export default function AgentsList() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
label={t('agents.searchPlaceholder')}
|
||||
labelBgClassName="bg-background"
|
||||
className="rounded-full"
|
||||
labelSurface="background"
|
||||
shape="pill"
|
||||
leftIcon={
|
||||
<Search
|
||||
className="text-muted-foreground size-4"
|
||||
@@ -432,7 +432,7 @@ function AgentSection({
|
||||
{config.showNewAgentButton && (
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-full text-white"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
setModalFolderId(null);
|
||||
setShowAgentTypeModal(true);
|
||||
@@ -453,6 +453,9 @@ function AgentSection({
|
||||
// Drilling into a folder is a trail, not a back button — the
|
||||
// sidebar's back is the only thing that means "leave".
|
||||
<Breadcrumb>
|
||||
{/* eslint-disable-next-line shadcn/no-restyle -- the folder
|
||||
trail stands in for the section <h2>, so it keeps heading
|
||||
typography (DESIGN.md Approved exceptions). */}
|
||||
<BreadcrumbList className="text-foreground gap-2 text-lg font-semibold sm:gap-2">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
@@ -469,7 +472,9 @@ function AgentSection({
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{index === breadcrumbItems.length - 1 ? (
|
||||
<BreadcrumbPage>{item.name}</BreadcrumbPage>
|
||||
<BreadcrumbPage className="max-w-[32ch]">
|
||||
{item.name}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<button
|
||||
@@ -525,7 +530,8 @@ function AgentSection({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="bg-card shrink-0 rounded-full whitespace-nowrap"
|
||||
shape="pill"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
setIsCreatingFolder(true);
|
||||
setTimeout(() => newFolderInputRef.current?.focus(), 0);
|
||||
@@ -538,7 +544,8 @@ function AgentSection({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="bg-card shrink-0 rounded-full whitespace-nowrap"
|
||||
shape="pill"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
>
|
||||
{t('agents.importAgent')}
|
||||
@@ -547,7 +554,8 @@ function AgentSection({
|
||||
{config.showNewAgentButton && (
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full whitespace-nowrap text-white"
|
||||
shape="pill"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
setModalFolderId(currentFolderId);
|
||||
setShowAgentTypeModal(true);
|
||||
@@ -610,7 +618,8 @@ function AgentSection({
|
||||
{config.showNewAgentButton && !currentFolderId && (
|
||||
<Button
|
||||
type="button"
|
||||
className="ml-2 rounded-full text-white"
|
||||
shape="pill"
|
||||
className="ml-2"
|
||||
onClick={() => {
|
||||
setModalFolderId(currentFolderId);
|
||||
setShowAgentTypeModal(true);
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function FolderCard({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isExpanded}
|
||||
className={`focus-visible:ring-ring/50 focus-visible:border-ring relative flex cursor-pointer items-center justify-between rounded-2xl px-4 py-3 outline-none focus-visible:ring-[3px] sm:w-48 ${
|
||||
className={`focus-visible:ring-ring/50 focus-visible:border-ring relative flex cursor-pointer items-center justify-between rounded-2xl px-4 py-3 outline-none focus-visible:ring-3 sm:w-48 ${
|
||||
isExpanded ? 'bg-accent' : 'bg-muted hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => onToggleExpand(folder.id)}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mockState = {
|
||||
preference: {
|
||||
token: null,
|
||||
sourceDocs: [],
|
||||
selectedAgent: null,
|
||||
prompts: [],
|
||||
agentFolders: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) => selector(mockState),
|
||||
useDispatch: () => vi.fn(),
|
||||
}));
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getUserTools: () =>
|
||||
jsonResponse({
|
||||
tools: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'remote_device',
|
||||
display_name: 'Laptop',
|
||||
config: { device_id: 'device-1' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
getAgentFolders: () => jsonResponse({ folders: [] }),
|
||||
getAgent: () => jsonResponse({}),
|
||||
createAgent: () => jsonResponse({ message: 'Name is taken' }, false),
|
||||
updateAgent: () => jsonResponse({}),
|
||||
deleteAgent: () => jsonResponse({}),
|
||||
createPrompt: () => jsonResponse({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/devicesService', () => ({
|
||||
default: {
|
||||
list: () =>
|
||||
Promise.resolve({
|
||||
devices: [{ id: 'device-1', last_seen_at: new Date().toISOString() }],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/modelService', () => ({
|
||||
default: {
|
||||
getModels: () => jsonResponse({ models: [] }),
|
||||
transformModels: () => [],
|
||||
},
|
||||
}));
|
||||
|
||||
// The pickers render only their trigger here, plus each item's rich
|
||||
// description so the device status pill can be checked.
|
||||
vi.mock('../components/MultiSelectPopover', () => ({
|
||||
MultiSelectPopover: ({
|
||||
trigger,
|
||||
items,
|
||||
}: {
|
||||
trigger: React.ReactNode;
|
||||
items: MultiSelectPopoverItem[];
|
||||
}) => (
|
||||
<div data-testid="picker">
|
||||
{trigger}
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>{item.descriptionNode}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./workflow/WorkflowBuilder', () => ({ default: () => null }));
|
||||
vi.mock('./AgentPreview', () => ({ default: () => null }));
|
||||
vi.mock('../settings/Prompts', () => ({ default: () => null }));
|
||||
vi.mock('./components/GuardrailsSection', () => ({
|
||||
default: () => null,
|
||||
guardrailsIncomplete: () => false,
|
||||
}));
|
||||
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
||||
vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPills', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPageHeader', () => ({
|
||||
CurrentSectionHeader: () => null,
|
||||
}));
|
||||
vi.mock('../components/FileUpload', () => ({ FileUpload: () => null }));
|
||||
vi.mock('../components/SourcesPopoverFooter', () => ({ default: () => null }));
|
||||
vi.mock('../components/ToolIcon', () => ({ default: () => null }));
|
||||
|
||||
import NewAgent from './NewAgent';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const setNativeValue = (
|
||||
el: HTMLInputElement | HTMLTextAreaElement,
|
||||
value: string,
|
||||
) => {
|
||||
const proto =
|
||||
el instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
};
|
||||
|
||||
describe('NewAgent form', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<NewAgent mode="new" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const buttonByText = (text: string) =>
|
||||
Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes(text),
|
||||
)!;
|
||||
|
||||
// Decision 63 (3): every field, picker and button in the form is 42px.
|
||||
it('renders the name field as a default-size (42px) pill Input', async () => {
|
||||
await render();
|
||||
const name = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="agents.form.placeholders.agentName"]',
|
||||
)!;
|
||||
expect(name.getAttribute('data-slot')).toBe('input');
|
||||
expect(name.getAttribute('data-size')).toBe('default');
|
||||
expect(name.getAttribute('data-shape')).toBe('pill');
|
||||
});
|
||||
|
||||
it('renders the Add prompt button at the field height', async () => {
|
||||
await render();
|
||||
const add = buttonByText('agents.form.buttons.add');
|
||||
expect(add.getAttribute('data-variant')).toBe('outline-primary');
|
||||
expect(add.getAttribute('data-size')).toBe('field');
|
||||
expect(add.getAttribute('data-shape')).toBe('pill');
|
||||
});
|
||||
|
||||
it('renders the token and request limits as default-size pill Inputs', async () => {
|
||||
await render();
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sections.advanced').click(),
|
||||
);
|
||||
for (const key of ['enterTokenLimit', 'enterRequestLimit']) {
|
||||
const field = container.querySelector(
|
||||
`input[placeholder="agents.form.placeholders.${key}"]`,
|
||||
)!;
|
||||
expect(field.getAttribute('data-size')).toBe('default');
|
||||
expect(field.getAttribute('data-shape')).toBe('pill');
|
||||
}
|
||||
});
|
||||
|
||||
// Decision 64: the Advanced header is a section-toggle with a leading
|
||||
// lucide chevron; the panel draws the focus ring.
|
||||
it('renders the Advanced header as a section-toggle', async () => {
|
||||
await render();
|
||||
const toggle = buttonByText('agents.form.sections.advanced');
|
||||
expect(toggle.getAttribute('data-variant')).toBe('section-toggle');
|
||||
expect(toggle.getAttribute('data-size')).toBe('sm');
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
const chevron = toggle.firstElementChild!;
|
||||
expect(chevron.matches('svg.lucide-chevron-right')).toBe(true);
|
||||
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
|
||||
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
|
||||
expect(toggle.querySelector('h2')).toBeNull();
|
||||
expect(toggle.parentElement!.tagName).toBe('H2');
|
||||
const panel = toggle.closest('.bg-card')!;
|
||||
expect(panel.className).toContain(
|
||||
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
|
||||
);
|
||||
|
||||
await act(async () => toggle.click());
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(chevron.getAttribute('class')).toContain('rotate-90');
|
||||
});
|
||||
|
||||
it('renders the description as a large ui Textarea', async () => {
|
||||
await render();
|
||||
const description = container.querySelector(
|
||||
'textarea[placeholder="agents.form.placeholders.describeAgent"]',
|
||||
)!;
|
||||
expect(description.getAttribute('data-slot')).toBe('textarea');
|
||||
expect(description.getAttribute('data-size')).toBe('lg');
|
||||
});
|
||||
|
||||
it('renders the pickers as pill comboboxes, muted while empty', async () => {
|
||||
await render();
|
||||
const triggers = Array.from(
|
||||
container.querySelectorAll('[data-testid="picker"] > button'),
|
||||
);
|
||||
// Sources, tools, models.
|
||||
expect(triggers).toHaveLength(3);
|
||||
for (const trigger of triggers) {
|
||||
expect(trigger.getAttribute('data-variant')).toBe('combobox');
|
||||
expect(trigger.getAttribute('data-size')).toBe('field');
|
||||
expect(trigger.getAttribute('data-shape')).toBe('pill');
|
||||
expect(trigger.hasAttribute('data-placeholder')).toBe(true);
|
||||
expect(trigger.querySelector('span.truncate')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the device status as a Badge', async () => {
|
||||
await render();
|
||||
const badge = container.querySelector('[data-slot="badge"]');
|
||||
expect(badge?.textContent).toBe('settings.devices.online');
|
||||
expect(badge?.getAttribute('data-variant')).toBe('success');
|
||||
});
|
||||
|
||||
it('renders Save draft and Publish as pill button variants', async () => {
|
||||
await render();
|
||||
const draft = buttonByText('agents.form.buttons.saveDraft');
|
||||
expect(draft.getAttribute('data-variant')).toBe('outline-primary');
|
||||
expect(draft.getAttribute('data-shape')).toBe('pill');
|
||||
const publish = buttonByText('agents.form.buttons.publish');
|
||||
expect(publish.getAttribute('data-variant')).toBe('default');
|
||||
expect(publish.getAttribute('data-shape')).toBe('pill');
|
||||
expect(publish.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a failed save as a destructive alert with an icon', async () => {
|
||||
await render();
|
||||
const draft = buttonByText('agents.form.buttons.saveDraft');
|
||||
await act(async () => draft.click());
|
||||
const alert = container.querySelector('[role="alert"]')!;
|
||||
expect(alert.textContent).toContain('Name is taken');
|
||||
// ui/alert's destructive variant.
|
||||
expect(alert.className).toContain('border-destructive/50');
|
||||
expect(alert.className).toContain('bg-destructive/10');
|
||||
expect(alert.querySelector('svg.lucide-circle-x')).not.toBeNull();
|
||||
// Decision 69 (b): the notice sits above the form panel, not in the
|
||||
// header's button row.
|
||||
expect(draft.parentElement?.contains(alert)).toBe(false);
|
||||
});
|
||||
|
||||
it('marks JSON schema validity with token colours and lucide icons', async () => {
|
||||
await render();
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sections.advanced').click(),
|
||||
);
|
||||
const schema = Array.from(container.querySelectorAll('textarea')).find(
|
||||
(el) => el.className.includes('font-mono'),
|
||||
)!;
|
||||
expect(schema.getAttribute('data-slot')).toBe('textarea');
|
||||
expect(schema.getAttribute('data-size')).toBe('lg');
|
||||
|
||||
await act(async () => setNativeValue(schema, '{ not json'));
|
||||
const invalid = Array.from(container.querySelectorAll('div')).find(
|
||||
(el) => el.textContent === 'agents.form.advanced.invalidJson',
|
||||
)!;
|
||||
expect(invalid.className).toContain('text-destructive');
|
||||
expect(invalid.querySelector('svg.lucide-circle-x')).not.toBeNull();
|
||||
|
||||
await act(async () => setNativeValue(schema, '{}'));
|
||||
const valid = Array.from(container.querySelectorAll('div')).find(
|
||||
(el) => el.textContent === 'agents.form.advanced.validJson',
|
||||
)!;
|
||||
expect(valid.className).toContain('text-success');
|
||||
expect(valid.querySelector('svg.lucide-circle-check')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the preview placeholder illustration as decorative images', async () => {
|
||||
await render();
|
||||
const images = container.querySelectorAll('img[alt=""]');
|
||||
expect(images).toHaveLength(2);
|
||||
for (const img of images) {
|
||||
expect(img.getAttribute('aria-hidden')).toBe('true');
|
||||
}
|
||||
});
|
||||
});
|
||||
+132
-131
@@ -1,5 +1,10 @@
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -11,6 +16,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -28,10 +35,14 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import devicesService from '../api/services/devicesService';
|
||||
import modelService from '../api/services/modelService';
|
||||
import userService from '../api/services/userService';
|
||||
import ScienceSparkDarkIcon from '../assets/science-spark-dark.svg';
|
||||
import ScienceSparkIcon from '../assets/science-spark.svg';
|
||||
import SourceIcon from '../assets/source.svg';
|
||||
import { FileUpload } from '../components/FileUpload';
|
||||
import {
|
||||
@@ -589,17 +600,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
const meta = devicesById.get(deviceId);
|
||||
const online = meta?.online ?? false;
|
||||
base.descriptionNode = (
|
||||
<span
|
||||
className={`mt-0.5 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
online
|
||||
? 'bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-gray-200 text-gray-700 dark:bg-gray-700/40 dark:text-gray-300'
|
||||
}`}
|
||||
<Badge
|
||||
variant={online ? 'success' : 'neutral'}
|
||||
className="mt-0.5"
|
||||
>
|
||||
{online
|
||||
? t('settings.devices.online')
|
||||
: t('settings.devices.offline')}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return base;
|
||||
@@ -808,33 +816,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-2">
|
||||
{showAgentNav ? <CurrentSectionHeader /> : <span aria-hidden />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{submitError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
<span className="h-4 w-4 shrink-0 bg-[url('/src/assets/circle-x.svg')] bg-contain bg-center bg-no-repeat" />
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
className="text-primary dark:text-foreground rounded-3xl px-2 hover:bg-transparent"
|
||||
>
|
||||
<Button type="button" variant="link" onClick={handleCancel}>
|
||||
{t('agents.form.buttons.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{modeConfig[effectiveMode].showSaveDraft && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
shape="pill"
|
||||
disabled={isDraftBlocked()}
|
||||
onClick={handleSaveDraft}
|
||||
className={`border-primary text-primary hover:bg-primary/90 min-w-28 rounded-3xl border border-solid bg-transparent px-5 whitespace-nowrap hover:text-white ${
|
||||
isDraftBlocked() ? 'disabled:opacity-30' : ''
|
||||
}`}
|
||||
className="min-w-28"
|
||||
>
|
||||
<span className="flex items-center justify-center transition-all duration-200">
|
||||
{draftLoading ? (
|
||||
@@ -849,7 +843,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
type="button"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
onClick={handlePublish}
|
||||
className={`${!isPublishable() || !hasChanges ? 'disabled:opacity-30' : ''} min-w-28 rounded-3xl px-5 whitespace-nowrap text-white`}
|
||||
shape="pill"
|
||||
className="min-w-28"
|
||||
>
|
||||
<span className="flex items-center justify-center transition-all duration-200">
|
||||
{publishLoading ? (
|
||||
@@ -889,6 +884,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
</div>
|
||||
</div>
|
||||
{showAgentNav && <SectionPills className="mt-4" />}
|
||||
{submitError && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<CircleX aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="bg-muted dark:bg-background mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-2xl p-5 max-[1179px]:overflow-visible min-[1180px]:grid min-[1180px]:gap-5 min-[1180px]:overflow-hidden">
|
||||
<div className="scrollbar-overlay col-span-2 flex flex-col gap-5 max-[1179px]:overflow-visible min-[1180px]:max-h-full min-[1180px]:overflow-y-auto min-[1180px]:pr-3">
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
@@ -896,14 +897,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.sections.meta')}
|
||||
</h2>
|
||||
<Input
|
||||
className="bg-card mt-3 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm"
|
||||
shape="pill"
|
||||
className="mt-3"
|
||||
type="text"
|
||||
value={agent.name}
|
||||
placeholder={t('agents.form.placeholders.agentName')}
|
||||
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
className="border-border text-foreground dark:text-foreground dark:placeholder:text-muted-foreground bg-card dark:border-border focus-visible:ring-ring/50 focus-visible:border-ring mt-3 h-32 w-full rounded-xl border px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 focus-visible:ring-[3px]"
|
||||
<Textarea
|
||||
size="lg"
|
||||
className="mt-3 h-32"
|
||||
placeholder={t('agents.form.placeholders.describeAgent')}
|
||||
value={agent.description}
|
||||
onChange={(e) =>
|
||||
@@ -913,17 +916,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<div className="mt-3">
|
||||
<FileUpload
|
||||
showPreview
|
||||
className="bg-card"
|
||||
size="compact"
|
||||
previewSize={56}
|
||||
onUpload={handleUpload}
|
||||
onRemove={() => setImageFile(null)}
|
||||
uploadText={[
|
||||
{
|
||||
text: t('agents.form.upload.clickToUpload'),
|
||||
colorClass: 'text-primary',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
text: t('agents.form.upload.dragAndDrop'),
|
||||
colorClass: 'text-[#525252]',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -960,16 +963,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="combobox"
|
||||
size="field"
|
||||
shape="pill"
|
||||
ref={sourceAnchorButtonRef}
|
||||
title={selectedSourceNames.join(', ')}
|
||||
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
|
||||
selectedSourceIds.size > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
data-placeholder={
|
||||
selectedSourceIds.size > 0 ? undefined : ''
|
||||
}
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
{sourceTriggerLabel}
|
||||
<span className="truncate">{sourceTriggerLabel}</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -1016,8 +1020,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => setAddPromptModal('ACTIVE')}
|
||||
className="border-primary text-primary hover:bg-primary/90 h-auto min-w-20 shrink-0 basis-full rounded-3xl border border-solid bg-transparent px-5 py-3 whitespace-nowrap hover:text-white sm:basis-auto"
|
||||
className="min-w-20 shrink-0 basis-full sm:basis-auto"
|
||||
>
|
||||
{t('agents.form.buttons.add')}
|
||||
</Button>
|
||||
@@ -1059,20 +1066,21 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="combobox"
|
||||
size="field"
|
||||
shape="pill"
|
||||
ref={toolAnchorButtonRef}
|
||||
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
|
||||
selectedTools.length > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
data-placeholder={selectedTools.length > 0 ? undefined : ''}
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
{selectedTools.length > 0
|
||||
? selectedTools
|
||||
.map((tool) => getToolDisplayName(tool))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectTools')}
|
||||
<span className="truncate">
|
||||
{selectedTools.length > 0
|
||||
? selectedTools
|
||||
.map((tool) => getToolDisplayName(tool))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectTools')}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -1089,10 +1097,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
setAgent({ ...agent, agent_type: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectTrigger className="w-full" shape="pill" size="lg">
|
||||
<SelectValue
|
||||
placeholder={t('agents.form.placeholders.selectType')}
|
||||
/>
|
||||
@@ -1148,20 +1153,23 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="combobox"
|
||||
size="field"
|
||||
shape="pill"
|
||||
ref={modelAnchorButtonRef}
|
||||
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
|
||||
selectedModelIds.size > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
data-placeholder={
|
||||
selectedModelIds.size > 0 ? undefined : ''
|
||||
}
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
{selectedModelIds.size > 0
|
||||
? availableModels
|
||||
.filter((m) => selectedModelIds.has(m.id))
|
||||
.map((m) => m.display_name)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectModels')}
|
||||
<span className="truncate">
|
||||
{selectedModelIds.size > 0
|
||||
? availableModels
|
||||
.filter((m) => selectedModelIds.has(m.id))
|
||||
.map((m) => m.display_name)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectModels')}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -1176,10 +1184,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
setAgent({ ...agent, default_model_id: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectTrigger className="w-full" shape="pill" size="lg">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.selectDefaultModel',
|
||||
@@ -1200,49 +1205,44 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
|
||||
}
|
||||
className="h-auto w-full justify-between px-0 py-0 text-left hover:bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
<div className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset">
|
||||
{/* The heading wraps the toggle: a button's children are
|
||||
presentational, so a heading inside it is lost to screen readers. */}
|
||||
<h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="section-toggle"
|
||||
onClick={() =>
|
||||
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
|
||||
}
|
||||
size="sm"
|
||||
aria-expanded={isAdvancedSectionExpanded}
|
||||
className="-ml-3 w-fit justify-start"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'transition-transform',
|
||||
isAdvancedSectionExpanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{t('agents.form.sections.advanced')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<svg
|
||||
className={`size-5 transform transition-transform duration-200 ${
|
||||
isAdvancedSectionExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</Button>
|
||||
</span>
|
||||
</Button>
|
||||
</h2>
|
||||
{isAdvancedSectionExpanded && (
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">
|
||||
{t('agents.form.advanced.jsonSchema')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t('agents.form.advanced.jsonSchemaDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
<Textarea
|
||||
size="lg"
|
||||
value={jsonSchemaText}
|
||||
onChange={(e) => validateAndSetJsonSchema(e.target.value)}
|
||||
placeholder={`{
|
||||
@@ -1255,23 +1255,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
"additionalProperties": false
|
||||
}`}
|
||||
rows={9}
|
||||
className={`border-border text-foreground dark:text-foreground bg-card dark:border-border focus-visible:ring-ring/50 focus-visible:border-ring mt-2 w-full rounded-2xl border px-4 py-3 font-mono text-sm outline-hidden focus-visible:ring-[3px]`}
|
||||
className="mt-2 font-mono"
|
||||
/>
|
||||
{jsonSchemaText.trim() !== '' && (
|
||||
<div
|
||||
className={`mt-2 flex items-center gap-2 text-sm ${
|
||||
jsonSchemaValid
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
jsonSchemaValid ? 'text-success' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-4 w-4 bg-contain bg-center bg-no-repeat ${
|
||||
jsonSchemaValid
|
||||
? "bg-[url('/src/assets/circle-check.svg')]"
|
||||
: "bg-[url('/src/assets/circle-x.svg')]"
|
||||
}`}
|
||||
/>
|
||||
{jsonSchemaValid ? (
|
||||
<CircleCheck className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<CircleX className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{jsonSchemaValid
|
||||
? t('agents.form.advanced.validJson')
|
||||
: t('agents.form.advanced.invalidJson')}
|
||||
@@ -1284,7 +1280,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<h2 className="text-sm font-medium">
|
||||
{t('agents.form.advanced.tokenLimiting')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t('agents.form.advanced.tokenLimitingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1315,11 +1311,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}
|
||||
disabled={!agent.limited_token_mode}
|
||||
placeholder={t('agents.form.placeholders.enterTokenLimit')}
|
||||
className={`bg-card mt-2 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm ${
|
||||
!agent.limited_token_mode
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
shape="pill"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1329,7 +1322,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<h2 className="text-sm font-medium">
|
||||
{t('agents.form.advanced.requestLimiting')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t('agents.form.advanced.requestLimitingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1362,11 +1355,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.enterRequestLimit',
|
||||
)}
|
||||
className={`bg-card mt-2 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm ${
|
||||
!agent.limited_request_mode
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
shape="pill"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1376,7 +1366,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<h2 className="text-sm font-medium">
|
||||
{t('agents.form.advanced.systemPromptOverride')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t(
|
||||
'agents.form.advanced.systemPromptOverrideDescription',
|
||||
)}
|
||||
@@ -1508,7 +1498,18 @@ function AgentPreviewArea() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<span className="block h-12 w-12 bg-[url('/src/assets/science-spark.svg')] bg-contain bg-center bg-no-repeat transition-all dark:bg-[url('/src/assets/science-spark-dark.svg')]" />{' '}
|
||||
<img
|
||||
src={ScienceSparkIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="block size-12 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src={ScienceSparkDarkIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="hidden size-12 dark:block"
|
||||
/>{' '}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('agents.form.preview.publishedPreview')}
|
||||
</p>
|
||||
|
||||
@@ -155,7 +155,9 @@ export default function SharedAgent() {
|
||||
<Avatar
|
||||
src={sharedAgent.image}
|
||||
alt="agent-logo"
|
||||
imgClassName="h-6 w-6 rounded-full object-contain"
|
||||
shape="circle"
|
||||
className="overflow-hidden"
|
||||
imgClassName="h-6 w-6 object-contain"
|
||||
/>
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{sharedAgent.name}
|
||||
|
||||
@@ -29,8 +29,9 @@ export default function SharedAgentCard({
|
||||
<Avatar
|
||||
src={agent.image}
|
||||
alt={agent.name}
|
||||
className="h-full w-full"
|
||||
imgClassName="h-full w-full rounded-full object-contain"
|
||||
shape="circle"
|
||||
className="size-full overflow-hidden"
|
||||
imgClassName="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex max-h-[92px] flex-1 flex-col gap-px">
|
||||
@@ -46,8 +47,9 @@ export default function SharedAgentCard({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={onEdit}
|
||||
className="shrink-0 gap-1.5 rounded-full"
|
||||
className="shrink-0"
|
||||
aria-label={t('agents.edit')}
|
||||
>
|
||||
<img src={EditIcon} alt="" className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const navigate = vi.fn();
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
import AgentTypeModal from './AgentTypeModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('AgentTypeModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
navigate.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (onClose = vi.fn()) => {
|
||||
await act(async () => {
|
||||
root.render(<AgentTypeModal isOpen onClose={onClose} folderId="f1" />);
|
||||
});
|
||||
return onClose;
|
||||
};
|
||||
|
||||
const tiles = () =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-slot="option-card"]',
|
||||
),
|
||||
);
|
||||
|
||||
it('renders the two agent types as OptionCards', async () => {
|
||||
await render();
|
||||
const found = tiles();
|
||||
expect(found).toHaveLength(2);
|
||||
expect(found[0].textContent).toContain('Classic Agent');
|
||||
expect(found[1].textContent).toContain('Workflow Agent');
|
||||
expect(found[0].querySelector('[data-slot="option-card-icon"]')).not.toBe(
|
||||
null,
|
||||
);
|
||||
expect(found[0].querySelector('[data-slot="card-description"]')).not.toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the workflow builder and closes', async () => {
|
||||
const onClose = await render();
|
||||
await act(async () => tiles()[1].click());
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(String(navigate.mock.calls[0][0])).toContain('workflow');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigates to the classic form and closes', async () => {
|
||||
const onClose = await render();
|
||||
await act(async () => tiles()[0].click());
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(String(navigate.mock.calls[0][0])).not.toContain('workflow');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { agentNewPath } from '../paths';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { OptionCard } from '../../components/ui/option-card';
|
||||
|
||||
interface AgentTypeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -35,43 +36,18 @@ export default function AgentTypeModal({
|
||||
size="md"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
<OptionCard
|
||||
icon={<Bot />}
|
||||
title="Classic Agent"
|
||||
description="Create a standard AI agent with a single model, tools, and knowledge sources"
|
||||
onClick={() => handleSelect('normal')}
|
||||
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 focus-visible:border-ring focus-visible:ring-ring/50 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary/90 flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
|
||||
<Bot size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
|
||||
Classic Agent
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Create a standard AI agent with a single model, tools, and
|
||||
knowledge sources
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
/>
|
||||
<OptionCard
|
||||
icon={<Workflow />}
|
||||
title="Workflow Agent"
|
||||
description="Design complex multi-step workflows with different models, conditional logic, and state management"
|
||||
onClick={() => handleSelect('workflow')}
|
||||
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 focus-visible:border-ring focus-visible:ring-ring/50 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
|
||||
<Workflow size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
|
||||
Workflow Agent
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Design complex multi-step workflows with different models,
|
||||
conditional logic, and state management
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
// A stable `t`: the component lists it as an effect dependency.
|
||||
vi.mock('react-i18next', () => {
|
||||
const t = (key: string) => key;
|
||||
return { useTranslation: () => ({ t }) };
|
||||
});
|
||||
|
||||
const events = [
|
||||
{ id: '1', action: 'block', outcome: 'triggered' },
|
||||
{ id: '2', action: 'redact', outcome: 'triggered' },
|
||||
{ id: '3', action: 'flag', outcome: 'triggered' },
|
||||
{ id: '4', action: 'flag', outcome: 'not_evaluated' },
|
||||
].map((e) => ({
|
||||
...e,
|
||||
check_name: `check-${e.id}`,
|
||||
stage: 'input',
|
||||
category: null,
|
||||
detail: '',
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
}));
|
||||
|
||||
vi.mock('../../api/services/userService', () => ({
|
||||
default: {
|
||||
getGuardrailEvents: () =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve({ success: true, events }),
|
||||
}),
|
||||
getGuardrailSummary: () =>
|
||||
Promise.resolve({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
breakdown: [],
|
||||
totals: { blocked: 1, redacted: 1, flagged: 1, not_evaluated: 1 },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import GuardrailEvents from './GuardrailEvents';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const makeStore = () =>
|
||||
configureStore({ reducer: { preference: () => ({ token: null }) } });
|
||||
|
||||
describe('GuardrailEvents tones', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(async () => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={makeStore()}>
|
||||
<GuardrailEvents agentId="a1" />
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('colours outcome badges by meaning', () => {
|
||||
const variants = Array.from(
|
||||
container.querySelectorAll(
|
||||
'[data-testid="guardrail-events-rows"] [data-slot="badge"]',
|
||||
),
|
||||
).map((b) => b.getAttribute('data-variant'));
|
||||
expect(variants).toEqual(['destructive', 'info', 'warning', 'neutral']);
|
||||
});
|
||||
|
||||
it('colours the stat tiles to match', () => {
|
||||
const tone = (id: string) =>
|
||||
container.querySelector(`[data-testid="${id}"] p:last-child`)?.className;
|
||||
expect(tone('guardrail-stat-blocked')).toContain('text-destructive');
|
||||
expect(tone('guardrail-stat-redacted')).toContain('text-info');
|
||||
expect(tone('guardrail-stat-flagged')).toContain('text-warning');
|
||||
expect(tone('guardrail-stat-not-evaluated')).toContain(
|
||||
'text-muted-foreground',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import userService from '../../api/services/userService';
|
||||
import SkeletonLoader from '../../components/SkeletonLoader';
|
||||
@@ -32,15 +35,18 @@ const ACTION_KEYS: Record<string, string> = {
|
||||
block: 'agents.form.guardrails.actions.block',
|
||||
};
|
||||
|
||||
/** Colour by consequence, so "we refused" reads differently from "we noticed". */
|
||||
function actionTone(action: string, outcome: string): string {
|
||||
if (outcome === 'not_evaluated')
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
if (action === 'block')
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300';
|
||||
if (action === 'redact')
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300';
|
||||
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300';
|
||||
/**
|
||||
* Colour by meaning, matching the status tokens used across the app: a block
|
||||
* is a refusal, a flag needs review, a redaction is informational.
|
||||
*/
|
||||
function actionTone(
|
||||
action: string,
|
||||
outcome: string,
|
||||
): 'neutral' | 'destructive' | 'warning' | 'info' {
|
||||
if (outcome === 'not_evaluated') return 'neutral';
|
||||
if (action === 'block') return 'destructive';
|
||||
if (action === 'redact') return 'info';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
type Props = { agentId?: string };
|
||||
@@ -122,8 +128,9 @@ export default function GuardrailEvents({ agentId }: Props) {
|
||||
onValueChange={(value) => setDays(Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[150px] rounded-3xl px-5 py-3 text-sm"
|
||||
className="w-[150px]"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
data-testid="guardrail-events-window"
|
||||
>
|
||||
<SelectValue />
|
||||
@@ -148,21 +155,21 @@ export default function GuardrailEvents({ agentId }: Props) {
|
||||
value={totals?.blocked}
|
||||
loading={loading}
|
||||
testId="guardrail-stat-blocked"
|
||||
tone="text-red-700 dark:text-red-400"
|
||||
tone="text-destructive"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('agents.guardrailEvents.redacted')}
|
||||
value={totals?.redacted}
|
||||
loading={loading}
|
||||
testId="guardrail-stat-redacted"
|
||||
tone="text-amber-700 dark:text-amber-400"
|
||||
tone="text-info"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('agents.guardrailEvents.flagged')}
|
||||
value={totals?.flagged}
|
||||
loading={loading}
|
||||
testId="guardrail-stat-flagged"
|
||||
tone="text-emerald-700 dark:text-emerald-400"
|
||||
tone="text-warning"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('agents.guardrailEvents.notEvaluated')}
|
||||
@@ -179,8 +186,9 @@ export default function GuardrailEvents({ agentId }: Props) {
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<Select value={checkFilter} onValueChange={setCheckFilter}>
|
||||
<SelectTrigger
|
||||
className="w-[170px] rounded-3xl px-5 py-3 text-sm"
|
||||
className="w-[170px]"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
data-testid="guardrail-events-check-filter"
|
||||
>
|
||||
<SelectValue />
|
||||
@@ -198,8 +206,9 @@ export default function GuardrailEvents({ agentId }: Props) {
|
||||
</Select>
|
||||
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
|
||||
<SelectTrigger
|
||||
className="w-[170px] rounded-3xl px-5 py-3 text-sm"
|
||||
className="w-[170px]"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
data-testid="guardrail-events-outcome-filter"
|
||||
>
|
||||
<SelectValue />
|
||||
@@ -288,16 +297,11 @@ export default function GuardrailEvents({ agentId }: Props) {
|
||||
{t(STAGE_KEYS[event.stage] ?? event.stage)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${actionTone(
|
||||
event.action,
|
||||
event.outcome,
|
||||
)}`}
|
||||
>
|
||||
<Badge variant={actionTone(event.action, event.outcome)}>
|
||||
{event.outcome === 'not_evaluated'
|
||||
? t('agents.guardrailEvents.notEvaluated')
|
||||
: t(ACTION_KEYS[event.action] ?? event.action)}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground max-w-[28rem] px-3 py-2">
|
||||
{event.detail || '—'}
|
||||
@@ -345,7 +349,7 @@ function StatTile({
|
||||
<SkeletonLoader count={1} />
|
||||
</div>
|
||||
) : (
|
||||
<p className={`mt-1 text-xl font-semibold ${tone}`}>{value ?? 0}</p>
|
||||
<p className={cn('mt-1 text-xl font-semibold', tone)}>{value ?? 0}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -381,12 +385,10 @@ function ByCheck({ summary }: { summary: GuardrailSummary }) {
|
||||
{rows.map(([check, total]) => (
|
||||
<div key={check} className="flex items-center gap-3">
|
||||
<span className="w-32 shrink-0 truncate text-xs">{check}</span>
|
||||
<div className="bg-border/60 h-2 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-violets-are-blue h-full rounded-full"
|
||||
style={{ width: `${max ? (total / max) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
className="flex-1"
|
||||
value={max ? (total / max) * 100 : 0}
|
||||
/>
|
||||
<span className="text-muted-foreground w-10 shrink-0 text-right text-xs">
|
||||
{total}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import type { GuardrailCatalog, GuardrailsConfig } from '../types';
|
||||
|
||||
const getGuardrailCatalog = vi.fn();
|
||||
vi.mock('../../api/services/userService', () => ({
|
||||
default: {
|
||||
getGuardrailCatalog: (...args: unknown[]) => getGuardrailCatalog(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// A stable `t`: the component refetches the catalog whenever `t` changes.
|
||||
const t = (key: string) => key;
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t }),
|
||||
}));
|
||||
|
||||
import GuardrailsSection from './GuardrailsSection';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const catalog: GuardrailCatalog = {
|
||||
enabled: false,
|
||||
checks: [
|
||||
{
|
||||
name: 'pii',
|
||||
label: 'PII',
|
||||
description: 'Detects personal data',
|
||||
stages: ['input', 'output'],
|
||||
supports_redaction: true,
|
||||
latency_hint_ms: 50,
|
||||
remote: false,
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
name: 'policy',
|
||||
label: 'Policy',
|
||||
description: 'LLM judge',
|
||||
stages: ['input'],
|
||||
supports_redaction: false,
|
||||
latency_hint_ms: 1500,
|
||||
remote: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
stages: ['input', 'output'],
|
||||
modes: ['monitor_only', 'scan_all'],
|
||||
actions_by_stage: {
|
||||
input: ['flag', 'block'],
|
||||
retrieval: ['flag'],
|
||||
tool_result: ['flag'],
|
||||
output: ['flag', 'redact', 'block'],
|
||||
},
|
||||
default_block_message: 'No',
|
||||
pii_entities: ['EMAIL', 'PHONE'],
|
||||
default_pii_entities: ['EMAIL'],
|
||||
floor: {
|
||||
enabled: true,
|
||||
mode: 'scan_all',
|
||||
fail_open: true,
|
||||
timeout_ms: 2000,
|
||||
block_message: '',
|
||||
controls: [
|
||||
{
|
||||
check: 'pii',
|
||||
stage: 'output',
|
||||
action: 'block',
|
||||
enabled: true,
|
||||
settings: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const config: GuardrailsConfig = {
|
||||
enabled: true,
|
||||
mode: 'monitor_only',
|
||||
fail_open: true,
|
||||
timeout_ms: 2000,
|
||||
block_message: 'Sorry',
|
||||
controls: [
|
||||
{
|
||||
check: 'pii',
|
||||
stage: 'input',
|
||||
action: 'flag',
|
||||
enabled: true,
|
||||
settings: { entities: ['EMAIL'] },
|
||||
},
|
||||
{
|
||||
check: 'policy',
|
||||
stage: 'input',
|
||||
action: 'flag',
|
||||
enabled: true,
|
||||
settings: { policy: '' },
|
||||
},
|
||||
{
|
||||
check: 'gone',
|
||||
stage: 'input',
|
||||
action: 'flag',
|
||||
enabled: true,
|
||||
settings: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('GuardrailsSection', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
getGuardrailCatalog.mockReset();
|
||||
getGuardrailCatalog.mockResolvedValue({
|
||||
json: async () => ({ success: true, ...catalog }),
|
||||
});
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const q = (testId: string) =>
|
||||
container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
|
||||
|
||||
const render = async (
|
||||
props: Partial<Parameters<typeof GuardrailsSection>[0]> = {},
|
||||
) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GuardrailsSection
|
||||
value={config}
|
||||
onChange={() => undefined}
|
||||
token="tok"
|
||||
disabledNotice="Read only"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
q('guardrails-toggle')?.click();
|
||||
});
|
||||
};
|
||||
|
||||
it('announces whether the section is open with aria-expanded', async () => {
|
||||
await render();
|
||||
expect(q('guardrails-toggle')?.getAttribute('aria-expanded')).toBe('true');
|
||||
await act(async () => {
|
||||
q('guardrails-toggle')?.click();
|
||||
});
|
||||
expect(q('guardrails-toggle')?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
// Decision 64: the header is a section-toggle with a leading lucide
|
||||
// chevron; the badges sit beside it and the panel draws the focus ring.
|
||||
it('renders the header as a section-toggle with the badges beside it', async () => {
|
||||
await render();
|
||||
const toggle = q('guardrails-toggle')!;
|
||||
expect(toggle.dataset.variant).toBe('section-toggle');
|
||||
expect(toggle.dataset.size).toBe('sm');
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
const chevron = toggle.firstElementChild!;
|
||||
expect(chevron.matches('svg.lucide-chevron-right')).toBe(true);
|
||||
expect(chevron.getAttribute('class')).toContain('rotate-90');
|
||||
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
|
||||
// The heading wraps the button (a button's children are presentational,
|
||||
// so a heading inside it would be lost to screen readers).
|
||||
expect(toggle.querySelector('h2')).toBeNull();
|
||||
const heading = toggle.closest('h2')!;
|
||||
expect(heading.textContent).toBe('agents.form.sections.guardrails');
|
||||
|
||||
const active = q('guardrails-active-badge')!;
|
||||
const incomplete = q('guardrails-incomplete-badge')!;
|
||||
expect(toggle.contains(active)).toBe(false);
|
||||
expect(toggle.contains(incomplete)).toBe(false);
|
||||
expect(active.parentElement).toBe(heading.parentElement);
|
||||
expect(heading.parentElement?.firstElementChild).toBe(heading);
|
||||
|
||||
const panel = q('guardrails-section')!;
|
||||
expect(panel.contains(toggle)).toBe(true);
|
||||
expect(panel.className).toContain(
|
||||
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
|
||||
);
|
||||
expect(panel.className).toContain('bg-card');
|
||||
|
||||
await act(async () => toggle.click());
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
|
||||
});
|
||||
|
||||
it('renders the header pills as badges in their status roles', async () => {
|
||||
await render();
|
||||
expect(q('guardrails-active-badge')?.dataset.slot).toBe('badge');
|
||||
expect(q('guardrails-active-badge')?.dataset.variant).toBe('success');
|
||||
expect(q('guardrails-incomplete-badge')?.dataset.variant).toBe(
|
||||
'destructive',
|
||||
);
|
||||
expect(q('guardrail-latency-pii')?.dataset.variant).toBe('neutral');
|
||||
expect(q('guardrail-latency-pii')?.title).toBe(
|
||||
'agents.form.guardrails.latencyHint',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the section notices as polite or warning alerts', async () => {
|
||||
await render({ disabled: true });
|
||||
const readOnly = q('guardrails-read-only');
|
||||
expect(readOnly?.getAttribute('role')).toBe('status');
|
||||
expect(readOnly?.textContent).toContain('Read only');
|
||||
const instance = q('guardrails-instance-disabled');
|
||||
expect(instance?.getAttribute('role')).toBe('alert');
|
||||
expect(instance?.className).toContain('text-warning');
|
||||
const floor = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role]'),
|
||||
).find((el) =>
|
||||
el.textContent?.includes('agents.form.guardrails.floorNotice'),
|
||||
);
|
||||
expect(floor?.getAttribute('role')).toBe('status');
|
||||
expect(floor?.className).toContain('text-info');
|
||||
});
|
||||
|
||||
it('uses the pressed-toggle variants for stage chips', async () => {
|
||||
await render();
|
||||
expect(q('guardrail-stage-pii-input')?.dataset.variant).toBe('secondary');
|
||||
const locked = q('guardrail-stage-pii-output');
|
||||
expect(locked?.dataset.variant).toBe('secondary');
|
||||
expect((locked as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(locked?.title).toBe('agents.form.guardrails.lockedByFloor');
|
||||
expect(q('guardrail-stage-policy-input')?.dataset.variant).toBe(
|
||||
'secondary',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders remove actions as ghost-destructive xs buttons', async () => {
|
||||
await render();
|
||||
const remove = q('guardrail-remove-pii-input');
|
||||
expect(remove?.dataset.variant).toBe('ghost-destructive');
|
||||
expect(remove?.dataset.size).toBe('xs');
|
||||
const orphan = q('guardrail-orphan-gone')?.querySelector('button');
|
||||
expect(orphan?.dataset.variant).toBe('ghost-destructive');
|
||||
expect(orphan?.dataset.size).toBe('xs');
|
||||
});
|
||||
|
||||
// Decision 63 (3): the form's fields and selects are all 42px.
|
||||
it('sizes the fields through Input and Textarea props', async () => {
|
||||
await render();
|
||||
const block = q('guardrails-block-message');
|
||||
expect(block?.dataset.size).toBe('default');
|
||||
expect(block?.dataset.shape).toBe('pill');
|
||||
expect(block?.className).not.toContain('bg-card');
|
||||
const timeout = q('guardrails-timeout');
|
||||
expect(timeout?.dataset.variant).toBe('filled');
|
||||
expect(timeout?.dataset.size).toBe('default');
|
||||
expect(timeout?.dataset.shape).toBe('pill');
|
||||
const mode = q('guardrails-mode');
|
||||
expect(mode?.dataset.size).toBe('lg');
|
||||
expect(mode?.dataset.shape).toBe('pill');
|
||||
|
||||
await act(async () => {
|
||||
q('guardrail-configure-policy-input')?.click();
|
||||
});
|
||||
expect(q('guardrail-policy-text')?.dataset.slot).toBe('textarea');
|
||||
});
|
||||
|
||||
it('shows PII entities as toggle chips', async () => {
|
||||
await render();
|
||||
await act(async () => {
|
||||
q('guardrail-configure-pii-input')?.click();
|
||||
});
|
||||
expect(q('guardrail-pii-EMAIL')?.dataset.variant).toBe('secondary');
|
||||
expect(q('guardrail-pii-PHONE')?.dataset.variant).toBe('ghost-muted');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight, Info, Lock, TriangleAlert } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -11,6 +14,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import userService from '../../api/services/userService';
|
||||
import {
|
||||
@@ -202,59 +207,49 @@ export default function GuardrailsSection({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card rounded-2xl px-6 py-3"
|
||||
className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset"
|
||||
data-testid="guardrails-section"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="h-auto w-full justify-between px-0 py-0 text-left hover:bg-transparent"
|
||||
data-testid="guardrails-toggle"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('agents.form.sections.guardrails')}
|
||||
</h2>
|
||||
{config.enabled && (
|
||||
<span
|
||||
className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
data-testid="guardrails-active-badge"
|
||||
>
|
||||
{t('agents.form.guardrails.activeCount', {
|
||||
count: config.controls.length + floorControls.size,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{incompleteCount > 0 && (
|
||||
<span
|
||||
className="bg-destructive/10 text-destructive rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
data-testid="guardrails-incomplete-badge"
|
||||
>
|
||||
{t('agents.form.guardrails.needsSetup', {
|
||||
count: incompleteCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<svg
|
||||
className={`size-5 transform transition-transform duration-200 ${
|
||||
expanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* The heading wraps the toggle: a button's children are
|
||||
presentational, so a heading inside it is lost to screen readers. */}
|
||||
<h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="section-toggle"
|
||||
size="sm"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
className="-ml-3 w-fit justify-start"
|
||||
data-testid="guardrails-toggle"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn('transition-transform', expanded && 'rotate-90')}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</Button>
|
||||
<span className="text-lg font-semibold">
|
||||
{t('agents.form.sections.guardrails')}
|
||||
</span>
|
||||
</Button>
|
||||
</h2>
|
||||
{config.enabled && (
|
||||
<Badge variant="success" data-testid="guardrails-active-badge">
|
||||
{t('agents.form.guardrails.activeCount', {
|
||||
count: config.controls.length + floorControls.size,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
{incompleteCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
data-testid="guardrails-incomplete-badge"
|
||||
>
|
||||
{t('agents.form.guardrails.needsSetup', {
|
||||
count: incompleteCount,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 pb-3">
|
||||
@@ -263,29 +258,38 @@ export default function GuardrailsSection({
|
||||
)}
|
||||
|
||||
{disabled && disabledNotice && (
|
||||
<p
|
||||
className="mt-3 rounded-lg bg-gray-100 px-3 py-2 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
<Alert
|
||||
role="status"
|
||||
className="mt-3"
|
||||
data-testid="guardrails-read-only"
|
||||
>
|
||||
{disabledNotice}
|
||||
</p>
|
||||
<Lock aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>{disabledNotice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{instanceDisabled && (
|
||||
<p
|
||||
className="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
<Alert
|
||||
variant="warning"
|
||||
className="mt-3"
|
||||
data-testid="guardrails-instance-disabled"
|
||||
>
|
||||
{t('agents.form.guardrails.instanceDisabled')}
|
||||
</p>
|
||||
<TriangleAlert aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>
|
||||
{t('agents.form.guardrails.instanceDisabled')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{floorControls.size > 0 && (
|
||||
<p className="mt-3 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
{t('agents.form.guardrails.floorNotice', {
|
||||
count: floorControls.size,
|
||||
})}
|
||||
</p>
|
||||
<Alert variant="info" role="status" className="mt-3">
|
||||
<Info aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>
|
||||
{t('agents.form.guardrails.floorNotice', {
|
||||
count: floorControls.size,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-4">
|
||||
@@ -293,7 +297,7 @@ export default function GuardrailsSection({
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('agents.form.guardrails.enable')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t('agents.form.guardrails.enableDescription')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -320,8 +324,9 @@ export default function GuardrailsSection({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full rounded-3xl px-5 py-3 text-sm"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
data-testid="guardrails-mode"
|
||||
>
|
||||
<SelectValue />
|
||||
@@ -335,7 +340,7 @@ export default function GuardrailsSection({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.mode === 'monitor_only' && (
|
||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
<p className="text-warning mt-2 text-xs">
|
||||
{t('agents.form.guardrails.monitorHint')}
|
||||
</p>
|
||||
)}
|
||||
@@ -375,10 +380,9 @@ export default function GuardrailsSection({
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant="ghost-destructive"
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
className="text-destructive h-auto px-2 py-1 text-xs"
|
||||
onClick={() =>
|
||||
removeControl(control.check, control.stage)
|
||||
}
|
||||
@@ -401,9 +405,9 @@ export default function GuardrailsSection({
|
||||
disabled={disabled}
|
||||
data-testid="guardrails-block-message"
|
||||
onChange={(e) => patch({ block_message: e.target.value })}
|
||||
className="bg-card h-auto rounded-3xl px-5 py-3 text-sm md:text-sm"
|
||||
shape="pill"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t('agents.form.guardrails.blockMessageDescription')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -413,7 +417,7 @@ export default function GuardrailsSection({
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('agents.form.guardrails.failOpen')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t('agents.form.guardrails.failOpenDescription')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -494,7 +498,8 @@ function NumberField({
|
||||
setDraft(String(next));
|
||||
onCommit(next);
|
||||
}}
|
||||
className="bg-card h-auto rounded-3xl px-5 py-3 text-sm md:text-sm"
|
||||
variant="filled"
|
||||
shape="pill"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -566,28 +571,26 @@ function CheckCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700"
|
||||
className="border-border rounded-xl border px-4 py-3"
|
||||
data-testid={`guardrail-check-${info.name}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="text-sm font-medium">{info.label}</h4>
|
||||
<span
|
||||
className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
<Badge
|
||||
variant="neutral"
|
||||
title={t('agents.form.guardrails.latencyHint')}
|
||||
data-testid={`guardrail-latency-${info.name}`}
|
||||
>
|
||||
{latencyLabel(info.latency_hint_ms)}
|
||||
</span>
|
||||
</Badge>
|
||||
{unavailable && (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
<Badge variant="warning">
|
||||
{t('agents.form.guardrails.notConfigured')}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{info.description}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{info.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -602,25 +605,36 @@ function CheckCard({
|
||||
// out a credential strands a control that can never be cleared.
|
||||
const canToggle =
|
||||
!disabled && !locked && (Boolean(control) || !unavailable);
|
||||
return (
|
||||
<button
|
||||
const chip = (
|
||||
<Button
|
||||
key={stage}
|
||||
type="button"
|
||||
variant={on ? 'secondary' : 'ghost-muted'}
|
||||
size="xs"
|
||||
shape="pill"
|
||||
disabled={!canToggle}
|
||||
data-testid={`guardrail-stage-${info.name}-${stage}`}
|
||||
onClick={() => toggleControl(info, stage, !control)}
|
||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||
on
|
||||
? 'border-violets-are-blue bg-violets-are-blue/10 text-violets-are-blue'
|
||||
: 'border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-400'
|
||||
} ${!canToggle ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||
title={
|
||||
locked ? t('agents.form.guardrails.lockedByFloor') : undefined
|
||||
}
|
||||
>
|
||||
{t(STAGE_KEYS[stage] ?? stage)}
|
||||
{locked ? ' 🔒' : ''}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
// A disabled Button takes no pointer events, so the locked chip's
|
||||
// hint lives on a wrapper that can still be hovered.
|
||||
return locked ? (
|
||||
<span
|
||||
key={stage}
|
||||
className="inline-flex"
|
||||
title={t('agents.form.guardrails.lockedByFloor')}
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
) : (
|
||||
chip
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -628,7 +642,7 @@ function CheckCard({
|
||||
{floorForCheck.map(([k, action]) => (
|
||||
<div
|
||||
key={`floor-${k}`}
|
||||
className="mt-3 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
className="bg-info/10 text-info mt-3 rounded-lg px-3 py-2 text-xs"
|
||||
data-testid={`guardrail-floor-${k}`}
|
||||
>
|
||||
{t('agents.form.guardrails.floorControl', {
|
||||
@@ -644,11 +658,12 @@ function CheckCard({
|
||||
return (
|
||||
<div
|
||||
key={panelKey}
|
||||
className={`mt-3 rounded-lg px-3 py-2 ${
|
||||
className={cn(
|
||||
'mt-3 rounded-lg px-3 py-2',
|
||||
needsSetup
|
||||
? 'border-destructive/50 bg-destructive/5 border'
|
||||
: 'bg-gray-50 dark:bg-gray-900/40'
|
||||
}`}
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">
|
||||
@@ -665,7 +680,8 @@ function CheckCard({
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-auto rounded-full px-3 py-1 text-xs"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
data-testid={`guardrail-action-${control.check}-${control.stage}`}
|
||||
>
|
||||
<SelectValue />
|
||||
@@ -687,8 +703,7 @@ function CheckCard({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-1 text-xs"
|
||||
size="xs"
|
||||
data-testid={`guardrail-configure-${control.check}-${control.stage}`}
|
||||
onClick={() =>
|
||||
setOpenSettings(
|
||||
@@ -701,10 +716,9 @@ function CheckCard({
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant="ghost-destructive"
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
className="text-destructive h-auto px-2 py-1 text-xs"
|
||||
data-testid={`guardrail-remove-${control.check}-${control.stage}`}
|
||||
onClick={() => removeControl(control.check, control.stage)}
|
||||
>
|
||||
@@ -759,8 +773,9 @@ function CheckSettings({
|
||||
const listField = (fieldKey: string, label: string, placeholder: string) => (
|
||||
<div className="mt-2">
|
||||
<label className="mb-1 block text-xs font-medium">{label}</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
rows={2}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
data-testid={`guardrail-setting-${control.check}-${fieldKey}`}
|
||||
value={(s[fieldKey] ?? []).join('\n')}
|
||||
@@ -773,21 +788,24 @@ function CheckSettings({
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
className="bg-card w-full rounded-xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700"
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
|
||||
<div className="border-border mt-3 border-t pt-3">
|
||||
{control.check === 'pii' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(catalog?.pii_entities ?? []).map((entity) => {
|
||||
const on = (s.entities ?? []).includes(entity);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={entity}
|
||||
type="button"
|
||||
variant={on ? 'secondary' : 'ghost-muted'}
|
||||
size="xs"
|
||||
shape="pill"
|
||||
disabled={disabled}
|
||||
data-testid={`guardrail-pii-${entity}`}
|
||||
onClick={() =>
|
||||
@@ -797,14 +815,9 @@ function CheckSettings({
|
||||
: [...(s.entities ?? []), entity],
|
||||
})
|
||||
}
|
||||
className={`rounded-full border px-2 py-0.5 text-[11px] ${
|
||||
on
|
||||
? 'border-violets-are-blue bg-violets-are-blue/10 text-violets-are-blue'
|
||||
: 'border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{entity}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{(s.entities ?? []).length === 0 && (
|
||||
@@ -842,13 +855,14 @@ function CheckSettings({
|
||||
<label className="mb-1 block text-xs font-medium">
|
||||
{t('agents.form.guardrails.policyText')}
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
rows={4}
|
||||
size="sm"
|
||||
value={s.policy ?? ''}
|
||||
disabled={disabled}
|
||||
data-testid="guardrail-policy-text"
|
||||
onChange={(e) => set({ policy: e.target.value })}
|
||||
className="bg-card w-full rounded-xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700"
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -900,7 +914,7 @@ function CheckSettings({
|
||||
disabled={disabled}
|
||||
onCommit={(confidence_threshold) => set({ confidence_threshold })}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-gray-500">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t('agents.form.guardrails.confidenceHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -43,13 +43,7 @@ export default function RunDetailDrawer({
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Run details</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -71,9 +71,9 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onClick={() => onSelect(run)}
|
||||
className="h-auto p-0 text-xs underline"
|
||||
className="-mx-2 -my-1"
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { formatDateOnly } from '../../utils/dateTimeUtils';
|
||||
@@ -195,7 +194,7 @@ export default function ScheduleFormModal({
|
||||
}
|
||||
size="md"
|
||||
mobileVariant="sheet"
|
||||
className="w-[min(560px,92vw)] sm:p-6"
|
||||
className="w-[min(560px,92vw)]"
|
||||
contentClassName="max-h-[80vh]"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
@@ -273,7 +272,7 @@ export default function ScheduleFormModal({
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={submit}
|
||||
className="rounded-3xl px-5"
|
||||
shape="pill"
|
||||
>
|
||||
{submitting
|
||||
? '…'
|
||||
@@ -302,15 +301,11 @@ function FrequencyTabs({ frequency, onChange, labels }: FrequencyTabsProps) {
|
||||
<Button
|
||||
key={f}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant={active ? 'outline' : 'ghost-muted'}
|
||||
size="xs"
|
||||
shape="pill"
|
||||
onClick={() => onChange(f)}
|
||||
className={cn(
|
||||
'h-auto flex-1 rounded-full px-3 py-1.5 text-xs font-medium',
|
||||
active
|
||||
? 'bg-card text-foreground hover:bg-card shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-transparent',
|
||||
)}
|
||||
className="flex-1"
|
||||
aria-pressed={active}
|
||||
>
|
||||
{labels[f]}
|
||||
@@ -380,7 +375,7 @@ function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
|
||||
size="sm"
|
||||
variant={active ? 'default' : 'outline'}
|
||||
onClick={() => set({ dayOfWeek: d.value })}
|
||||
className="rounded-full"
|
||||
shape="pill"
|
||||
aria-pressed={active}
|
||||
>
|
||||
{tDay(d.key)}
|
||||
@@ -464,13 +459,11 @@ function DatePicker({ value, onChange, placeholder }: DatePickerProps) {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="combobox"
|
||||
size="sm"
|
||||
aria-label={placeholder}
|
||||
className={cn(
|
||||
'h-9 justify-start gap-2 px-3 font-normal',
|
||||
!value && 'text-muted-foreground',
|
||||
)}
|
||||
data-placeholder={value ? undefined : ''}
|
||||
className="h-9 justify-start"
|
||||
>
|
||||
<CalendarIcon className="size-4 opacity-70" />
|
||||
{value ? formatDateLabel(value) : placeholder}
|
||||
|
||||
@@ -161,10 +161,10 @@ export default function SchedulerToolCallCard({
|
||||
{taskId && !cancelled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="destructive-outline"
|
||||
size="xs"
|
||||
onClick={cancel}
|
||||
className="text-destructive mt-2 text-xs"
|
||||
className="mt-2"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -214,15 +214,18 @@ export default function SchedulesView() {
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openEdit(schedule)}
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
|
||||
>
|
||||
{t('agents.schedules.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setSchedulePaused({
|
||||
@@ -235,7 +238,6 @@ export default function SchedulesView() {
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
|
||||
>
|
||||
{schedule.status === 'active'
|
||||
? t('agents.schedules.pause')
|
||||
@@ -243,13 +245,14 @@ export default function SchedulesView() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
runScheduleNow({ id: schedule.id, token }),
|
||||
)
|
||||
}
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
|
||||
>
|
||||
{t('agents.schedules.runNow')}
|
||||
</Button>
|
||||
@@ -257,8 +260,8 @@ export default function SchedulesView() {
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => requestDelete(schedule)}
|
||||
className="rounded-full px-5"
|
||||
>
|
||||
{t('agents.schedules.delete')}
|
||||
</Button>
|
||||
@@ -267,13 +270,13 @@ export default function SchedulesView() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
setExpanded(
|
||||
expanded === schedule.id ? null : schedule.id,
|
||||
)
|
||||
}
|
||||
className="mt-2 h-auto p-0 text-xs underline"
|
||||
className="mt-0.5 -ml-2"
|
||||
>
|
||||
{expanded === schedule.id
|
||||
? t('agents.schedules.hideRuns')
|
||||
@@ -324,9 +327,10 @@ export default function SchedulesView() {
|
||||
{schedule.status === 'active' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openEdit(schedule)}
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
|
||||
>
|
||||
{t('agents.schedules.edit')}
|
||||
</Button>
|
||||
@@ -336,8 +340,8 @@ export default function SchedulesView() {
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => requestDelete(schedule)}
|
||||
className="rounded-full px-5"
|
||||
>
|
||||
{t('agents.schedules.cancel')}
|
||||
</Button>
|
||||
|
||||
@@ -3,14 +3,14 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import ScheduleStatusBadge, {
|
||||
formatStatusLabel,
|
||||
getStatusClasses,
|
||||
getStatusVariant,
|
||||
type ScheduleStatusBadgeStatus,
|
||||
} from './StatusBadge';
|
||||
|
||||
type Case = {
|
||||
status: ScheduleStatusBadgeStatus;
|
||||
label: string;
|
||||
classes: string[];
|
||||
variant: string;
|
||||
};
|
||||
|
||||
const CASES: Case[] = [
|
||||
@@ -18,68 +18,67 @@ const CASES: Case[] = [
|
||||
{
|
||||
status: 'active',
|
||||
label: 'Active',
|
||||
classes: ['bg-green-100', 'text-green-700'],
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
status: 'paused',
|
||||
label: 'Paused',
|
||||
classes: ['bg-amber-100', 'text-amber-700'],
|
||||
variant: 'warning',
|
||||
},
|
||||
{
|
||||
status: 'completed',
|
||||
label: 'Completed',
|
||||
classes: ['bg-blue-100', 'text-blue-700'],
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
status: 'cancelled',
|
||||
label: 'Cancelled',
|
||||
classes: ['bg-muted', 'text-muted-foreground'],
|
||||
variant: 'neutral',
|
||||
},
|
||||
// Run statuses
|
||||
{
|
||||
status: 'success',
|
||||
label: 'Success',
|
||||
classes: ['bg-green-100', 'text-green-700'],
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
status: 'failed',
|
||||
label: 'Failed',
|
||||
classes: ['bg-red-100', 'text-red-700'],
|
||||
variant: 'destructive',
|
||||
},
|
||||
{
|
||||
status: 'skipped',
|
||||
label: 'Skipped',
|
||||
classes: ['bg-amber-100', 'text-amber-700'],
|
||||
variant: 'warning',
|
||||
},
|
||||
{
|
||||
status: 'running',
|
||||
label: 'Running',
|
||||
classes: ['bg-blue-100', 'text-blue-700'],
|
||||
variant: 'info',
|
||||
},
|
||||
{
|
||||
status: 'pending',
|
||||
label: 'Pending',
|
||||
classes: ['bg-muted', 'text-muted-foreground'],
|
||||
variant: 'neutral',
|
||||
},
|
||||
{
|
||||
status: 'timeout',
|
||||
label: 'Timeout',
|
||||
classes: ['bg-red-100', 'text-red-700'],
|
||||
variant: 'destructive',
|
||||
},
|
||||
];
|
||||
|
||||
describe('ScheduleStatusBadge', () => {
|
||||
it.each(CASES)(
|
||||
'renders $status with label "$label" and the right color classes',
|
||||
({ status, label, classes }) => {
|
||||
'renders $status with label "$label" as a $variant badge',
|
||||
({ status, label, variant }) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ScheduleStatusBadge status={status} />,
|
||||
);
|
||||
expect(html).toContain(label);
|
||||
expect(html).toContain(`data-status="${status}"`);
|
||||
for (const cls of classes) {
|
||||
expect(html).toContain(cls);
|
||||
}
|
||||
expect(html).toContain('data-slot="badge"');
|
||||
expect(html).toContain(`data-variant="${variant}"`);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -101,9 +100,9 @@ describe('formatStatusLabel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusClasses', () => {
|
||||
it('returns muted classes for an unknown status', () => {
|
||||
describe('getStatusVariant', () => {
|
||||
it('returns neutral for an unknown status', () => {
|
||||
// @ts-expect-error -- exercising the runtime fallback for unknown values
|
||||
expect(getStatusClasses('unknown')).toContain('text-muted-foreground');
|
||||
expect(getStatusVariant('unknown')).toBe('neutral');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
import type { ScheduleRunStatus, ScheduleStatus } from '../types/schedule';
|
||||
|
||||
@@ -9,23 +9,21 @@ export type ScheduleStatusBadgeProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<ScheduleStatusBadgeStatus, string> = {
|
||||
type StatusVariant = 'success' | 'warning' | 'info' | 'destructive' | 'neutral';
|
||||
|
||||
const STATUS_VARIANTS: Record<ScheduleStatusBadgeStatus, StatusVariant> = {
|
||||
// Schedule statuses
|
||||
active:
|
||||
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
paused:
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
completed: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
cancelled: 'bg-muted text-muted-foreground',
|
||||
active: 'success',
|
||||
paused: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'neutral',
|
||||
// Run statuses
|
||||
success:
|
||||
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
skipped:
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
running: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
pending: 'bg-muted text-muted-foreground',
|
||||
timeout: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
success: 'success',
|
||||
failed: 'destructive',
|
||||
skipped: 'warning',
|
||||
running: 'info',
|
||||
pending: 'neutral',
|
||||
timeout: 'destructive',
|
||||
};
|
||||
|
||||
/** Maps a status string to a human label (sentence-cased, underscores spaced). */
|
||||
@@ -34,9 +32,10 @@ export const formatStatusLabel = (status: string): string => {
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
};
|
||||
|
||||
/** Returns the Tailwind classes for a given schedule/run status pill. */
|
||||
export const getStatusClasses = (status: ScheduleStatusBadgeStatus): string =>
|
||||
STATUS_CLASSES[status] ?? 'bg-muted text-muted-foreground';
|
||||
/** Returns the Badge variant for a given schedule/run status. */
|
||||
export const getStatusVariant = (
|
||||
status: ScheduleStatusBadgeStatus,
|
||||
): StatusVariant => STATUS_VARIANTS[status] ?? 'neutral';
|
||||
|
||||
/** Colored pill for schedule or run statuses. */
|
||||
export default function ScheduleStatusBadge({
|
||||
@@ -44,15 +43,12 @@ export default function ScheduleStatusBadge({
|
||||
className,
|
||||
}: ScheduleStatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium',
|
||||
getStatusClasses(status),
|
||||
className,
|
||||
)}
|
||||
<Badge
|
||||
variant={getStatusVariant(status)}
|
||||
className={className}
|
||||
data-status={status}
|
||||
>
|
||||
{formatStatusLabel(status)}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -125,16 +125,12 @@ export default function TimezoneCombobox({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="combobox"
|
||||
role="combobox"
|
||||
size="sm"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'h-9 w-full justify-between gap-2 px-3 font-normal',
|
||||
!value && 'text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
data-placeholder={value ? undefined : ''}
|
||||
className={cn('w-full justify-between', className)}
|
||||
>
|
||||
{value ? (
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import modelService from '../../api/services/modelService';
|
||||
import userService from '../../api/services/userService';
|
||||
@@ -1557,8 +1559,8 @@ function WorkflowBuilderInner() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
shape="pill"
|
||||
onClick={navigateBackToAgents}
|
||||
className="text-muted-foreground rounded-full px-4 py-2 text-sm font-normal shadow-none"
|
||||
>
|
||||
{t('agents.backToAll')}
|
||||
</Button>
|
||||
@@ -1566,7 +1568,7 @@ function WorkflowBuilderInner() {
|
||||
{!canManageAgent && (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="max-w-xs truncate text-xl font-bold text-gray-900 dark:text-white"
|
||||
className="text-foreground max-w-xs truncate text-xl font-bold"
|
||||
title={workflowName || 'New Workflow'}
|
||||
>
|
||||
{workflowName || 'New Workflow'}
|
||||
@@ -1584,10 +1586,9 @@ function WorkflowBuilderInner() {
|
||||
<div className="relative flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowWorkflowSettings(!showWorkflowSettings)}
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground size-auto p-1"
|
||||
aria-label="Edit workflow details"
|
||||
title={
|
||||
workflowDescription
|
||||
@@ -1603,31 +1604,29 @@ function WorkflowBuilderInner() {
|
||||
className="border-border bg-card absolute top-full left-0 z-50 mt-2 w-80 rounded-xl border p-4 shadow-lg"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-1 block text-sm font-medium">
|
||||
Workflow Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={workflowName}
|
||||
onChange={(e) => setWorkflowName(e.target.value)}
|
||||
className="bg-card h-auto rounded-lg px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Enter workflow name"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-1 block text-sm font-medium">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={workflowDescription}
|
||||
onChange={(e) => setWorkflowDescription(e.target.value)}
|
||||
className="focus-visible:ring-ring/50 focus-visible:border-ring border-border bg-card w-full rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={3}
|
||||
placeholder="Describe what this workflow does"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-1 block text-sm font-medium">
|
||||
Agent Image
|
||||
</label>
|
||||
{currentAgentImage && !imageFile && (
|
||||
@@ -1637,7 +1636,7 @@ function WorkflowBuilderInner() {
|
||||
alt="Agent image"
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Current image
|
||||
</span>
|
||||
</div>
|
||||
@@ -1646,19 +1645,18 @@ function WorkflowBuilderInner() {
|
||||
showPreview
|
||||
maxFiles={1}
|
||||
previewSize={56}
|
||||
size="compact"
|
||||
onUpload={handleUpload}
|
||||
onRemove={() => setImageFile(null)}
|
||||
uploadText={[
|
||||
{
|
||||
text: 'Click to upload',
|
||||
colorClass: 'text-primary',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
text: ' or drag and drop',
|
||||
colorClass: 'text-muted-foreground',
|
||||
},
|
||||
]}
|
||||
className="border-border rounded-lg border-2 border-dashed p-3 text-center transition-colors"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Image updates are included the next time you save.
|
||||
@@ -1667,44 +1665,38 @@ function WorkflowBuilderInner() {
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label
|
||||
htmlFor="workflow-system-prompt-override"
|
||||
className="text-foreground block text-sm font-medium"
|
||||
>
|
||||
{t('agents.form.advanced.systemPromptOverride')}
|
||||
</label>
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{t(
|
||||
'agents.form.advanced.systemPromptOverrideDescription',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
<Switch
|
||||
id="workflow-system-prompt-override"
|
||||
checked={Boolean(
|
||||
currentAgent.allow_system_prompt_override,
|
||||
)}
|
||||
onCheckedChange={() =>
|
||||
setCurrentAgent((prev) => ({
|
||||
...prev,
|
||||
allow_system_prompt_override:
|
||||
!prev.allow_system_prompt_override,
|
||||
}))
|
||||
}
|
||||
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||
currentAgent.allow_system_prompt_override
|
||||
? 'bg-primary'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||
currentAgent.allow_system_prompt_override
|
||||
? ''
|
||||
: '-translate-x-5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleWorkflowSettingsDone}
|
||||
disabled={isPublishing}
|
||||
className="w-full rounded-lg text-white"
|
||||
className="w-full"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
@@ -1717,8 +1709,8 @@ function WorkflowBuilderInner() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
shape="pill"
|
||||
onClick={() => setAgentDetails('ACTIVE')}
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<Link size={16} />
|
||||
Access Details
|
||||
@@ -1728,9 +1720,9 @@ function WorkflowBuilderInner() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
shape="pill"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
disabled={isDeletingAgent}
|
||||
className="bg-card rounded-full border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-600 dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-900/10 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{isDeletingAgent ? 'Deleting...' : 'Delete'}
|
||||
@@ -1739,6 +1731,7 @@ function WorkflowBuilderInner() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
const validationErrors = validateWorkflow();
|
||||
if (validationErrors.length > 0) {
|
||||
@@ -1748,7 +1741,6 @@ function WorkflowBuilderInner() {
|
||||
}
|
||||
setShowPreview(true);
|
||||
}}
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<Play size={16} />
|
||||
Preview
|
||||
@@ -1757,11 +1749,9 @@ function WorkflowBuilderInner() {
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
className={`relative rounded-full px-6 py-2 text-sm font-medium shadow-sm ${
|
||||
canManageAgent && !hasSavableChanges
|
||||
? 'dark:bg-accent bg-gray-200 text-gray-500 hover:bg-gray-200 dark:text-gray-400'
|
||||
: 'text-white'
|
||||
}`}
|
||||
size="lg"
|
||||
shape="pill"
|
||||
className="relative"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
@@ -1779,42 +1769,43 @@ function WorkflowBuilderInner() {
|
||||
|
||||
{publishErrors.length > 0 && (
|
||||
<div className="pointer-events-none absolute top-20 right-0 left-0 z-50 flex justify-center px-4">
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="pointer-events-auto w-full max-w-md bg-red-50 shadow-lg dark:bg-red-950/20"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{errorContext === 'preview'
|
||||
? 'Unable to preview workflow'
|
||||
: canManageAgent
|
||||
? 'Unable to save workflow'
|
||||
: 'Unable to publish workflow'}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1 wrap-break-word">
|
||||
{publishErrors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPublishErrors([])}
|
||||
className="absolute top-4 right-4 size-auto p-0 text-red-700 hover:bg-transparent hover:text-red-900 dark:text-red-300 dark:hover:bg-transparent dark:hover:text-red-100"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</Alert>
|
||||
<div className="bg-card pointer-events-auto w-full max-w-md rounded-lg shadow-lg">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{errorContext === 'preview'
|
||||
? 'Unable to preview workflow'
|
||||
: canManageAgent
|
||||
? 'Unable to save workflow'
|
||||
: 'Unable to publish workflow'}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1 wrap-break-word">
|
||||
{publishErrors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
<div className="absolute top-2.5 right-2.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setPublishErrors([])}
|
||||
aria-label={t('agents.close')}
|
||||
>
|
||||
<X size={16} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="border-border bg-muted dark:bg-background flex w-64 flex-col gap-6 border-r p-4">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
|
||||
<h3 className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
|
||||
Core Nodes
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1823,10 +1814,10 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'agent')}
|
||||
>
|
||||
<div className="text-primary group-hover:bg-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-100 transition-colors group-hover:text-white dark:bg-purple-900/40">
|
||||
<div className="bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
|
||||
<Bot size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
AI Agent
|
||||
</span>
|
||||
</div>
|
||||
@@ -1835,10 +1826,10 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'end')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 transition-colors group-hover:bg-green-600 group-hover:text-white dark:bg-green-900/40 dark:text-green-300">
|
||||
<div className="bg-success/10 text-success group-hover:bg-success group-hover:text-success-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
|
||||
<Flag size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
End
|
||||
</span>
|
||||
</div>
|
||||
@@ -1847,10 +1838,10 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'note')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-600 transition-colors group-hover:bg-yellow-500 group-hover:text-white dark:bg-yellow-900/40 dark:text-yellow-300">
|
||||
<div className="bg-warning/10 text-warning group-hover:bg-warning group-hover:text-warning-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
|
||||
<StickyNote size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
Note
|
||||
</span>
|
||||
</div>
|
||||
@@ -1858,7 +1849,7 @@ function WorkflowBuilderInner() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
|
||||
<h3 className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
|
||||
Logic & Data
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1867,7 +1858,7 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'state')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<div className="bg-info/10 text-info group-hover:bg-info group-hover:text-info-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
|
||||
<Database size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -1884,7 +1875,7 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'condition')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 transition-colors group-hover:bg-orange-600 group-hover:text-white dark:bg-orange-900/40 dark:text-orange-300">
|
||||
<div className="bg-warning/10 text-warning group-hover:bg-warning group-hover:text-warning-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
|
||||
<GitBranch size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -1901,7 +1892,7 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'code')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 transition-colors group-hover:bg-indigo-600 group-hover:text-white dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||
<div className="bg-info/10 text-info group-hover:bg-info group-hover:text-info-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors">
|
||||
<Code2 size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -1950,7 +1941,6 @@ function WorkflowBuilderInner() {
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
aria-label="Undo"
|
||||
className="bg-card"
|
||||
>
|
||||
<Undo2 size={16} />
|
||||
</Button>
|
||||
@@ -1962,7 +1952,6 @@ function WorkflowBuilderInner() {
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
aria-label="Redo"
|
||||
className="bg-card"
|
||||
>
|
||||
<Redo2 size={16} />
|
||||
</Button>
|
||||
@@ -1973,7 +1962,7 @@ function WorkflowBuilderInner() {
|
||||
<>
|
||||
<div className="border-border bg-card shadow-modal absolute top-4 right-4 z-20 w-96 rounded-2xl border">
|
||||
<div className="border-border flex items-center justify-between border-b p-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
<h3 className="text-foreground font-semibold">
|
||||
{selectedNode.type === 'start' && 'Start Node'}
|
||||
{selectedNode.type === 'end' && 'End Node'}
|
||||
{selectedNode.type === 'agent' && 'AI Agent'}
|
||||
@@ -1984,22 +1973,21 @@ function WorkflowBuilderInner() {
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowNodeConfig(false)}
|
||||
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-gray-600 dark:hover:bg-transparent dark:hover:text-gray-200 [&_svg:not([class*='size-'])]:size-5"
|
||||
>
|
||||
<X size={20} />
|
||||
<X size={20} className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-y-auto p-4">
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<div className="mb-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="text-muted-foreground mb-1 text-xs">
|
||||
Node ID
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
<div className="text-foreground truncate font-mono text-xs">
|
||||
{selectedNode.id}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2008,7 +1996,7 @@ function WorkflowBuilderInner() {
|
||||
selectedNode.type !== 'end' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
@@ -2024,7 +2012,6 @@ function WorkflowBuilderInner() {
|
||||
label: e.target.value,
|
||||
})
|
||||
}
|
||||
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Enter node title"
|
||||
/>
|
||||
</div>
|
||||
@@ -2032,7 +2019,7 @@ function WorkflowBuilderInner() {
|
||||
{selectedNode.type === 'agent' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Agent Type
|
||||
</label>
|
||||
<Select
|
||||
@@ -2049,7 +2036,7 @@ function WorkflowBuilderInner() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger size="lg" className="w-full">
|
||||
<SelectValue placeholder="Select agent type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2063,7 +2050,7 @@ function WorkflowBuilderInner() {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Model
|
||||
</label>
|
||||
<Select
|
||||
@@ -2085,7 +2072,7 @@ function WorkflowBuilderInner() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger size="lg" className="w-full">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2141,10 +2128,10 @@ function WorkflowBuilderInner() {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
System Prompt
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={
|
||||
selectedNode.data.config?.system_prompt ??
|
||||
''
|
||||
@@ -2157,7 +2144,6 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={3}
|
||||
placeholder="System prompt for the agent"
|
||||
/>
|
||||
@@ -2182,7 +2168,7 @@ function WorkflowBuilderInner() {
|
||||
placeholder="Use {{ agent.variable }} for dynamic content"
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Output Variable
|
||||
</label>
|
||||
<Input
|
||||
@@ -2200,7 +2186,6 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Variable name for output"
|
||||
/>
|
||||
</div>
|
||||
@@ -2224,13 +2209,13 @@ function WorkflowBuilderInner() {
|
||||
/>
|
||||
<label
|
||||
htmlFor="stream_to_user"
|
||||
className="text-sm text-gray-700 dark:text-gray-300"
|
||||
className="text-foreground text-sm"
|
||||
>
|
||||
Stream output to user
|
||||
</label>
|
||||
</div>{' '}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Tools
|
||||
</label>
|
||||
<MultiSelect
|
||||
@@ -2255,7 +2240,7 @@ function WorkflowBuilderInner() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Sources
|
||||
</label>
|
||||
<MultiSelect
|
||||
@@ -2295,7 +2280,7 @@ function WorkflowBuilderInner() {
|
||||
helpText="Documents passed to this agent from uploads or upstream nodes."
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
File passing
|
||||
</label>
|
||||
<Select
|
||||
@@ -2311,7 +2296,7 @@ function WorkflowBuilderInner() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger size="lg" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2331,23 +2316,23 @@ function WorkflowBuilderInner() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Structured Output (JSON Schema)
|
||||
</label>
|
||||
{!selectedAgentModelSupportsStructuredOutput && (
|
||||
<p className="mb-2 text-xs text-red-600 dark:text-red-400">
|
||||
<p className="text-destructive mb-2 text-xs">
|
||||
Selected model does not support structured
|
||||
output.
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
<Textarea
|
||||
value={selectedAgentJsonSchemaText}
|
||||
onChange={(e) =>
|
||||
handleAgentJsonSchemaChange(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
className="font-mono"
|
||||
rows={8}
|
||||
placeholder={`{
|
||||
"type": "object",
|
||||
@@ -2362,8 +2347,8 @@ function WorkflowBuilderInner() {
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
selectedAgentJsonSchemaError
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-green-600 dark:text-green-400'
|
||||
? 'text-destructive'
|
||||
: 'text-success'
|
||||
}`}
|
||||
>
|
||||
{selectedAgentJsonSchemaError
|
||||
@@ -2377,17 +2362,16 @@ function WorkflowBuilderInner() {
|
||||
|
||||
{selectedNode.type === 'note' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Note Content
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={selectedNode.data.content || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateNodeData({
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={4}
|
||||
placeholder="Enter note content"
|
||||
/>
|
||||
@@ -2396,7 +2380,7 @@ function WorkflowBuilderInner() {
|
||||
|
||||
{selectedNode.type === 'state' && (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Assign values to workflow's state
|
||||
variables
|
||||
</p>
|
||||
@@ -2415,7 +2399,7 @@ function WorkflowBuilderInner() {
|
||||
className="border-border rounded-xl border p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
Assign value
|
||||
</span>
|
||||
{(
|
||||
@@ -2424,8 +2408,8 @@ function WorkflowBuilderInner() {
|
||||
).length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
variant="ghost-destructive"
|
||||
size="icon-xs"
|
||||
onClick={() => {
|
||||
const ops = [
|
||||
...(selectedNode.data.config
|
||||
@@ -2440,13 +2424,12 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-red-500 dark:hover:bg-transparent"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={op.expression}
|
||||
onChange={(e) => {
|
||||
const ops = [
|
||||
@@ -2465,7 +2448,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card dark:bg-accent mb-1 w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
className="mb-1"
|
||||
rows={2}
|
||||
placeholder="query"
|
||||
/>
|
||||
@@ -2484,7 +2467,7 @@ function WorkflowBuilderInner() {
|
||||
</a>
|
||||
</p>
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<span className="text-foreground mb-1 block text-sm font-medium">
|
||||
To variable
|
||||
</span>
|
||||
<Input
|
||||
@@ -2507,7 +2490,6 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="variable_name"
|
||||
/>
|
||||
</div>
|
||||
@@ -2516,7 +2498,7 @@ function WorkflowBuilderInner() {
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ops = [
|
||||
@@ -2531,7 +2513,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-auto gap-1 self-start rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
className="self-start"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
@@ -2541,13 +2523,19 @@ function WorkflowBuilderInner() {
|
||||
|
||||
{selectedNode.type === 'condition' && (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Create conditions to branch your workflow
|
||||
</p>
|
||||
<div className="border-border flex overflow-hidden rounded-lg border">
|
||||
<div className="border-border bg-card flex gap-1 rounded-xl border p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant={
|
||||
(selectedNode.data.config?.mode ||
|
||||
'simple') === 'simple'
|
||||
? 'outline'
|
||||
: 'ghost-muted'
|
||||
}
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
@@ -2556,18 +2544,19 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className={`h-auto flex-1 rounded-none px-3 py-1.5 text-xs font-medium ${
|
||||
(selectedNode.data.config?.mode ||
|
||||
'simple') === 'simple'
|
||||
? 'bg-primary hover:bg-primary text-white hover:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
className="flex-1"
|
||||
>
|
||||
Simple
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant={
|
||||
selectedNode.data.config?.mode ===
|
||||
'advanced'
|
||||
? 'outline'
|
||||
: 'ghost-muted'
|
||||
}
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
@@ -2576,12 +2565,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className={`h-auto flex-1 rounded-none px-3 py-1.5 text-xs font-medium ${
|
||||
selectedNode.data.config?.mode ===
|
||||
'advanced'
|
||||
? 'bg-primary hover:bg-primary text-white hover:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
className="flex-1"
|
||||
>
|
||||
Advanced
|
||||
</Button>
|
||||
@@ -2594,15 +2578,15 @@ function WorkflowBuilderInner() {
|
||||
className="border-border rounded-xl border p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-orange-600 dark:text-orange-400">
|
||||
<span className="text-warning text-sm font-semibold">
|
||||
{idx === 0 ? 'If' : 'Else if'}
|
||||
</span>
|
||||
{(selectedNode.data.config?.cases || [])
|
||||
.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
variant="ghost-destructive"
|
||||
size="icon-xs"
|
||||
onClick={() => {
|
||||
const cases =
|
||||
normalizeConditionCases([
|
||||
@@ -2633,7 +2617,6 @@ function WorkflowBuilderInner() {
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-red-500 dark:hover:bg-transparent"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
@@ -2659,7 +2642,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="bg-card dark:bg-accent mb-2 h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
className="mb-2"
|
||||
placeholder="Case name (optional)"
|
||||
/>
|
||||
{(selectedNode.data.config?.mode ||
|
||||
@@ -2695,7 +2678,6 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Variable"
|
||||
/>
|
||||
<Select
|
||||
@@ -2728,7 +2710,10 @@ function WorkflowBuilderInner() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-24 shrink-0">
|
||||
<SelectTrigger
|
||||
size="lg"
|
||||
className="w-24 shrink-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2787,13 +2772,12 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={c.expression}
|
||||
onChange={(e) => {
|
||||
const cases = [
|
||||
@@ -2812,7 +2796,6 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card dark:bg-accent w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={2}
|
||||
placeholder='Enter condition, e.g. query.contains("refund")'
|
||||
/>
|
||||
@@ -2839,7 +2822,7 @@ function WorkflowBuilderInner() {
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const cases = normalizeConditionCases([
|
||||
@@ -2860,7 +2843,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-auto gap-1 self-start rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
className="self-start"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
@@ -2870,15 +2853,15 @@ function WorkflowBuilderInner() {
|
||||
|
||||
{selectedNode.type === 'code' && (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Run code in the workflow sandbox. Produced
|
||||
files are saved as artifacts.
|
||||
</p>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Code
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={selectedNode.data.config?.code ?? ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateNodeData({
|
||||
@@ -2888,7 +2871,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
className="font-mono"
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
placeholder={'print("hello world")'}
|
||||
@@ -2910,7 +2893,7 @@ function WorkflowBuilderInner() {
|
||||
helpText="Artifacts/upstream refs staged as files in the sandbox (one becomes inputs/<name>)."
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Output Variable
|
||||
</label>
|
||||
<Input
|
||||
@@ -2927,12 +2910,11 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Variable name for output"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Timeout (seconds)
|
||||
</label>
|
||||
<Input
|
||||
@@ -2958,20 +2940,19 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
Structured Output (JSON Schema)
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={selectedCodeJsonSchemaText}
|
||||
onChange={(e) =>
|
||||
handleCodeJsonSchemaChange(e.target.value)
|
||||
}
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
className="font-mono"
|
||||
rows={6}
|
||||
placeholder={`{
|
||||
"type": "object",
|
||||
@@ -2985,8 +2966,8 @@ function WorkflowBuilderInner() {
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
selectedCodeJsonSchemaError
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-green-600 dark:text-green-400'
|
||||
? 'text-destructive'
|
||||
: 'text-success'
|
||||
}`}
|
||||
>
|
||||
{selectedCodeJsonSchemaError
|
||||
@@ -3006,7 +2987,8 @@ function WorkflowBuilderInner() {
|
||||
variant="destructive-outline"
|
||||
onClick={handleDeleteNode}
|
||||
disabled={selectedNode?.type === 'start'}
|
||||
className="w-full rounded-full border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-600 dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-900/10 dark:hover:text-red-400"
|
||||
shape="pill"
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{selectedNode?.type === 'start'
|
||||
@@ -3024,7 +3006,7 @@ function WorkflowBuilderInner() {
|
||||
<SheetContent
|
||||
side="right"
|
||||
title="Workflow preview"
|
||||
className="bg-card w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
|
||||
className="w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
|
||||
>
|
||||
<WorkflowPreview
|
||||
workflowId={workflowId}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import type { WorkflowNode } from '../types/workflow';
|
||||
import type { WorkflowExecutionStep } from './workflowPreviewSlice';
|
||||
import { WorkflowMiniMap } from './WorkflowPreview';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const node = (
|
||||
id: string,
|
||||
type: WorkflowNode['type'],
|
||||
y: number,
|
||||
): WorkflowNode => ({
|
||||
id,
|
||||
type,
|
||||
title: id,
|
||||
position: { x: 0, y },
|
||||
data: {},
|
||||
});
|
||||
|
||||
const nodes = [
|
||||
node('start', 'start', 0),
|
||||
node('done', 'agent', 1),
|
||||
node('broken', 'agent', 2),
|
||||
node('busy', 'agent', 3),
|
||||
];
|
||||
|
||||
const step = (
|
||||
nodeId: string,
|
||||
status: WorkflowExecutionStep['status'],
|
||||
): WorkflowExecutionStep => ({
|
||||
nodeId,
|
||||
nodeType: 'agent',
|
||||
nodeTitle: nodeId,
|
||||
status,
|
||||
});
|
||||
|
||||
const steps = [
|
||||
step('done', 'completed'),
|
||||
step('broken', 'failed'),
|
||||
step('busy', 'running'),
|
||||
];
|
||||
|
||||
describe('WorkflowMiniMap', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const rows = (activeNodeId: string | null = null) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkflowMiniMap
|
||||
nodes={nodes}
|
||||
activeNodeId={activeNodeId}
|
||||
executionSteps={steps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const byName: Record<string, HTMLButtonElement> = {};
|
||||
container.querySelectorAll<HTMLButtonElement>('button').forEach((b) => {
|
||||
byName[b.textContent ?? ''] = b;
|
||||
});
|
||||
return byName;
|
||||
};
|
||||
|
||||
const tokens = (el: HTMLElement) => el.className.split(/\s+/);
|
||||
|
||||
it('pins the success fill on hover for a completed row', () => {
|
||||
const done = tokens(rows().done);
|
||||
expect(done).toEqual(
|
||||
expect.arrayContaining([
|
||||
'bg-success/10',
|
||||
'hover:bg-success/10',
|
||||
'dark:hover:bg-success/10',
|
||||
'hover:opacity-80',
|
||||
]),
|
||||
);
|
||||
expect(done).not.toContain('hover:bg-accent');
|
||||
expect(done).not.toContain('dark:hover:bg-input/50');
|
||||
});
|
||||
|
||||
it('pins each status fill on hover', () => {
|
||||
const r = rows();
|
||||
expect(tokens(r.broken)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'hover:bg-destructive/10',
|
||||
'dark:hover:bg-destructive/10',
|
||||
]),
|
||||
);
|
||||
expect(tokens(r.busy)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'hover:bg-primary/10',
|
||||
'dark:hover:bg-primary/10',
|
||||
'animate-pulse',
|
||||
]),
|
||||
);
|
||||
expect(tokens(r.Start)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'bg-muted',
|
||||
'hover:bg-muted',
|
||||
'dark:hover:bg-muted',
|
||||
]),
|
||||
);
|
||||
expect(r.Start.disabled).toBe(true);
|
||||
expect(tokens(r.Start)).not.toContain('hover:opacity-80');
|
||||
});
|
||||
|
||||
it('pins the active fill on hover and keeps the ring', () => {
|
||||
const done = tokens(rows('done').done);
|
||||
expect(done).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ring-2',
|
||||
'ring-primary',
|
||||
'hover:bg-primary/10',
|
||||
'dark:hover:bg-primary/10',
|
||||
]),
|
||||
);
|
||||
expect(done).not.toContain('hover:bg-accent');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
CircleAlert,
|
||||
Code2,
|
||||
Database,
|
||||
FileBox,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
MessageScroller,
|
||||
@@ -77,13 +79,13 @@ const NODE_ICONS: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
start: 'text-green-600 dark:text-green-400',
|
||||
agent: 'text-purple-600 dark:text-purple-400',
|
||||
end: 'text-gray-600 dark:text-gray-400',
|
||||
note: 'text-yellow-600 dark:text-yellow-400',
|
||||
state: 'text-blue-600 dark:text-blue-400',
|
||||
condition: 'text-orange-600 dark:text-orange-400',
|
||||
code: 'text-indigo-600 dark:text-indigo-400',
|
||||
start: 'text-success',
|
||||
agent: 'text-primary',
|
||||
end: 'text-destructive',
|
||||
note: 'text-warning',
|
||||
state: 'text-info',
|
||||
condition: 'text-warning',
|
||||
code: 'text-info',
|
||||
};
|
||||
|
||||
function ExecutionDetails({
|
||||
@@ -116,17 +118,18 @@ function ExecutionDetails({
|
||||
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<div className="flex h-[26px] w-[30px] items-center justify-center">
|
||||
<Workflow className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
<Workflow className="text-muted-foreground h-5 w-5" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className="h-auto gap-2 px-0 py-0 hover:bg-transparent"
|
||||
className="-ml-2.5"
|
||||
>
|
||||
<p className="text-base font-semibold">
|
||||
Execution Details
|
||||
<span className="ml-1.5 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
|
||||
({completedSteps.length}{' '}
|
||||
{completedSteps.length === 1 ? 'step' : 'steps'})
|
||||
</span>
|
||||
@@ -178,7 +181,7 @@ function ExecutionDetails({
|
||||
className="bg-muted dark:bg-accent rounded-xl p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
<span className="text-muted-foreground flex h-5 w-5 shrink-0 items-center justify-center text-xs font-medium">
|
||||
{stepIndex + 1}.
|
||||
</span>
|
||||
<div
|
||||
@@ -191,15 +194,15 @@ function ExecutionDetails({
|
||||
<Circle className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 truncate font-medium text-gray-900 dark:text-white">
|
||||
<span className="text-foreground min-w-0 truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
<div className="ml-auto shrink-0">
|
||||
{step.status === 'completed' && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<CheckCircle2 className="text-success h-4 w-4" />
|
||||
)}
|
||||
{step.status === 'failed' && (
|
||||
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,23 +210,24 @@ function ExecutionDetails({
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{hasOutput && (
|
||||
<div className="bg-muted rounded-lg p-2">
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Output:{' '}
|
||||
</span>
|
||||
<span className="wrap-break-word whitespace-pre-wrap text-gray-900 dark:text-gray-100">
|
||||
<span className="text-foreground wrap-break-word whitespace-pre-wrap">
|
||||
{truncateText(formattedOutput, 300)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{step.error && (
|
||||
<div className="rounded-lg bg-red-50 p-2 dark:bg-red-900/30">
|
||||
<span className="font-medium text-red-700 dark:text-red-300">
|
||||
Error:{' '}
|
||||
</span>
|
||||
<span className="wrap-break-word whitespace-pre-wrap text-red-800 dark:text-red-200">
|
||||
{step.error}
|
||||
</span>
|
||||
</div>
|
||||
<Alert variant="destructive" role="status">
|
||||
<CircleAlert />
|
||||
<AlertDescription>
|
||||
<span className="font-medium">Error: </span>
|
||||
<span className="wrap-break-word whitespace-pre-wrap">
|
||||
{step.error}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{stateVars.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -232,11 +236,11 @@ function ExecutionDetails({
|
||||
key={key}
|
||||
className="bg-muted inline-flex items-center rounded-lg px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="max-w-[100px] truncate font-medium text-gray-600 dark:text-gray-400">
|
||||
<span className="text-muted-foreground max-w-[100px] truncate font-medium">
|
||||
{key}:
|
||||
</span>
|
||||
<span
|
||||
className="ml-1 max-w-[200px] truncate text-gray-900 dark:text-gray-100"
|
||||
className="text-foreground ml-1 max-w-[200px] truncate"
|
||||
title={formatValue(value)}
|
||||
>
|
||||
{truncateText(formatValue(value), 50)}
|
||||
@@ -272,13 +276,14 @@ function RunArtifactsSection({
|
||||
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<div className="flex h-[26px] w-[30px] items-center justify-center">
|
||||
<FileBox className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
<FileBox className="text-muted-foreground h-5 w-5" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className="h-auto gap-2 px-0 py-0 hover:bg-transparent"
|
||||
className="-ml-2.5"
|
||||
>
|
||||
<p className="text-base font-semibold">Artifacts</p>
|
||||
<img
|
||||
@@ -312,7 +317,7 @@ function RunArtifactsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowMiniMap({
|
||||
export function WorkflowMiniMap({
|
||||
nodes,
|
||||
activeNodeId,
|
||||
executionSteps,
|
||||
@@ -341,26 +346,6 @@ function WorkflowMiniMap({
|
||||
return step?.status || 'pending';
|
||||
};
|
||||
|
||||
const getStatusColor = (nodeId: string) => {
|
||||
const status = getNodeStatus(nodeId);
|
||||
const isActive = nodeId === activeNodeId;
|
||||
|
||||
if (isActive) {
|
||||
return 'ring-2 ring-purple-500 bg-purple-100 dark:bg-purple-900/50';
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 dark:bg-green-900/30 border-green-300 dark:border-green-700';
|
||||
case 'running':
|
||||
return 'bg-purple-100 dark:bg-purple-900/30 border-purple-300 dark:border-purple-700 animate-pulse';
|
||||
case 'failed':
|
||||
return 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700';
|
||||
default:
|
||||
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const executedOrder = new Map(executionSteps.map((s, i) => [s.nodeId, i]));
|
||||
const startNode = nodes.find((node) => node.type === 'start');
|
||||
const visibleNodeIds = new Set(executionSteps.map((step) => step.nodeId));
|
||||
@@ -392,55 +377,73 @@ function WorkflowMiniMap({
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{sortedNodes.map((node, index) => (
|
||||
<div key={node.id} className="relative">
|
||||
{index < sortedNodes.length - 1 && (
|
||||
<div className="absolute top-12 left-4 h-3 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
|
||||
disabled={!hasStepData(node.id)}
|
||||
className={cn(
|
||||
'h-12 w-full justify-start gap-2 px-3 text-xs disabled:opacity-100',
|
||||
getStatusColor(node.id),
|
||||
hasStepData(node.id) && 'hover:opacity-80',
|
||||
{sortedNodes.map((node, index) => {
|
||||
const status = getNodeStatus(node.id);
|
||||
const isActive = node.id === activeNodeId;
|
||||
return (
|
||||
<div key={node.id} className="relative">
|
||||
{index < sortedNodes.length - 1 && (
|
||||
<div className="bg-border absolute top-12 left-4 h-3 w-0.5" />
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
|
||||
disabled={!hasStepData(node.id)}
|
||||
/* eslint-disable shadcn/no-restyle --
|
||||
Preview minimap status row: the fill, border and ring follow the step status (success, primary running + pulse,
|
||||
destructive, muted pending), pending and running rows stay unfaded while disabled, and clickable rows dim on hover.
|
||||
No Button variant is status-tinted. See DESIGN.md, Approved exceptions. */
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
|
||||
NODE_COLORS[node.type] || NODE_COLORS.state,
|
||||
'h-12 w-full justify-start disabled:opacity-100',
|
||||
isActive
|
||||
? 'bg-primary/10 ring-primary hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/10 ring-2'
|
||||
: status === 'completed'
|
||||
? 'border-success/50 bg-success/10 hover:bg-success/10 dark:border-success/50 dark:bg-success/10 dark:hover:bg-success/10'
|
||||
: status === 'running'
|
||||
? 'border-primary/50 bg-primary/10 hover:bg-primary/10 dark:border-primary/50 dark:bg-primary/10 dark:hover:bg-primary/10 animate-pulse'
|
||||
: status === 'failed'
|
||||
? 'border-destructive/50 bg-destructive/10 hover:bg-destructive/10 dark:border-destructive/50 dark:bg-destructive/10 dark:hover:bg-destructive/10'
|
||||
: 'border-border bg-muted hover:bg-muted dark:border-border dark:bg-muted dark:hover:bg-muted',
|
||||
hasStepData(node.id) && 'hover:opacity-80',
|
||||
)}
|
||||
/* eslint-enable shadcn/no-restyle */
|
||||
>
|
||||
{NODE_ICONS[node.type] || <Circle className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium text-gray-700 dark:text-gray-200">
|
||||
{getNodeDisplayName(node)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
|
||||
NODE_COLORS[node.type] || NODE_COLORS.state,
|
||||
)}
|
||||
>
|
||||
{NODE_ICONS[node.type] || <Circle className="h-3 w-3" />}
|
||||
</div>
|
||||
{getNodeSubtitle(node) && (
|
||||
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{getNodeSubtitle(node)}
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-foreground truncate font-medium">
|
||||
{getNodeDisplayName(node)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{getNodeStatus(node.id) === 'running' && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
|
||||
)}
|
||||
{getNodeStatus(node.id) === 'completed' && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
{getNodeStatus(node.id) === 'failed' && (
|
||||
<XCircle className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{getNodeSubtitle(node) && (
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{getNodeSubtitle(node)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{status === 'running' && (
|
||||
<Loader2 className="text-primary h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{status === 'completed' && (
|
||||
<CheckCircle2 className="text-success h-3 w-3" />
|
||||
)}
|
||||
{status === 'failed' && (
|
||||
<XCircle className="text-destructive h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -605,21 +608,19 @@ export default function WorkflowPreview({
|
||||
<div className="bg-card flex h-full flex-col">
|
||||
<div className="border-border flex h-[77px] items-center justify-between border-b px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted flex items-center justify-center rounded-full p-3 text-gray-600 dark:text-gray-300">
|
||||
<div className="bg-muted text-muted-foreground flex items-center justify-center rounded-full p-3">
|
||||
<Play className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Preview
|
||||
</h2>
|
||||
<p className="max-w-md truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
<h2 className="text-foreground text-xl font-bold">Preview</h2>
|
||||
<p className="text-muted-foreground max-w-md truncate text-xs">
|
||||
{workflowData.name}
|
||||
{workflowData.description && ` - ${workflowData.description}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status === 'loading' && (
|
||||
<span className="text-primary dark:text-primary flex items-center gap-1 text-xs">
|
||||
<span className="text-primary flex items-center gap-1 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Running
|
||||
</span>
|
||||
@@ -629,7 +630,7 @@ export default function WorkflowPreview({
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="border-border flex w-64 shrink-0 flex-col border-r">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h3 className="text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
|
||||
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
Workflow
|
||||
</h3>
|
||||
</div>
|
||||
@@ -650,16 +651,19 @@ export default function WorkflowPreview({
|
||||
{queries.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4">
|
||||
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
|
||||
<MessageSquare className="size-6 text-gray-600 dark:text-gray-300" />
|
||||
<MessageSquare className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
<p className="text-foreground text-xl font-semibold">
|
||||
Test the workflow
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageScrollerProvider autoScroll>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport className="scrollbar-thin px-4 pt-4">
|
||||
<MessageScrollerViewport
|
||||
/* eslint-disable-next-line shadcn/no-restyle -- MessageScroller padding: the primitive measures Content's padding-block for its scroll math, and the Viewport's top gap must scroll with the messages. See DESIGN.md, Approved exceptions. */
|
||||
className="scrollbar-thin px-4 pt-4"
|
||||
>
|
||||
<MessageScrollerContent className="w-full">
|
||||
{queries.map((query, index) => {
|
||||
const querySteps = query.executionSteps || [];
|
||||
@@ -776,7 +780,7 @@ export default function WorkflowPreview({
|
||||
</div>
|
||||
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
|
||||
{sendBlockedMessage && (
|
||||
<p className="text-xs text-red-500" role="alert">
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{sendBlockedMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function WorkflowRunArtifacts({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-3 py-4 text-sm">
|
||||
<Spinner size="small" /> Loading artifacts...
|
||||
</div>
|
||||
);
|
||||
@@ -139,7 +139,7 @@ export default function WorkflowRunArtifacts({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-3 text-sm text-red-500">
|
||||
<div className="text-destructive flex items-center justify-between gap-2 px-3 py-3 text-sm">
|
||||
<span>{error}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={loadList}>
|
||||
Retry
|
||||
@@ -150,7 +150,7 @@ export default function WorkflowRunArtifacts({
|
||||
|
||||
if (!artifacts || artifacts.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="text-muted-foreground px-3 py-3 text-sm">
|
||||
{inProgress
|
||||
? 'Run in progress — artifacts appear here as nodes produce them.'
|
||||
: 'No artifacts produced by this run.'}
|
||||
@@ -166,7 +166,6 @@ export default function WorkflowRunArtifacts({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1 px-2"
|
||||
onClick={() => setSelectedId(null)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -180,7 +179,7 @@ export default function WorkflowRunArtifacts({
|
||||
</div>
|
||||
) : detailError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-red-500">{detailError}</p>
|
||||
<p className="text-destructive text-sm">{detailError}</p>
|
||||
</div>
|
||||
) : detail ? (
|
||||
<DocumentArtifactView
|
||||
@@ -201,14 +200,14 @@ export default function WorkflowRunArtifacts({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedId(artifact.id)}
|
||||
className="h-auto w-full justify-start gap-3 px-3 py-2 text-left"
|
||||
className="h-auto w-full justify-start text-left"
|
||||
>
|
||||
<FileBox className="h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400" />
|
||||
<FileBox className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div className="text-foreground truncate text-sm font-medium">
|
||||
{artifact.title || `Artifact ${artifact.id.slice(0, 8)}`}
|
||||
</div>
|
||||
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{artifact.kind || 'file'}
|
||||
{artifact.current_version != null
|
||||
? ` · v${artifact.current_version}`
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function NodeDocumentsControl({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<div className="border-border bg-card flex gap-1 rounded-xl border p-1">
|
||||
@@ -68,14 +68,10 @@ export default function NodeDocumentsControl({
|
||||
<Button
|
||||
key={optionMode}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant={mode === optionMode ? 'outline' : 'ghost-muted'}
|
||||
size="xs"
|
||||
onClick={() => selectMode(optionMode)}
|
||||
className={`h-auto flex-1 rounded-lg px-2 py-1.5 text-xs font-medium ${
|
||||
mode === optionMode
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
className="flex-1"
|
||||
>
|
||||
{modeLabel}
|
||||
</Button>
|
||||
@@ -104,15 +100,13 @@ export default function NodeDocumentsControl({
|
||||
addRef();
|
||||
}
|
||||
}}
|
||||
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Add ref (e.g. A1)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant="ghost-muted"
|
||||
onClick={addRef}
|
||||
className="h-auto shrink-0 gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Braces, Plus, Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Edge, Node } from 'reactflow';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -221,7 +228,7 @@ function HighlightedOverlay({ text }: { text: string }) {
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i} className="text-gray-900 dark:text-white">
|
||||
<span key={i} className="text-foreground">
|
||||
{part}
|
||||
</span>
|
||||
),
|
||||
@@ -260,7 +267,7 @@ function VariableListWithSearch({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search variables..."
|
||||
className="h-auto rounded-none border-0 px-0 py-0 text-sm text-gray-800 shadow-none focus-visible:ring-0 md:text-sm dark:border-0 dark:text-gray-200"
|
||||
variant="bare"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -280,15 +287,16 @@ function VariableListWithSearch({
|
||||
key={`${section}-${v.templatePath}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(v.templatePath);
|
||||
}}
|
||||
className="h-auto w-full justify-start gap-2 rounded-none px-3 py-1.5 text-left text-sm font-normal"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Braces className="text-primary h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate font-medium text-gray-800 dark:text-gray-200">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{v.label}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -439,7 +447,7 @@ export default function PromptTextArea({
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
@@ -455,9 +463,7 @@ export default function PromptTextArea({
|
||||
{value ? (
|
||||
<HighlightedOverlay text={value} />
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{placeholder}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -481,11 +487,7 @@ export default function PromptTextArea({
|
||||
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
}}
|
||||
className="focus-visible:ring-ring/50 focus-visible:border-ring relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm caret-black outline-none focus-visible:ring-[3px] dark:caret-white"
|
||||
style={{
|
||||
color: 'transparent',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
className="focus-visible:ring-ring/50 focus-visible:border-ring relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm text-transparent caret-black outline-none focus-visible:ring-3 dark:caret-white"
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
@@ -494,11 +496,7 @@ export default function PromptTextArea({
|
||||
<div className="absolute right-4 bottom-1.5 z-10">
|
||||
<Popover open={contextOpen} onOpenChange={setContextOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-primary hover:bg-primary/10 h-auto gap-1 px-2 py-1 text-xs font-medium"
|
||||
>
|
||||
<Button type="button" variant="link" size="xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
Add context
|
||||
</Button>
|
||||
@@ -506,7 +504,7 @@ export default function PromptTextArea({
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
className="border-border bg-card w-60 rounded-xl border p-0 shadow-lg"
|
||||
className="w-60 p-0"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<VariableListWithSearch
|
||||
@@ -520,8 +518,13 @@ export default function PromptTextArea({
|
||||
{showDropdown && filtered.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="border-border bg-card absolute z-50 w-64 rounded-xl border shadow-lg"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
className="border-border bg-card absolute top-(--dropdown-top) left-(--dropdown-left) z-50 w-64 rounded-xl border shadow-lg"
|
||||
style={
|
||||
{
|
||||
'--dropdown-top': `${dropdownPos.top}px`,
|
||||
'--dropdown-left': `${dropdownPos.left}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<VariableListWithSearch
|
||||
variables={filtered}
|
||||
|
||||
@@ -23,28 +23,28 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
|
||||
}) => {
|
||||
let bgColor = 'bg-card';
|
||||
let borderColor = 'border-border';
|
||||
let iconBg = 'bg-gray-100 dark:bg-gray-800';
|
||||
let iconColor = 'text-gray-600 dark:text-gray-400';
|
||||
let iconBg = 'bg-muted';
|
||||
let iconColor = 'text-muted-foreground';
|
||||
|
||||
if (selected) {
|
||||
borderColor = 'border-primary ring-2 ring-primary';
|
||||
}
|
||||
|
||||
if (type === 'start') {
|
||||
iconBg = 'bg-green-100 dark:bg-green-900/30';
|
||||
iconColor = 'text-green-600 dark:text-green-400';
|
||||
iconBg = 'bg-success/10';
|
||||
iconColor = 'text-success';
|
||||
} else if (type === 'end') {
|
||||
iconBg = 'bg-red-100 dark:bg-red-900/30';
|
||||
iconColor = 'text-red-600 dark:text-red-400';
|
||||
iconBg = 'bg-destructive/10';
|
||||
iconColor = 'text-destructive';
|
||||
} else if (type === 'state') {
|
||||
iconBg = 'bg-gray-100 dark:bg-gray-800';
|
||||
iconColor = 'text-gray-600 dark:text-gray-400';
|
||||
iconBg = 'bg-muted';
|
||||
iconColor = 'text-muted-foreground';
|
||||
} else if (type === 'condition') {
|
||||
iconBg = 'bg-orange-100 dark:bg-orange-900/30';
|
||||
iconColor = 'text-orange-600 dark:text-orange-400';
|
||||
iconBg = 'bg-warning/10';
|
||||
iconColor = 'text-warning';
|
||||
} else if (type === 'code') {
|
||||
iconBg = 'bg-indigo-100 dark:bg-indigo-900/30';
|
||||
iconColor = 'text-indigo-600 dark:text-indigo-400';
|
||||
iconBg = 'bg-info/10';
|
||||
iconColor = 'text-info';
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -58,7 +58,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
isConnectable={true}
|
||||
className="hover:bg-primary/90! border-card! -left-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
|
||||
className="hover:bg-primary/90! border-card! bg-muted-foreground! -left-1! h-3! w-3! rounded-full! border-2! transition-colors!"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -70,13 +70,13 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pr-3">
|
||||
<div
|
||||
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
|
||||
className="text-foreground truncate text-sm font-semibold"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -88,7 +88,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
isConnectable={true}
|
||||
className="hover:bg-primary/90! border-card! -right-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
|
||||
className="hover:bg-primary/90! border-card! bg-muted-foreground! -right-1! h-3! w-3! rounded-full! border-2! transition-colors!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,19 +30,17 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
|
||||
<div className="flex flex-col gap-1">
|
||||
{codeHint ? (
|
||||
<div
|
||||
className="truncate font-mono text-xs text-gray-500 dark:text-gray-400"
|
||||
className="text-muted-foreground truncate font-mono text-xs"
|
||||
title={code}
|
||||
>
|
||||
{codeHint}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
No code yet
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">No code yet</div>
|
||||
)}
|
||||
{config.output_variable && (
|
||||
<div
|
||||
className="truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={`Output: ${config.output_variable}`}
|
||||
>
|
||||
Output: {config.output_variable}
|
||||
|
||||
@@ -38,7 +38,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
<div
|
||||
className={`bg-card relative rounded-2xl border shadow-md transition-all ${
|
||||
selected
|
||||
? 'border-primary dark:ring-primary scale-105 ring-2 ring-purple-300'
|
||||
? 'border-primary ring-primary scale-105 ring-2'
|
||||
: 'border-border hover:shadow-lg'
|
||||
}`}
|
||||
style={{ minWidth: 180, maxWidth: 220, height }}
|
||||
@@ -47,21 +47,21 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
isConnectable
|
||||
className="hover:bg-primary/90! border-card! top-1/2! -left-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
|
||||
className="hover:bg-primary/90! border-card! bg-muted-foreground! top-1/2! -left-1! h-3! w-3! rounded-full! border-2! transition-colors!"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400">
|
||||
<div className="bg-warning/10 text-warning flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||
<GitBranch size={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
<div
|
||||
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
|
||||
className="text-foreground truncate text-sm font-semibold"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 uppercase">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
{data.config?.mode || 'simple'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,12 +74,12 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
className="flex items-center gap-1"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
<span className="shrink-0 text-xs font-medium text-orange-600 dark:text-orange-400">
|
||||
<span className="text-warning shrink-0 text-xs font-medium">
|
||||
{i === 0 ? 'If' : 'Else if'}
|
||||
</span>
|
||||
{c.name && (
|
||||
<span
|
||||
className="truncate text-xs text-gray-600 dark:text-gray-400"
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={c.name}
|
||||
>
|
||||
{c.name}
|
||||
@@ -88,7 +88,9 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1" style={{ height: ROW_HEIGHT }}>
|
||||
<span className="text-xs font-medium text-gray-500">Else</span>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Else
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +102,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
id={c.sourceHandle}
|
||||
isConnectable
|
||||
style={{ top: getHandleTop(i, totalOutputs) }}
|
||||
className="hover:bg-primary/90! dark:border-border! -right-1! h-3! w-3! rounded-full! border-2! border-white! bg-orange-400! transition-colors"
|
||||
className="hover:bg-primary/90! border-card! bg-warning! -right-1! h-3! w-3! rounded-full! border-2! transition-colors"
|
||||
/>
|
||||
))}
|
||||
<Handle
|
||||
@@ -109,7 +111,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
id="else"
|
||||
isConnectable
|
||||
style={{ top: getHandleTop(cases.length, totalOutputs) }}
|
||||
className="hover:bg-primary/90! border-card! -right-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
|
||||
className="hover:bg-primary/90! border-card! bg-muted-foreground! -right-1! h-3! w-3! rounded-full! border-2! transition-colors!"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
|
||||
<div className="flex flex-col gap-1">
|
||||
{operations.length > 0 ? (
|
||||
<div
|
||||
className="truncate text-xs text-gray-500"
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={`${operations.length} operation(s)`}
|
||||
>
|
||||
{operations.length} variable{operations.length !== 1 ? 's' : ''}
|
||||
@@ -39,14 +39,14 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
|
||||
) : hasLegacy ? (
|
||||
<>
|
||||
<div
|
||||
className="truncate text-xs text-gray-500 uppercase"
|
||||
className="text-muted-foreground truncate text-xs uppercase"
|
||||
title={`Variable: ${data.variable}`}
|
||||
>
|
||||
{data.variable}
|
||||
</div>
|
||||
{data.value && (
|
||||
<div
|
||||
className="truncate text-xs text-blue-600 dark:text-blue-400"
|
||||
className="text-info truncate text-xs"
|
||||
title={`Value: ${data.value}`}
|
||||
>
|
||||
{data.value}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Bot, Flag, Play, StickyNote } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { BaseNode } from './BaseNode';
|
||||
import CodeNode from './CodeNode';
|
||||
import ConditionNode from './ConditionNode';
|
||||
@@ -19,7 +21,9 @@ export const StartNode = memo(function StartNode({
|
||||
handles={{ target: false, source: true }}
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
<div className="text-xs text-gray-500">Entry point of the workflow</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Entry point of the workflow
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
});
|
||||
@@ -37,7 +41,7 @@ export const EndNode = memo(function EndNode({
|
||||
handles={{ target: true, source: false }}
|
||||
icon={<Flag size={16} />}
|
||||
>
|
||||
<div className="text-xs text-gray-500">Workflow completion</div>
|
||||
<div className="text-muted-foreground text-xs">Workflow completion</div>
|
||||
</BaseNode>
|
||||
);
|
||||
});
|
||||
@@ -70,7 +74,7 @@ export const AgentNode = memo(function AgentNode({
|
||||
<div className="flex flex-col gap-1">
|
||||
{config.agent_type && (
|
||||
<div
|
||||
className="truncate text-xs text-gray-500 uppercase"
|
||||
className="text-muted-foreground truncate text-xs uppercase"
|
||||
title={config.agent_type}
|
||||
>
|
||||
{config.agent_type}
|
||||
@@ -86,7 +90,7 @@ export const AgentNode = memo(function AgentNode({
|
||||
)}
|
||||
{config.output_variable && (
|
||||
<div
|
||||
className="truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={`Output: ${config.output_variable}`}
|
||||
>
|
||||
Output: {config.output_variable}
|
||||
@@ -113,26 +117,29 @@ export const NoteNode = memo(function NoteNode({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[250px] rounded-3xl border border-yellow-200 bg-yellow-50 px-5 py-3 shadow-md transition-all dark:border-yellow-800 dark:bg-yellow-900/20 ${
|
||||
className={cn(
|
||||
// Opaque tint: the card colour underneath, the warning wash painted
|
||||
// over it as a flat gradient, so the canvas grid doesn't show through.
|
||||
'bg-card from-warning/10 to-warning/10 max-w-[250px] rounded-3xl border bg-linear-to-b px-5 py-3 shadow-md transition-all',
|
||||
selected
|
||||
? 'scale-105 ring-2 ring-yellow-300 dark:ring-yellow-700'
|
||||
: 'hover:shadow-lg'
|
||||
}`}
|
||||
? 'border-warning ring-warning scale-105 ring-2'
|
||||
: 'border-warning/50 hover:shadow-lg',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-800/30 dark:text-yellow-500">
|
||||
<div className="bg-warning/15 text-warning flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
|
||||
<StickyNote size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold text-yellow-800 dark:text-yellow-300"
|
||||
className="text-foreground truncate text-sm font-semibold"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{displayContent && (
|
||||
<div
|
||||
className="mt-1 text-xs wrap-break-word text-yellow-700 italic dark:text-yellow-400"
|
||||
className="text-muted-foreground mt-1 text-xs wrap-break-word italic"
|
||||
title={data.content}
|
||||
>
|
||||
{displayContent}
|
||||
|
||||
@@ -53,11 +53,12 @@ export default function ActionButtons({
|
||||
{showNewChat && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
title={t('actionButtons.openNewChat')}
|
||||
onClick={newChat}
|
||||
className="text-muted-foreground hover:text-foreground rounded-full lg:hidden"
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Plus className="size-5" strokeWidth={1.75} aria-label="NewChat" />
|
||||
</Button>
|
||||
@@ -67,11 +68,11 @@ export default function ActionButtons({
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="ghost-muted"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
title={t('actionButtons.share')}
|
||||
onClick={() => setShareModalState(true)}
|
||||
className="text-muted-foreground hover:text-foreground rounded-full"
|
||||
>
|
||||
<Share className="size-5" strokeWidth={1.75} aria-label="share" />
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
@@ -16,6 +18,7 @@ import { isDocumentArtifact, type DocumentArtifact } from './artifactViewUtils';
|
||||
import CopyButton from './CopyButton';
|
||||
import DocumentArtifactView from './DocumentArtifactView';
|
||||
import Spinner from './Spinner';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Sheet, SheetContent } from './ui/sheet';
|
||||
|
||||
@@ -82,17 +85,13 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="mb-4 flex items-center justify-end">
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="rounded-full bg-green-100 px-2 py-1 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
{data.completed_count} done
|
||||
</span>
|
||||
<span className="rounded-full bg-blue-100 px-2 py-1 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{data.open_count} open
|
||||
</span>
|
||||
<Badge variant="success">{data.completed_count} done</Badge>
|
||||
<Badge variant="info">{data.open_count} open</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{data.items.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
No todos yet
|
||||
</p>
|
||||
) : (
|
||||
@@ -100,18 +99,20 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
|
||||
{data.items.map((item, index) => (
|
||||
<li
|
||||
key={`${item.todo_id}-${index}`}
|
||||
className={`flex items-start gap-3 rounded-lg border p-3 ${
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3',
|
||||
item.status === 'completed'
|
||||
? 'border-green-300 dark:border-green-800'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
? 'border-success/50'
|
||||
: 'border-border',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${
|
||||
className={cn(
|
||||
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2',
|
||||
item.status === 'completed'
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
? 'border-success bg-success text-success-foreground'
|
||||
: 'border-input',
|
||||
)}
|
||||
>
|
||||
{item.status === 'completed' && (
|
||||
<svg
|
||||
@@ -131,15 +132,18 @@ function TodoListView({ data }: { data: TodoArtifactData }) {
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm ${
|
||||
className={cn(
|
||||
'text-sm',
|
||||
item.status === 'completed'
|
||||
? 'text-gray-500 line-through dark:text-gray-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">#{item.todo_id}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
#{item.todo_id}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -157,7 +161,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="mb-4 flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{data.line_count} lines
|
||||
</span>
|
||||
<CopyButton textToCopy={data.content || ''} />
|
||||
@@ -165,7 +169,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{data.content ? (
|
||||
<div className="flex flex-col gap-3 text-sm leading-normal wrap-break-word whitespace-pre-wrap text-gray-800 dark:text-gray-200">
|
||||
<div className="text-foreground flex flex-col gap-3 text-sm leading-normal wrap-break-word whitespace-pre-wrap">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
@@ -185,7 +189,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
return match ? (
|
||||
<div className="group border-border relative my-2 overflow-hidden rounded-xl border">
|
||||
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
{language}
|
||||
</span>
|
||||
<CopyButton
|
||||
@@ -196,6 +200,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
language={language}
|
||||
/* eslint-disable-next-line shadcn/no-inline-styles -- SyntaxHighlighter's style prop is its Prism theme object (oneLight / vscDarkPlus), picked by theme at runtime; it is not CSS. See DESIGN.md, Approved exceptions. */
|
||||
style={isDarkTheme ? vscDarkPlus : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
@@ -208,7 +213,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
</div>
|
||||
) : (
|
||||
<code
|
||||
className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal"
|
||||
className="bg-accent rounded-md px-2 py-1 text-xs font-normal"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
@@ -235,7 +240,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
@@ -255,7 +260,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-gray-300 pl-4 italic dark:border-gray-600">
|
||||
<blockquote className="border-border border-l-4 pl-4 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
@@ -266,7 +271,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Empty note</p>
|
||||
<p className="text-muted-foreground text-sm">Empty note</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,7 +451,7 @@ export default function ArtifactSidebar({
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -462,9 +467,7 @@ export default function ArtifactSidebar({
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Artifact not found
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">Artifact not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -475,7 +478,7 @@ export default function ArtifactSidebar({
|
||||
return <NoteView data={artifact.data} />;
|
||||
default:
|
||||
return (
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<pre className="text-muted-foreground text-xs">
|
||||
{JSON.stringify(artifact, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
@@ -490,16 +493,16 @@ export default function ArtifactSidebar({
|
||||
{/* Space for top bar / actions */}
|
||||
<div className="h-14 shrink-0" />
|
||||
{/* Artifact panel */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-transparent dark:border-gray-700">
|
||||
<div className="border-border flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border bg-transparent">
|
||||
<div className="flex w-full items-center justify-between px-4 py-2">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full"
|
||||
shape="pill"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
@@ -523,24 +526,26 @@ export default function ArtifactSidebar({
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
title={title || 'Artifact preview'}
|
||||
className="dark:bg-card bg-card flex h-full w-80 flex-col gap-0 border-l border-[#9ca3af]/10 p-0 sm:w-96 sm:max-w-none"
|
||||
className="h-full w-80 p-0 sm:w-96 sm:max-w-none"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{title}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-border flex w-full items-center justify-between border-b px-4 py-3">
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import ArrowLeft from '../assets/arrow-left.svg';
|
||||
import FileIcon from '../assets/file.svg';
|
||||
@@ -51,7 +53,7 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
const lineHeight = 19.93;
|
||||
const lineHeight = 20;
|
||||
const contentLines = value.split('\n').length;
|
||||
|
||||
const heightOffset = isMobile ? 200 : 300;
|
||||
@@ -62,24 +64,30 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
|
||||
const totalLines = Math.max(contentLines, minLinesForDisplay);
|
||||
|
||||
return (
|
||||
<div className={`relative w-full ${className}`}>
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 left-0 w-8 pr-2 text-right font-mono text-xs leading-[19.93px] text-gray-500 select-none lg:w-12 lg:pr-3 lg:text-sm dark:text-gray-400"
|
||||
style={{
|
||||
height: `${totalLines * lineHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn('relative w-full', className)}
|
||||
style={
|
||||
{
|
||||
'--numbered-height': `${totalLines * lineHeight}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="text-muted-foreground pointer-events-none absolute top-0 left-0 h-(--numbered-height) w-8 pr-2 text-right font-mono text-xs leading-5 select-none lg:w-12 lg:pr-3 lg:text-sm">
|
||||
{Array.from({ length: totalLines }, (_, i) => (
|
||||
<div
|
||||
key={i + 1}
|
||||
className="flex h-[19.93px] items-center justify-end leading-[19.93px]"
|
||||
className="flex h-5 items-center justify-end leading-5"
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className={`text-foreground focus-visible:ring-ring/50 focus-visible:border-ring w-full resize-none overflow-hidden border-none bg-transparent pl-8 font-['Inter'] text-[13.68px] leading-[19.93px] outline-none focus-visible:ring-[3px] lg:pl-12 dark:text-white ${isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]'} ${!editable ? 'select-none' : ''}`}
|
||||
className={cn(
|
||||
'text-foreground focus-visible:ring-ring/50 focus-visible:border-ring h-(--numbered-height) w-full resize-none overflow-hidden border-none bg-transparent pl-8 text-sm leading-5 outline-none focus-visible:ring-3 lg:pl-12',
|
||||
isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]',
|
||||
!editable && 'select-none',
|
||||
)}
|
||||
value={value}
|
||||
onChange={editable ? handleChange : undefined}
|
||||
onDoubleClick={onDoubleClick}
|
||||
@@ -87,9 +95,6 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
|
||||
aria-label={ariaLabel}
|
||||
rows={totalLines}
|
||||
readOnly={!editable}
|
||||
style={{
|
||||
height: `${totalLines * lineHeight}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -309,8 +314,10 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="mr-3 h-[29px] w-[29px] rounded-full p-2 text-gray-400 dark:border-0 dark:text-gray-500"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
className="mr-3"
|
||||
aria-label={t('settings.sources.back')}
|
||||
onClick={
|
||||
editingChunk
|
||||
? () => setEditingChunk(null)
|
||||
@@ -330,20 +337,22 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
|
||||
{pathParts.length > 0 && (
|
||||
<>
|
||||
<span className="mx-1 shrink-0 text-gray-500">/</span>
|
||||
<span className="text-muted-foreground mx-1 shrink-0">/</span>
|
||||
{pathParts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<span
|
||||
className={`wrap-break-word ${
|
||||
index < pathParts.length - 1
|
||||
? 'text-primary font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
{index < pathParts.length - 1 && (
|
||||
<span className="mx-1 shrink-0 text-gray-500">/</span>
|
||||
<span className="text-muted-foreground mx-1 shrink-0">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -359,7 +368,8 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-[38px] min-w-[108px] rounded-full px-4 text-sm font-medium whitespace-nowrap"
|
||||
shape="pill"
|
||||
className="min-w-[108px]"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{t('modals.chunk.edit')}
|
||||
@@ -367,7 +377,8 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
className="h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap"
|
||||
shape="pill"
|
||||
className="min-w-[108px]"
|
||||
onClick={() => {
|
||||
confirmDeleteChunk(editingChunk);
|
||||
}}
|
||||
@@ -383,7 +394,8 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
className="dark:text-foreground h-[38px] min-w-[108px] cursor-pointer rounded-full px-4 py-1 text-sm font-medium text-nowrap"
|
||||
shape="pill"
|
||||
className="min-w-[108px]"
|
||||
>
|
||||
{t('modals.chunk.cancel')}
|
||||
</Button>
|
||||
@@ -412,13 +424,8 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
(editingTitle === (editingChunk?.metadata?.title || '') &&
|
||||
editingText === (editingChunk?.text || ''))
|
||||
}
|
||||
className={`h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap transition-all ${
|
||||
editingText.trim() &&
|
||||
(editingTitle !== (editingChunk?.metadata?.title || '') ||
|
||||
editingText !== (editingChunk?.text || ''))
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed bg-gray-400 text-white hover:bg-gray-400'
|
||||
}`}
|
||||
shape="pill"
|
||||
className="min-w-[108px]"
|
||||
>
|
||||
{t('modals.chunk.save')}
|
||||
</Button>
|
||||
@@ -430,7 +437,8 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsAddingChunk(false)}
|
||||
className="dark:text-foreground h-[38px] min-w-[108px] cursor-pointer rounded-full px-4 py-1 text-sm font-medium text-nowrap"
|
||||
shape="pill"
|
||||
className="min-w-[108px]"
|
||||
>
|
||||
{t('modals.chunk.cancel')}
|
||||
</Button>
|
||||
@@ -443,11 +451,8 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
}
|
||||
}}
|
||||
disabled={!editingText.trim()}
|
||||
className={`h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap transition-all ${
|
||||
editingText.trim()
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed bg-gray-400 text-white hover:bg-gray-400'
|
||||
}`}
|
||||
shape="pill"
|
||||
className="min-w-[108px]"
|
||||
>
|
||||
{t('modals.chunk.add')}
|
||||
</Button>
|
||||
@@ -496,30 +501,26 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
const renderFileSearch = () => {
|
||||
return (
|
||||
<div className="relative" ref={searchDropdownRef}>
|
||||
<div className="relative flex items-center">
|
||||
<div className="pointer-events-none absolute left-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={fileSearchQuery}
|
||||
onChange={(e) => handleFileSearchChange(e.target.value)}
|
||||
placeholder={t('settings.sources.searchFiles')}
|
||||
className="h-[38px]"
|
||||
leftIcon={
|
||||
<SearchIcon
|
||||
className="text-muted-foreground size-4"
|
||||
strokeWidth={1.75}
|
||||
aria-label="Search"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={fileSearchQuery}
|
||||
onChange={(e) => handleFileSearchChange(e.target.value)}
|
||||
placeholder={t('settings.sources.searchFiles')}
|
||||
className={`h-[38px] py-2 pr-4 pl-10 ${
|
||||
fileSearchQuery ? 'rounded-t-md' : 'rounded-md'
|
||||
} transition-all duration-200`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{fileSearchQuery && (
|
||||
<div className="border-border bg-card dark:border-border dark:bg-card absolute z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-md border border-t-0 shadow-lg">
|
||||
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-xl border shadow-md">
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto">
|
||||
{fileSearchResults.length === 0 ? (
|
||||
<div className="py-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="text-muted-foreground py-2 text-center text-sm">
|
||||
{t('settings.sources.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -568,7 +569,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
<>
|
||||
<div className="mb-3 flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div className="border-border dark:border-border flex h-[38px] w-full flex-1 items-center overflow-hidden rounded-md border">
|
||||
<div className="dark:text-foreground flex h-full items-center px-4 font-medium whitespace-nowrap text-gray-700">
|
||||
<div className="text-foreground flex h-full items-center px-4 font-medium whitespace-nowrap">
|
||||
{totalChunks > 999999
|
||||
? `${(totalChunks / 1000000).toFixed(2)}M`
|
||||
: totalChunks > 999
|
||||
@@ -577,19 +578,21 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
{t('settings.sources.chunks')}
|
||||
</div>
|
||||
<div className="bg-border dark:bg-border h-full w-px"></div>
|
||||
<div className="h-full flex-1">
|
||||
<div className="h-full flex-1 px-3 py-2">
|
||||
<Input
|
||||
type="text"
|
||||
variant="bare"
|
||||
placeholder={t('settings.sources.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-full rounded-none border-0 px-3 py-2 text-[13.56px] leading-[100%] font-normal shadow-none focus-visible:ring-0 md:text-[13.56px] dark:border-0"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-[38px] w-full min-w-[108px] shrink-0 rounded-full px-4 text-sm font-medium whitespace-normal sm:w-auto"
|
||||
shape="pill"
|
||||
className="w-full min-w-[108px] shrink-0 whitespace-normal sm:w-auto"
|
||||
title={t('settings.sources.addChunk')}
|
||||
onClick={() => {
|
||||
setIsAddingChunk(true);
|
||||
@@ -607,7 +610,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
) : (
|
||||
<div className="grid w-full grid-cols-1 justify-items-start gap-4 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]">
|
||||
{filteredChunks.length === 0 ? (
|
||||
<div className="col-span-full flex min-h-[50vh] w-full flex-col items-center justify-center text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="text-muted-foreground col-span-full flex min-h-[50vh] w-full flex-col items-center justify-center text-center">
|
||||
<img
|
||||
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
|
||||
alt={t('settings.sources.noChunksAlt')}
|
||||
@@ -628,13 +631,13 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="border-border bg-muted dark:border-border dark:bg-card flex w-full items-center justify-between border-b px-4 py-3">
|
||||
<div className="dark:text-muted-foreground text-sm text-[#59636E]">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatChunkTokens(chunk.metadata)}{' '}
|
||||
{t('settings.sources.tokensUnit')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pt-3 pb-6">
|
||||
<p className="text-foreground line-clamp-6 font-['Inter'] text-[13.68px] leading-[19.93px] font-normal">
|
||||
<p className="text-foreground line-clamp-6 text-sm leading-5 font-normal">
|
||||
{chunk.text}
|
||||
</p>
|
||||
</div>
|
||||
@@ -661,7 +664,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
<div className="w-full">
|
||||
<div className="border-border dark:border-border relative flex w-full flex-col overflow-hidden rounded-md border">
|
||||
<div className="border-border bg-muted dark:border-border dark:bg-card flex w-full items-center justify-between border-b px-4 py-3">
|
||||
<div className="dark:text-muted-foreground text-sm text-[#59636E]">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatChunkTokens(editingChunk.metadata)}{' '}
|
||||
{t('settings.sources.tokensUnit')}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { ConfigRequirements } from '../modals/types';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
@@ -68,7 +66,7 @@ export default function ConfigFields({
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={key}>
|
||||
{spec.label || key}
|
||||
{spec.required && <span className="text-red-500">*</span>}
|
||||
{spec.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<Select
|
||||
value={value || spec.default || ''}
|
||||
@@ -78,11 +76,8 @@ export default function ConfigFields({
|
||||
id={key}
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className={cn(
|
||||
'w-full rounded-xl',
|
||||
hasError &&
|
||||
'border-destructive aria-invalid:ring-destructive/20',
|
||||
)}
|
||||
aria-invalid={hasError || undefined}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder={spec.label || key} />
|
||||
</SelectTrigger>
|
||||
@@ -106,7 +101,7 @@ export default function ConfigFields({
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={key}>
|
||||
{spec.label || key}
|
||||
{spec.required && <span className="text-red-500">*</span>}
|
||||
{spec.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={key}
|
||||
@@ -136,7 +131,6 @@ export default function ConfigFields({
|
||||
spec.type === 'number' && key === 'timeout' ? 300 : undefined
|
||||
}
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn('rounded-xl', hasError && 'border-destructive')}
|
||||
/>
|
||||
{hasError && (
|
||||
<p className="text-destructive text-xs">{errors[key]}</p>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => 'token',
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDarkTheme: () => [false, () => undefined],
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: {} }));
|
||||
|
||||
import ConnectorAuth from './ConnectorAuth';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('ConnectorAuth', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = (props: Partial<Parameters<typeof ConnectorAuth>[0]>) =>
|
||||
act(() =>
|
||||
root.render(
|
||||
<ConnectorAuth
|
||||
provider="google_drive"
|
||||
onSuccess={() => undefined}
|
||||
onError={() => undefined}
|
||||
label="Connect"
|
||||
{...props}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
it('shows the error as a destructive alert with an icon', () => {
|
||||
render({ errorMessage: 'Session expired' });
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).not.toBeNull();
|
||||
expect(alert?.className).toContain('text-destructive');
|
||||
expect(alert?.querySelector('svg')).not.toBeNull();
|
||||
expect(alert?.textContent).toContain('Session expired');
|
||||
});
|
||||
|
||||
it('renders the connected banner as a polite success alert', () => {
|
||||
const onDisconnect = vi.fn();
|
||||
render({ isConnected: true, userEmail: 'a@b.c', onDisconnect });
|
||||
const banner = container.querySelector('[role="status"]');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner?.className).toContain('text-success');
|
||||
expect(banner?.className).toContain('bg-success/10');
|
||||
expect(banner?.querySelector('svg')).not.toBeNull();
|
||||
const button = banner?.querySelector('button');
|
||||
expect(button?.className).toContain('text-primary');
|
||||
expect(button?.className).not.toContain('text-black');
|
||||
expect(button?.textContent).toBe(
|
||||
'modals.uploadDoc.connectors.auth.disconnect',
|
||||
);
|
||||
act(() => button?.click());
|
||||
expect(onDisconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the auth button as a default brand button', () => {
|
||||
render({});
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.textContent).toContain('Connect');
|
||||
expect(button?.className).toContain('bg-primary');
|
||||
expect(button?.className).not.toContain('bg-blue-500');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { isTrustedConnectorMessage } from '../utils/connectorAuthUtils';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface ConnectorAuthProps {
|
||||
@@ -31,7 +32,6 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const completedRef = useRef(false);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const authWindowRef = useRef<Window | null>(null);
|
||||
@@ -208,67 +208,36 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
|
||||
return (
|
||||
<>
|
||||
{errorMessage && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-lg border border-[#E60000] bg-transparent p-2 dark:border-[#D42626] dark:bg-[#D426261A]">
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.09974 24.5422H22.9C24.5156 24.5422 25.5228 22.7901 24.715 21.3947L16.8149 7.74526C16.007 6.34989 13.9927 6.34989 13.1848 7.74526L5.28471 21.3947C4.47686 22.7901 5.48405 24.5422 7.09974 24.5422ZM14.9998 17.1981C14.4228 17.1981 13.9507 16.726 13.9507 16.149V14.0507C13.9507 13.4736 14.4228 13.0015 14.9998 13.0015C15.5769 13.0015 16.049 13.4736 16.049 14.0507V16.149C16.049 16.726 15.5769 17.1981 14.9998 17.1981ZM16.049 21.3947H13.9507V19.2964H16.049V21.3947Z"
|
||||
fill={isDarkTheme ? '#EECF56' : '#E60000'}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span
|
||||
className="text-sm text-[#E60000] dark:text-red-400"
|
||||
style={{
|
||||
fontFamily: 'Inter',
|
||||
lineHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</span>
|
||||
</div>
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<TriangleAlert aria-hidden="true" />
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isConnected ? (
|
||||
<div className="mb-4">
|
||||
<div className="flex w-full items-center justify-between rounded-lg bg-[#8FDD51] px-4 py-2 text-sm font-medium text-black">
|
||||
<div className="flex max-w-[500px] items-center gap-2">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{t('modals.uploadDoc.connectors.auth.connectedAs', {
|
||||
email: userEmail,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Alert variant="success" className="mb-4">
|
||||
<CircleCheck aria-hidden="true" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span className="max-w-[500px]">
|
||||
{t('modals.uploadDoc.connectors.auth.connectedAs', {
|
||||
email: userEmail,
|
||||
})}
|
||||
</span>
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onClick={onDisconnect}
|
||||
className="h-auto p-0 text-xs text-black underline hover:text-gray-700"
|
||||
className="-my-1 ml-2 shrink-0"
|
||||
>
|
||||
{t('modals.uploadDoc.connectors.auth.disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAuth}
|
||||
className="w-full rounded-lg bg-blue-500 px-4 py-3 text-white hover:bg-blue-600"
|
||||
>
|
||||
<Button type="button" onClick={handleAuth} className="w-full">
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
|
||||
@@ -111,8 +111,8 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
disabled={isSyncing}
|
||||
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
isSyncing
|
||||
? 'dark:bg-muted dark:text-muted-foreground cursor-not-allowed bg-gray-300 text-gray-600'
|
||||
: 'bg-primary hover:bg-primary/90 text-white'
|
||||
? 'bg-muted text-muted-foreground cursor-not-allowed'
|
||||
: 'bg-primary hover:bg-primary/90 text-primary-foreground'
|
||||
}`}
|
||||
title={
|
||||
isSyncing
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const copyMock = vi.fn<(text: string) => boolean>(() => true);
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (text: string) => copyMock(text),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import CopyButton from './CopyButton';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('CopyButton', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
copyMock.mockClear();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function button(): HTMLButtonElement {
|
||||
const el = container.querySelector('button');
|
||||
if (!el) throw new Error('no button');
|
||||
return el;
|
||||
}
|
||||
|
||||
async function click() {
|
||||
await act(async () => {
|
||||
button().click();
|
||||
});
|
||||
}
|
||||
|
||||
it('renders an idle ghost-muted icon button', () => {
|
||||
act(() => root.render(<CopyButton textToCopy="hello" />));
|
||||
expect(button().getAttribute('aria-label')).toBe('conversation.copy');
|
||||
expect(button().dataset.variant).toBe('ghost-muted');
|
||||
expect(button().dataset.size).toBe('icon-sm');
|
||||
expect(button().dataset.shape).toBe('pill');
|
||||
});
|
||||
|
||||
it('uses icon-xs when size is xs and xs with text', () => {
|
||||
act(() => root.render(<CopyButton textToCopy="hello" size="xs" />));
|
||||
expect(button().dataset.size).toBe('icon-xs');
|
||||
act(() => root.render(<CopyButton textToCopy="hello" showText />));
|
||||
expect(button().dataset.size).toBe('xs');
|
||||
expect(button().textContent).toContain('conversation.copy');
|
||||
});
|
||||
|
||||
it('copies the text and shows the copied state without disabling', async () => {
|
||||
act(() => root.render(<CopyButton textToCopy="hello" showText />));
|
||||
await click();
|
||||
|
||||
expect(copyMock).toHaveBeenCalledWith('hello');
|
||||
expect(button().getAttribute('aria-label')).toBe('conversation.copied');
|
||||
expect(button().dataset.variant).toBe('secondary');
|
||||
expect(button().hasAttribute('disabled')).toBe(false);
|
||||
expect(button().textContent).toContain('conversation.copied');
|
||||
});
|
||||
|
||||
it('ignores clicks while copied and resets after the timeout', async () => {
|
||||
act(() =>
|
||||
root.render(<CopyButton textToCopy="hello" copiedDuration={1000} />),
|
||||
);
|
||||
await click();
|
||||
await click();
|
||||
expect(copyMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(button().getAttribute('aria-label')).toBe('conversation.copy');
|
||||
expect(button().dataset.variant).toBe('ghost-muted');
|
||||
});
|
||||
});
|
||||
@@ -1,71 +1,38 @@
|
||||
import clsx from 'clsx';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CheckMark from '../assets/checkmark.svg?react';
|
||||
import CopyIcon from '../assets/copy.svg?react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type CopyButtonProps = {
|
||||
textToCopy: string;
|
||||
iconSize?: string;
|
||||
padding?: string;
|
||||
/** Icon-only button size: `xs` is `icon-xs` (28px), `sm` is `icon-sm` (32px). */
|
||||
size?: 'xs' | 'sm';
|
||||
/** Show a "Copy"/"Copied" label next to the icon (renders at Button `xs`). */
|
||||
showText?: boolean;
|
||||
copiedDuration?: number;
|
||||
className?: string;
|
||||
iconWrapperClassName?: string;
|
||||
textClassName?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_SIZE = 'w-4 h-4';
|
||||
const DEFAULT_PADDING = 'p-2';
|
||||
const DEFAULT_COPIED_DURATION = 2000;
|
||||
|
||||
export default function CopyButton({
|
||||
textToCopy,
|
||||
iconSize = DEFAULT_ICON_SIZE,
|
||||
padding = DEFAULT_PADDING,
|
||||
size = 'sm',
|
||||
showText = false,
|
||||
copiedDuration = DEFAULT_COPIED_DURATION,
|
||||
className,
|
||||
iconWrapperClassName,
|
||||
textClassName,
|
||||
}: CopyButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutIdRef = useRef<number | null>(null);
|
||||
// `copy` is async, so `isCopied` and the disabled prop only catch up after it
|
||||
// resolves. Guard that window so rapid clicks cannot start a second write.
|
||||
// `copy` is async, so `isCopied` only catches up after it resolves. Guard
|
||||
// that window so rapid clicks cannot start a second write. The button is
|
||||
// never disabled, so the copied confirmation does not fade.
|
||||
const copyInFlightRef = useRef(false);
|
||||
|
||||
const iconWrapperClasses = clsx(
|
||||
'flex items-center justify-center rounded-full transition-colors duration-150 ease-in-out',
|
||||
padding,
|
||||
{
|
||||
[`bg-transparent hover:bg-muted`]: !isCopied,
|
||||
'bg-green-100 dark:bg-green-900 hover:bg-green-100 dark:hover:bg-green-900':
|
||||
isCopied,
|
||||
},
|
||||
iconWrapperClassName,
|
||||
);
|
||||
|
||||
const rootButtonClasses = clsx(
|
||||
'group h-auto rounded-full bg-transparent p-0 hover:bg-transparent disabled:opacity-100',
|
||||
className,
|
||||
);
|
||||
|
||||
const textSpanClasses = clsx(
|
||||
'text-xs text-gray-600 dark:text-gray-400 transition-opacity duration-150 ease-in-out',
|
||||
{ 'opacity-75': isCopied },
|
||||
textClassName,
|
||||
);
|
||||
|
||||
const IconComponent = isCopied ? CheckMark : CopyIcon;
|
||||
const iconClasses = clsx(iconSize, {
|
||||
'stroke-green-600 dark:stroke-green-400': isCopied,
|
||||
'fill-none text-gray-700 dark:text-gray-300': !isCopied,
|
||||
});
|
||||
const IconComponent = isCopied ? Check : Copy;
|
||||
|
||||
const buttonTitle = isCopied
|
||||
? t('conversation.copied')
|
||||
@@ -111,17 +78,16 @@ export default function CopyButton({
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant={isCopied ? 'secondary' : 'ghost-muted'}
|
||||
size={showText ? 'xs' : size === 'xs' ? 'icon-xs' : 'icon-sm'}
|
||||
shape="pill"
|
||||
onClick={handleCopy}
|
||||
className={rootButtonClasses}
|
||||
className={className}
|
||||
title={buttonTitle}
|
||||
aria-label={buttonTitle}
|
||||
disabled={isCopied}
|
||||
>
|
||||
<div className={iconWrapperClasses}>
|
||||
<IconComponent className={iconClasses} aria-hidden="true" />
|
||||
</div>
|
||||
{showText && <span className={textSpanClasses}>{displayedText}</span>}
|
||||
<IconComponent aria-hidden="true" />
|
||||
{showText && <span>{displayedText}</span>}
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{isCopied ? t('conversation.copied', 'Copied to clipboard') : ''}
|
||||
</span>
|
||||
|
||||
@@ -86,7 +86,7 @@ function FramePreview({
|
||||
|
||||
if (renderError) {
|
||||
return (
|
||||
<pre className="overflow-auto p-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<pre className="text-muted-foreground overflow-auto p-4 text-xs">
|
||||
{source}
|
||||
</pre>
|
||||
);
|
||||
@@ -107,7 +107,7 @@ function FramePreview({
|
||||
// No allow-same-origin: content is fully isolated from the app origin.
|
||||
sandbox=""
|
||||
title="Artifact preview"
|
||||
className="h-full w-full rounded-md border border-gray-200 bg-white dark:border-gray-700"
|
||||
className="border-border h-full w-full rounded-md border bg-white"
|
||||
srcDoc={buildPreviewDocument(previewKind, rendered)}
|
||||
/>
|
||||
);
|
||||
@@ -126,16 +126,14 @@ function DownloadCard({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-gray-200 p-8 text-center dark:border-gray-700">
|
||||
<FileText className="h-12 w-12 text-gray-400" />
|
||||
<div className="border-border flex flex-col items-center gap-3 rounded-xl border p-8 text-center">
|
||||
<FileText className="text-muted-foreground h-12 w-12" />
|
||||
<div>
|
||||
<p className="text-sm font-medium break-all text-gray-800 dark:text-gray-200">
|
||||
<p className="text-foreground text-sm font-medium break-all">
|
||||
{filename}
|
||||
</p>
|
||||
{size != null && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatBytes(size)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{formatBytes(size)}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -207,7 +205,7 @@ function BytesPreview({
|
||||
// No allow-same-origin / no scripts: bytes are isolated from the app origin.
|
||||
sandbox=""
|
||||
title="Artifact preview"
|
||||
className="h-full w-full rounded-md border border-gray-200 bg-white dark:border-gray-700"
|
||||
className="border-border h-full w-full rounded-md border bg-white"
|
||||
srcDoc={buildPreviewDocument(
|
||||
mode === 'iframe-svg' ? 'svg' : 'html',
|
||||
state.text,
|
||||
@@ -219,7 +217,7 @@ function BytesPreview({
|
||||
return <MarkdownPreview content={state.text} />;
|
||||
}
|
||||
return (
|
||||
<pre className="h-full overflow-auto p-4 text-xs whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
<pre className="text-foreground h-full overflow-auto p-4 text-xs whitespace-pre-wrap">
|
||||
{state.text}
|
||||
</pre>
|
||||
);
|
||||
@@ -340,7 +338,7 @@ export default function DocumentArtifactView({
|
||||
: (selectedRow?.preview_text ?? '');
|
||||
if (text) {
|
||||
return (
|
||||
<pre className="h-full overflow-auto p-4 text-xs whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
<pre className="text-foreground h-full overflow-auto p-4 text-xs whitespace-pre-wrap">
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
@@ -379,7 +377,7 @@ export default function DocumentArtifactView({
|
||||
value={String(selectedVersion)}
|
||||
onValueChange={(v) => setSelectedVersion(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto gap-2 rounded-md px-3 text-xs">
|
||||
<SelectTrigger size="sm" className="w-auto">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -426,7 +424,7 @@ export default function DocumentArtifactView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionError && <p className="text-xs text-red-500">{actionError}</p>}
|
||||
{actionError && <p className="text-destructive text-xs">{actionError}</p>}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{renderPreview()}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import Pagination from './DocumentPagination';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('DocumentPagination rows-per-page', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (onRowsPerPageChange = vi.fn()) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Pagination
|
||||
currentPage={1}
|
||||
totalPages={3}
|
||||
rowsPerPage={10}
|
||||
onPageChange={vi.fn()}
|
||||
onRowsPerPageChange={onRowsPerPageChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return onRowsPerPageChange;
|
||||
};
|
||||
|
||||
const trigger = () =>
|
||||
document.body.querySelector<HTMLButtonElement>(
|
||||
'[data-slot="select-trigger"]',
|
||||
);
|
||||
|
||||
it('shows the current value in a labelled combobox trigger', async () => {
|
||||
await render();
|
||||
const el = trigger();
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.getAttribute('role')).toBe('combobox');
|
||||
expect(el!.getAttribute('aria-label')).toBe('pagination.rowsPerPage');
|
||||
expect(el!.getAttribute('data-size')).toBe('sm');
|
||||
expect(el!.textContent).toContain('10');
|
||||
});
|
||||
|
||||
it('calls onRowsPerPageChange with the chosen number', async () => {
|
||||
const onChange = await render();
|
||||
const el = trigger()!;
|
||||
await act(async () => {
|
||||
el.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
);
|
||||
});
|
||||
const option = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
).find((o) => o.textContent === '50');
|
||||
expect(option).toBeDefined();
|
||||
await act(async () => option!.click());
|
||||
expect(onChange).toHaveBeenCalledWith(50);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SingleArrowLeft from '../assets/single-left-arrow.svg';
|
||||
import SingleArrowRight from '../assets/single-right-arrow.svg';
|
||||
import DoubleArrowLeft from '../assets/double-arrow-left.svg';
|
||||
import DoubleArrowRight from '../assets/double-arrow-right.svg';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
@@ -22,11 +29,8 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
onRowsPerPageChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const rowsPerPageOptions = [5, 10, 20, 50];
|
||||
|
||||
const toggleDropdown = () => setIsDropdownOpen((prev) => !prev);
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (currentPage > 1) {
|
||||
onPageChange(currentPage - 1);
|
||||
@@ -48,63 +52,42 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
};
|
||||
|
||||
const handleSelectRowsPerPage = (rows: number) => {
|
||||
setIsDropdownOpen(false);
|
||||
onRowsPerPageChange(rows);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-end gap-4 border-gray-200 p-2 text-xs">
|
||||
<div className="mt-2 flex items-center justify-end gap-4 p-2 text-xs">
|
||||
{/* Rows per page dropdown */}
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-gray-900 dark:text-gray-50">
|
||||
{t('pagination.rowsPerPage')}:
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleDropdown}
|
||||
className="dark:bg-card h-auto rounded px-3 py-1 shadow-none"
|
||||
>
|
||||
{rowsPerPage}
|
||||
</Button>
|
||||
<div
|
||||
className={`ring-opacity-5 dark:bg-card bg-card absolute right-0 z-50 mt-1 w-28 transform shadow-lg ring-1 ring-black transition-all duration-200 ease-in-out ${
|
||||
isDropdownOpen
|
||||
? 'block scale-100 opacity-100'
|
||||
: 'hidden scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<span className="text-foreground">{t('pagination.rowsPerPage')}:</span>
|
||||
<Select
|
||||
value={String(rowsPerPage)}
|
||||
onValueChange={(value) => handleSelectRowsPerPage(Number(value))}
|
||||
>
|
||||
<SelectTrigger size="sm" aria-label={t('pagination.rowsPerPage')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rowsPerPageOptions.map((option) => (
|
||||
<div
|
||||
key={option}
|
||||
onClick={() => handleSelectRowsPerPage(option)}
|
||||
className={`hover:bg-accent dark:hover:bg-accent cursor-pointer px-4 py-2 text-xs ${
|
||||
rowsPerPage === option
|
||||
? 'dark:text-foreground bg-gray-100 dark:bg-neutral-700'
|
||||
: 'bg-card dark:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="text-gray-900 dark:text-gray-50">
|
||||
<div className="text-foreground">
|
||||
{t('pagination.pageOf', { currentPage, totalPages })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-50">
|
||||
<div className="text-foreground flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon-xs"
|
||||
onClick={handleFirstPage}
|
||||
disabled={currentPage === 1}
|
||||
className="h-auto rounded px-2 py-1 shadow-none"
|
||||
>
|
||||
<img
|
||||
src={DoubleArrowLeft}
|
||||
@@ -115,10 +98,9 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon-xs"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
className="h-auto rounded px-2 py-1 shadow-none"
|
||||
>
|
||||
<img
|
||||
src={SingleArrowLeft}
|
||||
@@ -129,10 +111,9 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon-xs"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-auto rounded px-2 py-1 shadow-none"
|
||||
>
|
||||
<img
|
||||
src={SingleArrowRight}
|
||||
@@ -143,10 +124,9 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon-xs"
|
||||
onClick={handleLastPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-auto rounded px-2 py-1 shadow-none"
|
||||
>
|
||||
<img
|
||||
src={DoubleArrowRight}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const validateConnectorSession = vi.fn();
|
||||
const getConnectorFiles = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
validateConnectorSession: (...args: unknown[]) =>
|
||||
validateConnectorSession(...args),
|
||||
getConnectorFiles: (...args: unknown[]) => getConnectorFiles(...args),
|
||||
disconnectConnector: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/providerUtils', () => ({
|
||||
getSessionToken: () => 'session-token',
|
||||
setSessionToken: vi.fn(),
|
||||
removeSessionToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ConnectorAuth', () => ({ default: () => null }));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { FilePicker } from './FilePicker';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('FilePicker', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
validateConnectorSession.mockReset();
|
||||
getConnectorFiles.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('shows an expired session as a destructive alert', async () => {
|
||||
validateConnectorSession.mockResolvedValue({ ok: false });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<FilePicker
|
||||
provider="google_drive"
|
||||
token={null}
|
||||
onSelectionChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Session expired');
|
||||
expect(alert?.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
async function renderSharePoint() {
|
||||
validateConnectorSession.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
user_email: 'lena@meridian.example',
|
||||
allows_shared_content: true,
|
||||
}),
|
||||
});
|
||||
getConnectorFiles.mockResolvedValue({
|
||||
json: async () => ({ success: true, files: [], next_page_token: null }),
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<FilePicker
|
||||
provider="share_point"
|
||||
token={null}
|
||||
onSelectionChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('renders the drive switch as underline tabs with the active one marked', async () => {
|
||||
await renderSharePoint();
|
||||
const tabs = Array.from(
|
||||
container.querySelectorAll('[data-slot="button"][data-variant="tab"]'),
|
||||
);
|
||||
expect(tabs.map((tab) => tab.textContent)).toEqual([
|
||||
'filePicker.myFiles',
|
||||
'filePicker.sharedWithMe',
|
||||
]);
|
||||
expect(tabs[0].getAttribute('data-active')).toBe('true');
|
||||
expect(tabs[1].getAttribute('data-active')).not.toBe('true');
|
||||
});
|
||||
|
||||
it('shows the folder trail as a breadcrumb with the current folder as the page', async () => {
|
||||
await renderSharePoint();
|
||||
const trail = container.querySelector('nav[aria-label="breadcrumb"]');
|
||||
expect(trail).not.toBeNull();
|
||||
const page = trail?.querySelector('[data-slot="breadcrumb-page"]');
|
||||
expect(page?.textContent).toBe('My Files');
|
||||
expect(page?.getAttribute('title')).toBe('My Files');
|
||||
expect(trail?.querySelector('button[disabled]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Search as SearchIcon, TriangleAlert } from 'lucide-react';
|
||||
import React, {
|
||||
Fragment,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import userService from '../api/services/userService';
|
||||
import { formatBytes } from '../utils/stringUtils';
|
||||
@@ -13,6 +19,15 @@ import ConnectorAuth from '../components/ConnectorAuth';
|
||||
import FileIcon from '../assets/file.svg';
|
||||
import FolderIcon from '../assets/folder.svg';
|
||||
import CheckIcon from '../assets/checkmark.svg';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from './ui/breadcrumb';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import {
|
||||
@@ -379,7 +394,10 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
return (
|
||||
<div className="">
|
||||
{authError && (
|
||||
<div className="mb-4 text-center text-sm text-red-500">{authError}</div>
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<TriangleAlert aria-hidden="true" />
|
||||
<AlertDescription>{authError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ConnectorAuth
|
||||
@@ -429,59 +447,61 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
/>
|
||||
|
||||
{isConnected && (
|
||||
<div className="border-border dark:border-border mt-3 overflow-hidden rounded-lg border">
|
||||
<div className="border-border dark:border-border rounded-t-lg">
|
||||
<div className="border-border mt-3 overflow-hidden rounded-lg border">
|
||||
<div className="border-border rounded-t-lg">
|
||||
{provider === 'share_point' && allowsSharedContent && (
|
||||
<div className="border-border dark:border-border flex border-b">
|
||||
<div className="border-border flex border-b">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="tab"
|
||||
data-active={activeTab === 'my_files'}
|
||||
onClick={() => handleTabChange('my_files')}
|
||||
className={`h-auto rounded-none px-4 py-2 text-sm font-medium ${
|
||||
activeTab === 'my_files'
|
||||
? 'border-b-2 border-[#A076F6] text-[#A076F6] hover:bg-transparent hover:text-[#A076F6]'
|
||||
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{t('filePicker.myFiles')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="tab"
|
||||
data-active={activeTab === 'shared'}
|
||||
onClick={() => handleTabChange('shared')}
|
||||
className={`h-auto rounded-none px-4 py-2 text-sm font-medium ${
|
||||
activeTab === 'shared'
|
||||
? 'border-b-2 border-[#A076F6] text-[#A076F6] hover:bg-transparent hover:text-[#A076F6]'
|
||||
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{t('filePicker.sharedWithMe')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="dark:bg-muted rounded-t-lg bg-[#EEE6FF78] px-4 pt-4">
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
{folderPath.map((path, index) => (
|
||||
<div
|
||||
key={path.id || 'root'}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{index > 0 && <span className="text-gray-400">/</span>}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => navigateBack(index)}
|
||||
className="h-auto p-0 text-sm text-[#A076F6] underline-offset-2 hover:text-[#8A5FD4]"
|
||||
disabled={index === folderPath.length - 1}
|
||||
>
|
||||
{path.name}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-muted rounded-t-lg px-4 pt-4">
|
||||
<Breadcrumb className="mb-2 min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{folderPath.map((path, index) => (
|
||||
<Fragment key={path.id || 'root'}>
|
||||
{index > 0 && <BreadcrumbSeparator />}
|
||||
{index === folderPath.length - 1 ? (
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage
|
||||
title={path.name}
|
||||
className="max-w-[32ch]"
|
||||
>
|
||||
{path.name}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
) : (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateBack(index)}
|
||||
>
|
||||
{path.name}
|
||||
</button>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="text-muted-foreground mb-3 text-sm">
|
||||
Select Files from {getProviderConfig(provider).displayName}
|
||||
</div>
|
||||
|
||||
@@ -491,7 +511,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
label={t('filePicker.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
labelBgClassName="bg-[#EEE6FF78] dark:bg-muted"
|
||||
labelSurface="muted"
|
||||
leftIcon={
|
||||
<SearchIcon
|
||||
className="text-muted-foreground size-4"
|
||||
@@ -502,18 +522,18 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Selected Files Message */}
|
||||
<div className="pb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="text-muted-foreground pb-3 text-sm">
|
||||
{t('filePicker.itemsSelected', {
|
||||
count: selectedFiles.length + selectedFolders.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border dark:border-border h-72 border-t">
|
||||
<div className="border-border scrollbar-overlay h-72 border-t">
|
||||
<TableContainer
|
||||
ref={scrollContainerRef}
|
||||
height="288px"
|
||||
className="scrollbar-overlay md:w-4xl lg:w-5xl"
|
||||
className="md:w-4xl lg:w-5xl"
|
||||
bordered={false}
|
||||
>
|
||||
{
|
||||
@@ -538,16 +558,16 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
<TableCell width="40px" align="center">
|
||||
<div className="mx-auto h-5 w-5 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="bg-muted mx-auto h-5 w-5 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="bg-muted h-4 w-48 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="bg-muted h-4 w-16 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -564,7 +584,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
>
|
||||
<TableCell width="40px" align="center">
|
||||
<div
|
||||
className="border-border dark:border-border mx-auto flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border p-[0.5px] text-sm"
|
||||
className="border-border mx-auto flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFileSelect(file.id, isFolder(file));
|
||||
@@ -611,16 +631,16 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={`load-more-skeleton-${i}`}>
|
||||
<TableCell width="40px" align="center">
|
||||
<div className="mx-auto h-5 w-5 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="bg-muted mx-auto h-5 w-5 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="bg-muted h-4 w-48 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="bg-muted h-4 w-16 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { FileUpload } from './FileUpload';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
function makeFile(name: string, type: string, bytes = 10): File {
|
||||
return new File([new Uint8Array(bytes)], name, { type });
|
||||
}
|
||||
|
||||
async function selectFiles(container: HTMLElement, files: File[]) {
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
if (!input) throw new Error('no file input');
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true });
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
// react-dropzone resolves the selected files asynchronously.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe('FileUpload', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders on the shared Dropzone with the upload prompt', async () => {
|
||||
await act(async () => {
|
||||
root.render(<FileUpload onUpload={vi.fn()} />);
|
||||
});
|
||||
const zone = container.querySelector('[data-slot="dropzone"]');
|
||||
expect(zone).not.toBeNull();
|
||||
expect(zone?.getAttribute('data-size')).toBe('default');
|
||||
expect(container.textContent).toContain(
|
||||
'components.fileUpload.clickToUpload',
|
||||
);
|
||||
expect(container.textContent).toContain('components.fileUpload.fileTypes');
|
||||
});
|
||||
|
||||
it('passes the compact size through', async () => {
|
||||
await act(async () => {
|
||||
root.render(<FileUpload onUpload={vi.fn()} size="compact" />);
|
||||
});
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-slot="dropzone"]')
|
||||
?.getAttribute('data-size'),
|
||||
).toBe('compact');
|
||||
});
|
||||
|
||||
it('renders highlighted upload text segments', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<FileUpload
|
||||
onUpload={vi.fn()}
|
||||
uploadText={[
|
||||
{ text: 'Click to upload', highlight: true },
|
||||
{ text: ' or drag and drop' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const highlighted = Array.from(container.querySelectorAll('span')).find(
|
||||
(s) => s.textContent === 'Click to upload',
|
||||
);
|
||||
expect(highlighted?.className).toContain('text-primary');
|
||||
});
|
||||
|
||||
it('calls onUpload with a valid file', async () => {
|
||||
const onUpload = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(<FileUpload onUpload={onUpload} />);
|
||||
});
|
||||
const file = makeFile('logo.png', 'image/png');
|
||||
await selectFiles(container, [file]);
|
||||
expect(onUpload).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it('shows the validator error and does not upload', async () => {
|
||||
const onUpload = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<FileUpload
|
||||
onUpload={onUpload}
|
||||
validator={() => ({ isValid: false, error: 'Bad image' })}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await selectFiles(container, [makeFile('logo.png', 'image/png')]);
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
const error = container.querySelector('.text-destructive');
|
||||
expect(error?.textContent).toContain('Bad image');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { FileRejection } from 'react-dropzone';
|
||||
import { ImageUp } from 'lucide-react';
|
||||
|
||||
import { Dropzone } from '@/components/ui/dropzone';
|
||||
import Cross from '../assets/cross.svg';
|
||||
import ImagesIcon from '../assets/images.svg';
|
||||
|
||||
type UploadTextSegment = {
|
||||
text: string;
|
||||
/** Renders the segment in the brand colour; others are muted. */
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
interface FileUploadProps {
|
||||
onUpload: (files: File[]) => void;
|
||||
@@ -15,14 +21,14 @@ interface FileUploadProps {
|
||||
accept?: Record<string, string[]>; // e.g. { 'image/*': ['.png', '.jpg'] }
|
||||
showPreview?: boolean;
|
||||
previewSize?: number;
|
||||
/** `compact` is a one-row target for forms and panels. */
|
||||
size?: 'default' | 'compact';
|
||||
|
||||
children?: React.ReactNode;
|
||||
/** Layout classes only; the Dropzone owns colours, border and radius. */
|
||||
className?: string;
|
||||
activeClassName?: string;
|
||||
acceptClassName?: string;
|
||||
rejectClassName?: string;
|
||||
|
||||
uploadText?: string | { text: string; colorClass?: string }[];
|
||||
uploadText?: string | UploadTextSegment[];
|
||||
dragActiveText?: string;
|
||||
fileTypeText?: string;
|
||||
sizeLimitText?: string;
|
||||
@@ -31,6 +37,13 @@ interface FileUploadProps {
|
||||
validator?: (file: File) => { isValid: boolean; error?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Image/file picker built on `ui/dropzone`. Adds size and custom validation,
|
||||
* an optional image preview with a remove button, and translated prompts.
|
||||
*
|
||||
* @param props - See `FileUploadProps`.
|
||||
* @returns The dropzone with its preview and validation errors.
|
||||
*/
|
||||
export const FileUpload = ({
|
||||
onUpload,
|
||||
onRemove,
|
||||
@@ -40,11 +53,9 @@ export const FileUpload = ({
|
||||
accept = { 'image/*': ['.jpeg', '.png', '.jpg'] },
|
||||
showPreview = false,
|
||||
previewSize = 80,
|
||||
size = 'default',
|
||||
children,
|
||||
className = 'border-2 border-dashed rounded-3xl p-6 text-center cursor-pointer transition-colors border-border dark:border-border',
|
||||
activeClassName = 'border-blue-500 bg-blue-50',
|
||||
acceptClassName = 'border-green-500 dark:border-green-500 bg-green-50 dark:bg-green-50/10',
|
||||
rejectClassName = 'border-red-500 bg-red-50 dark:bg-red-500/10 dark:border-red-500',
|
||||
className,
|
||||
uploadText,
|
||||
dragActiveText,
|
||||
fileTypeText,
|
||||
@@ -83,12 +94,12 @@ export const FileUpload = ({
|
||||
};
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[], fileRejections: any[]) => {
|
||||
(acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
setErrors([]);
|
||||
|
||||
if (fileRejections.length > 0) {
|
||||
const newErrors = fileRejections
|
||||
.map(({ errors }) => errors.map((e: any) => e.message))
|
||||
.map(({ errors }) => errors.map((e) => e.message))
|
||||
.flat();
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
@@ -117,40 +128,22 @@ export const FileUpload = ({
|
||||
[onUpload, multiple, maxSize, validator],
|
||||
);
|
||||
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
} = useDropzone({
|
||||
onDrop,
|
||||
multiple,
|
||||
maxFiles,
|
||||
maxSize,
|
||||
accept,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const currentClassName = twMerge(
|
||||
'border-2 border-dashed rounded-3xl p-8 text-center cursor-pointer transition-colors border-border dark:border-border',
|
||||
className,
|
||||
isDragActive && activeClassName,
|
||||
isDragAccept && acceptClassName,
|
||||
isDragReject && rejectClassName,
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
);
|
||||
|
||||
const handleRemove = () => {
|
||||
setPreview(null);
|
||||
setCurrentFile(null);
|
||||
if (onRemove && currentFile) onRemove(currentFile);
|
||||
};
|
||||
|
||||
// Callers size the preview box at runtime; a custom property keeps the
|
||||
// dimension out of the style's width/height and in the classes.
|
||||
const previewSizeStyle = {
|
||||
'--preview-size': `${previewSize}px`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const renderPreview = () => (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: previewSize, height: previewSize }}
|
||||
className="relative size-(--preview-size) shrink-0"
|
||||
style={previewSizeStyle}
|
||||
>
|
||||
<img
|
||||
src={preview ?? undefined}
|
||||
@@ -170,71 +163,75 @@ export const FileUpload = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderUploadText = () => {
|
||||
if (Array.isArray(uploadText)) {
|
||||
return (
|
||||
<p className="text-sm font-semibold">
|
||||
{uploadText.map((segment, i) => (
|
||||
<span key={i} className={segment.colorClass || ''}>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-sm font-semibold">
|
||||
{uploadText || t('components.fileUpload.clickToUpload')}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultContent = (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{showPreview && preview ? (
|
||||
renderPreview()
|
||||
) : (
|
||||
<div
|
||||
style={{ width: previewSize, height: previewSize }}
|
||||
className="flex items-center justify-center"
|
||||
const uploadPrompt = Array.isArray(uploadText)
|
||||
? uploadText.map((segment, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
segment.highlight ? 'text-primary' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
<img src={ImagesIcon} className="h-10 w-10" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">
|
||||
{isDragActive ? (
|
||||
<p className="text-sm font-semibold">
|
||||
{dragActiveText || t('components.fileUpload.dropFiles')}
|
||||
</p>
|
||||
) : (
|
||||
renderUploadText()
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#A3A3A3]">
|
||||
{fileTypeText || t('components.fileUpload.fileTypes')}{' '}
|
||||
{maxSize / 1024 / 1024}
|
||||
{sizeLimitText || t('components.fileUpload.sizeLimitUnit')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{segment.text}
|
||||
</span>
|
||||
))
|
||||
: uploadText || t('components.fileUpload.clickToUpload');
|
||||
|
||||
// The Dropzone owns the drag state; its data attribute swaps the prompt.
|
||||
const title = (
|
||||
<>
|
||||
<span className="in-data-[drag-active]:hidden">{uploadPrompt}</span>
|
||||
<span className="hidden in-data-[drag-active]:inline">
|
||||
{dragActiveText || t('components.fileUpload.dropFiles')}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const description = (
|
||||
<>
|
||||
{fileTypeText || t('components.fileUpload.fileTypes')}{' '}
|
||||
{maxSize / 1024 / 1024}
|
||||
{sizeLimitText || t('components.fileUpload.sizeLimitUnit')}
|
||||
</>
|
||||
);
|
||||
|
||||
// With a preview, the image takes the icon's place; the text block mirrors
|
||||
// the Dropzone's own title/description layout.
|
||||
const previewContent =
|
||||
showPreview && preview ? (
|
||||
<>
|
||||
{renderPreview()}
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
<span className="text-muted-foreground text-xs">{description}</span>
|
||||
</span>
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
const error =
|
||||
errors.length > 0
|
||||
? errors.map((message, i) => (
|
||||
<span key={i} className="block truncate">
|
||||
{message}
|
||||
</span>
|
||||
))
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div {...getRootProps({ className: currentClassName })}>
|
||||
<input {...getInputProps()} />
|
||||
{children || defaultContent}
|
||||
{errors.length > 0 && (
|
||||
<div className="absolute right-0 left-0 mt-[2px] px-4 text-xs text-red-600">
|
||||
{errors.map((error, i) => (
|
||||
<p key={i} className="truncate">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
maxFiles={maxFiles}
|
||||
maxSize={maxSize}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
title={title}
|
||||
description={description}
|
||||
icon={<ImageUp />}
|
||||
error={error}
|
||||
className={className}
|
||||
>
|
||||
{children ?? previewContent}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
@@ -123,8 +123,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
|
||||
try {
|
||||
const clientId: string = envVar('VITE_GOOGLE_CLIENT_ID');
|
||||
const developerKey: string =
|
||||
envVar('VITE_GOOGLE_PICKER_API_KEY') ?? '';
|
||||
const developerKey: string = envVar('VITE_GOOGLE_PICKER_API_KEY') ?? '';
|
||||
|
||||
// Derive appId from clientId (extract numeric part before first dash)
|
||||
const appId = clientId ? clientId.split('-')[0] : null;
|
||||
@@ -265,7 +264,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
/>
|
||||
|
||||
{isConnected && (
|
||||
<div className="border-border dark:border-border rounded-lg border">
|
||||
<div className="border-border rounded-lg border">
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
@@ -275,7 +274,6 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleOpenPicker()}
|
||||
className="bg-[#A076F6] hover:bg-[#8A5FD4]"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading
|
||||
@@ -287,7 +285,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
</div>
|
||||
|
||||
{selectedFiles.length === 0 && selectedFolders.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
'modals.uploadDoc.connectors.googleDrive.noFilesSelected',
|
||||
)}
|
||||
@@ -296,13 +294,13 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{selectedFolders.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">
|
||||
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{t('modals.uploadDoc.connectors.googleDrive.folders')}
|
||||
</h4>
|
||||
{selectedFolders.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="flex items-center border-b border-gray-200 p-2 dark:border-gray-700"
|
||||
className="border-border flex items-center border-b p-2"
|
||||
>
|
||||
<img
|
||||
src={folder.iconUrl}
|
||||
@@ -316,8 +314,8 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
variant="ghost-destructive"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const newSelectedFolders =
|
||||
selectedFolders.filter(
|
||||
@@ -329,7 +327,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
newSelectedFolders.map((f) => f.id),
|
||||
);
|
||||
}}
|
||||
className="ml-2 h-auto p-0 text-sm text-red-500 hover:text-red-700 hover:no-underline"
|
||||
className="ml-2"
|
||||
>
|
||||
{t(
|
||||
'modals.uploadDoc.connectors.googleDrive.remove',
|
||||
@@ -342,13 +340,13 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">
|
||||
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{t('modals.uploadDoc.connectors.googleDrive.files')}
|
||||
</h4>
|
||||
{selectedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center border-b border-gray-200 p-2 dark:border-gray-700"
|
||||
className="border-border flex items-center border-b p-2"
|
||||
>
|
||||
<img
|
||||
src={file.iconUrl}
|
||||
@@ -362,8 +360,8 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
variant="ghost-destructive"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const newSelectedFiles = selectedFiles.filter(
|
||||
(f) => f.id !== file.id,
|
||||
@@ -374,7 +372,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
selectedFolders.map((f) => f.id),
|
||||
);
|
||||
}}
|
||||
className="ml-2 h-auto p-0 text-sm text-red-500 hover:text-red-700 hover:no-underline"
|
||||
className="ml-2"
|
||||
>
|
||||
{t(
|
||||
'modals.uploadDoc.connectors.googleDrive.remove',
|
||||
|
||||
@@ -162,8 +162,9 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground mr-3 h-[29px] w-[29px] rounded-full p-2 dark:border-0"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
className="mr-3"
|
||||
onClick={onBackToDocuments}
|
||||
aria-label={t('settings.sources.backToAll')}
|
||||
>
|
||||
|
||||
@@ -41,10 +41,10 @@ const Help = () => {
|
||||
<div className="relative inline-block text-sm" ref={dropdownRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="sidebar-item"
|
||||
ref={buttonRef}
|
||||
onClick={toggleDropdown}
|
||||
className="hover:bg-sidebar-accent mx-4 my-auto w-full justify-start gap-2.5 rounded-3xl pr-0 pl-3"
|
||||
className="mx-4 my-auto w-full"
|
||||
>
|
||||
<img
|
||||
src={Info}
|
||||
|
||||
@@ -9,7 +9,7 @@ import remarkGfm from 'remark-gfm';
|
||||
export default function MarkdownPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="flex flex-col gap-3 text-sm leading-normal break-words whitespace-pre-wrap text-gray-800 dark:text-gray-200">
|
||||
<div className="text-foreground flex flex-col gap-3 text-sm leading-normal break-words whitespace-pre-wrap">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
@@ -19,7 +19,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
@@ -51,7 +51,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
|
||||
code({ children, ...rest }) {
|
||||
return (
|
||||
<code
|
||||
className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal"
|
||||
className="bg-accent rounded-md px-2 py-1 text-xs font-normal"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
@@ -60,7 +60,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-gray-300 pl-4 italic dark:border-gray-600">
|
||||
<blockquote className="border-border border-l-4 pl-4 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,42 @@ describe('MermaidRenderer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector('pre.mermaid')).not.toBeNull();
|
||||
// The diagram host is the only <pre> while the code view is closed.
|
||||
expect(container.querySelectorAll('pre')).toHaveLength(1);
|
||||
expect(container.querySelector('pre')?.id).toMatch(/^mermaid-/);
|
||||
expect(container.querySelector('svg[data-rendered="true"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens the Download menu with a menu item per format', async () => {
|
||||
renderMermaidDiagramMock.mockResolvedValue({ svg: '<svg></svg>' });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MermaidRenderer code={'flowchart LR\nA --> B'} isLoading={false} />,
|
||||
);
|
||||
});
|
||||
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[title="mermaid.downloadOptions"]',
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.getAttribute('aria-haspopup')).toBe('menu');
|
||||
expect(document.querySelector('[role="menu"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(trigger?.getAttribute('aria-expanded')).toBe('true');
|
||||
const items = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((item) => item.textContent);
|
||||
expect(items).toEqual([
|
||||
'Download as SVG',
|
||||
'Download as PNG',
|
||||
'Download as MMD',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { CircleAlert } from 'lucide-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import {
|
||||
oneLight,
|
||||
@@ -11,7 +12,14 @@ import { selectStatus } from '../conversation/conversationSlice';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import CopyButton from './CopyButton';
|
||||
import { renderMermaidDiagram } from './mermaidSecurity';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { MermaidRendererProps } from './types';
|
||||
|
||||
const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
@@ -27,8 +35,6 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
const status = useSelector(selectStatus);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCode, setShowCode] = useState<boolean>(false);
|
||||
const [showDownloadMenu, setShowDownloadMenu] = useState<boolean>(false);
|
||||
const downloadMenuRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLPreElement>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{
|
||||
@@ -126,22 +132,6 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
};
|
||||
}, [code, isDarkTheme, isCurrentlyLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
downloadMenuRef.current &&
|
||||
!downloadMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDownloadMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showDownloadMenu]);
|
||||
|
||||
const downloadSvg = (): void => {
|
||||
const element = document.getElementById(diagramId.current);
|
||||
if (!element) return;
|
||||
@@ -274,61 +264,43 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
const errorRender = !isCurrentlyLoading && error;
|
||||
|
||||
return (
|
||||
<div className="w-inherit group border-border bg-card relative overflow-hidden rounded-xl border">
|
||||
<div className="group border-border bg-card relative overflow-hidden rounded-xl border">
|
||||
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
mermaid
|
||||
</span>
|
||||
<span className="text-foreground text-xs font-medium">mermaid</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyButton textToCopy={String(code).replace(/\n$/, '')} />
|
||||
|
||||
{showDiagramOptions && (
|
||||
<div className="relative" ref={downloadMenuRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
|
||||
className="h-auto gap-1 rounded-sm bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
|
||||
title={t('mermaid.downloadOptions')}
|
||||
>
|
||||
Download <span className="ml-1">▼</span>
|
||||
</Button>
|
||||
{showDownloadMenu && (
|
||||
<div className="border-border bg-card absolute right-0 z-10 mt-1 w-40 rounded-sm border shadow-lg">
|
||||
<ul>
|
||||
{downloadOptions.map((option, index) => (
|
||||
<li key={index}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
option.action();
|
||||
setShowDownloadMenu(false);
|
||||
}}
|
||||
className="h-auto w-full justify-start rounded-none px-4 py-2 text-left text-xs font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="xs"
|
||||
title={t('mermaid.downloadOptions')}
|
||||
>
|
||||
Download <span className="ml-1">▼</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{downloadOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.label}
|
||||
onSelect={() => option.action()}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{showDiagramOptions && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
variant={showCode ? 'secondary' : 'ghost-muted'}
|
||||
size="xs"
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className={`h-auto rounded px-2 py-1 text-xs ${
|
||||
showCode
|
||||
? 'bg-blue-200 hover:bg-blue-200/90 dark:bg-blue-800 dark:hover:bg-blue-800/90'
|
||||
: 'bg-gray-100 hover:bg-gray-100/90 dark:bg-gray-700 dark:hover:bg-gray-700/90'
|
||||
}`}
|
||||
title={t('mermaid.viewCode')}
|
||||
>
|
||||
Code
|
||||
@@ -339,16 +311,17 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
|
||||
{isCurrentlyLoading ? (
|
||||
<div className="bg-card flex items-center justify-center p-4">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Loading diagram...
|
||||
</div>
|
||||
</div>
|
||||
) : errorRender ? (
|
||||
<div className="m-2 rounded-sm border-2 border-red-400 dark:border-red-700">
|
||||
<div className="overflow-auto bg-red-100 px-4 py-2 text-sm wrap-break-word whitespace-normal text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
<Alert variant="destructive" className="m-2 w-auto">
|
||||
<CircleAlert />
|
||||
<AlertDescription className="overflow-auto wrap-break-word whitespace-normal">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -373,11 +346,14 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() =>
|
||||
setZoomFactor((prev) => Math.max(1, prev - 0.5))
|
||||
}
|
||||
className="h-auto px-1 py-0 text-white hover:bg-gray-600 hover:text-white"
|
||||
title={t('mermaid.decreaseZoom')}
|
||||
/* eslint-disable-next-line shadcn/no-restyle --
|
||||
zoom controls sit on the bg-black/70 overlay; ghost's accent hover would paint a light square on it */
|
||||
className="hover:bg-white/20 hover:text-white dark:hover:bg-white/20"
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
@@ -393,11 +369,14 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() =>
|
||||
setZoomFactor((prev) => Math.min(6, prev + 0.5))
|
||||
}
|
||||
className="h-auto px-1 py-0 text-white hover:bg-gray-600 hover:text-white"
|
||||
title={t('mermaid.increaseZoom')}
|
||||
/* eslint-disable-next-line shadcn/no-restyle --
|
||||
zoom controls sit on the bg-black/70 overlay; ghost's accent hover would paint a light square on it */
|
||||
className="hover:bg-white/20 hover:text-white dark:hover:bg-white/20"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
@@ -406,7 +385,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
)}
|
||||
<pre
|
||||
ref={diagramRef}
|
||||
className="mermaid w-full select-none"
|
||||
className="w-full select-none"
|
||||
id={diagramId.current}
|
||||
key={`mermaid-${diagramId.current}`}
|
||||
style={{
|
||||
@@ -424,7 +403,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
{showCode && (
|
||||
<div className="border-border border-t">
|
||||
<div className="bg-muted p-2">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
Mermaid Code
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { envVar } from '@/env';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -1712,7 +1713,7 @@ export default function MessageInput({
|
||||
)}
|
||||
{sendArmed && sendReadiness.state === 'blocked' && (
|
||||
<div
|
||||
className="px-2 pb-1 text-xs text-[#B42318] sm:px-3"
|
||||
className="text-destructive px-2 pb-1 text-xs sm:px-3"
|
||||
role="alert"
|
||||
>
|
||||
{t('conversation.attachments.sendBlockedByFailed', {
|
||||
@@ -1721,7 +1722,7 @@ export default function MessageInput({
|
||||
</div>
|
||||
)}
|
||||
{voiceError && (
|
||||
<div className="px-2 pb-1 text-xs text-[#B42318] sm:px-3">
|
||||
<div className="text-destructive px-2 pb-1 text-xs sm:px-3">
|
||||
{voiceError}
|
||||
</div>
|
||||
)}
|
||||
@@ -1742,7 +1743,7 @@ export default function MessageInput({
|
||||
}
|
||||
tabIndex={1}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
className="inputbox-style dark:text-foreground dark:placeholder:text-muted-foreground/50 w-full scrollbar-thin overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3"
|
||||
className="inputbox-style dark:text-foreground placeholder:text-muted-foreground w-full scrollbar-thin overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 focus:outline-hidden sm:px-3"
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
aria-label={t('inputPlaceholder')}
|
||||
@@ -1791,9 +1792,10 @@ export default function MessageInput({
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
onClick={handleCancel}
|
||||
aria-label={t('cancel')}
|
||||
className="bg-primary ml-auto h-7 w-7 shrink-0 rounded-full text-white sm:h-9 sm:w-9"
|
||||
className="ml-auto h-7 w-7 shrink-0 sm:h-9 sm:w-9"
|
||||
disabled={!loading}
|
||||
>
|
||||
<div className="flex h-3 w-3 items-center justify-center rounded-sm bg-white sm:h-3.5 sm:w-3.5" />
|
||||
@@ -1803,13 +1805,18 @@ export default function MessageInput({
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
onClick={handleSubmit}
|
||||
aria-label={t('send')}
|
||||
className={`ml-auto h-7 w-7 shrink-0 rounded-full transition-colors duration-300 ease-in-out sm:h-9 sm:w-9 ${
|
||||
canSubmit
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-muted text-muted-foreground dark:bg-accent dark:text-muted-foreground'
|
||||
}`}
|
||||
className={cn(
|
||||
'ml-auto h-7 w-7 shrink-0 sm:h-9 sm:w-9',
|
||||
!canSubmit &&
|
||||
/* eslint-disable-next-line shadcn/no-restyle --
|
||||
The empty composer's send button is a grey circle, not a
|
||||
faded brand one: no variant is neutral while disabled, and
|
||||
secondary is the brand-tinted pressed state. */
|
||||
'bg-muted text-muted-foreground dark:bg-accent',
|
||||
)}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<SendArrow
|
||||
@@ -1839,7 +1846,7 @@ export default function MessageInput({
|
||||
<span className="text-muted-foreground dark:text-muted-foreground px-2 text-2xl font-bold">
|
||||
{t('modals.uploadDoc.drag.title')}
|
||||
</span>
|
||||
<span className="text-s text-muted-foreground dark:text-muted-foreground w-48 p-2 text-center">
|
||||
<span className="text-muted-foreground dark:text-muted-foreground w-48 p-2 text-center text-sm">
|
||||
{t('modals.uploadDoc.drag.description')}
|
||||
</span>
|
||||
</div>,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const media = { isMobile: false, isTablet: false, isDesktop: true };
|
||||
vi.mock('../hooks', () => ({
|
||||
useMediaQuery: () => media,
|
||||
useDarkTheme: () => [false, () => undefined],
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MultiSelectPopover } from './MultiSelectPopover';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const items = [
|
||||
{ id: 'a', label: 'Alpha' },
|
||||
{ id: 'b', label: 'Beta' },
|
||||
];
|
||||
|
||||
describe('MultiSelectPopover', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
media.isMobile = false;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = () =>
|
||||
act(() => {
|
||||
root.render(
|
||||
<MultiSelectPopover
|
||||
open
|
||||
trigger={<button type="button">Open</button>}
|
||||
items={items}
|
||||
selectedIds={['b']}
|
||||
onToggle={() => undefined}
|
||||
className="max-h-40"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
it('puts className on the desktop popover', () => {
|
||||
render();
|
||||
const content = document.querySelector('[data-slot="popover-content"]');
|
||||
expect(content?.className).toContain('max-h-40');
|
||||
});
|
||||
|
||||
it('puts className on the mobile sheet', () => {
|
||||
media.isMobile = true;
|
||||
render();
|
||||
const content = document.querySelector('[data-slot="sheet-content"]');
|
||||
expect(content?.className).toContain('max-h-40');
|
||||
});
|
||||
|
||||
it('marks chosen rows with CommandItem checked and a primary tick', () => {
|
||||
render();
|
||||
const rows = document.querySelectorAll('[data-slot="command-item"]');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].hasAttribute('data-checked')).toBe(false);
|
||||
expect(rows[1].getAttribute('data-checked')).toBe('true');
|
||||
rows.forEach((row) => expect(row.querySelector('img')).toBeNull());
|
||||
const tick = rows[1].querySelector('svg');
|
||||
expect(tick).not.toBeNull();
|
||||
expect(tick?.getAttribute('class')).toContain('text-primary');
|
||||
expect(rows[0].querySelector('svg')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CheckmarkIcon from '../assets/checkmark.svg';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import { useDarkTheme, useMediaQuery } from '../hooks';
|
||||
@@ -42,7 +42,8 @@ export type MultiSelectPopoverProps = {
|
||||
footer?: React.ReactNode;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
contentClassName?: string;
|
||||
/** Extra layout classes for the popup (the desktop popover or the mobile sheet). */
|
||||
className?: string;
|
||||
emptyMessage?: string;
|
||||
selectedFirst?: boolean;
|
||||
title?: string;
|
||||
@@ -80,7 +81,7 @@ export function MultiSelectPopover({
|
||||
footer,
|
||||
align = 'start',
|
||||
side = 'bottom',
|
||||
contentClassName,
|
||||
className,
|
||||
emptyMessage,
|
||||
selectedFirst = false,
|
||||
title,
|
||||
@@ -128,14 +129,15 @@ export function MultiSelectPopover({
|
||||
onSelect={() => {
|
||||
if (!item.disabled) onToggle(item.id);
|
||||
}}
|
||||
className="dark:border-border data-[selected=true]:bg-accent flex cursor-pointer items-center justify-between gap-2 rounded-none border-b border-[#D9D9D9] px-3 py-3 last:border-b-0"
|
||||
checked={isSelected}
|
||||
className="cursor-pointer justify-between"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<div className="mr-3 flex grow items-center overflow-hidden">
|
||||
{renderIcon(item.icon)}
|
||||
<div className="overflow-hidden">
|
||||
<p
|
||||
className="overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900 dark:text-white"
|
||||
className="text-foreground overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
@@ -150,17 +152,11 @@ export function MultiSelectPopover({
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="border-border bg-card flex h-4 w-4 shrink-0 items-center justify-center rounded-xs border-2 p-[0.5px]"
|
||||
className="border-border bg-card flex h-4 w-4 shrink-0 items-center justify-center rounded-xs border-2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isSelected && (
|
||||
<img
|
||||
src={CheckmarkIcon}
|
||||
alt=""
|
||||
width={10}
|
||||
height={10}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Check className="text-primary size-2.5" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
@@ -175,19 +171,17 @@ export function MultiSelectPopover({
|
||||
aria-hidden="true"
|
||||
className="mb-3 h-16 w-16"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{emptyMessage || t('settings.tools.noToolsFound', 'No results found')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const commandBody = (
|
||||
<Command shouldFilter={searchable} className="bg-transparent">
|
||||
<Command shouldFilter={searchable}>
|
||||
{title && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<h3 className="text-foreground text-lg font-medium">{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
{searchable && (
|
||||
@@ -197,11 +191,11 @@ export function MultiSelectPopover({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-foreground flex items-center justify-center py-8 dark:text-white">
|
||||
<div className="text-foreground flex items-center justify-center py-8">
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dark:border-border mx-4 my-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-[#D9D9D9]">
|
||||
<div className="border-border mx-4 my-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<CommandList className="max-h-none min-h-0 flex-1 overflow-y-auto">
|
||||
<CommandEmpty>{renderEmptyState()}</CommandEmpty>
|
||||
{hasGroups ? (
|
||||
@@ -212,23 +206,20 @@ export function MultiSelectPopover({
|
||||
<CommandGroup
|
||||
key={`group-${groupKey || 'ungrouped'}`}
|
||||
heading={groupKey || undefined}
|
||||
className="[&_[cmdk-group-heading]]:bg-muted/50 [&_[cmdk-group-heading]]:dark:bg-card [&_[cmdk-group-heading]]:text-muted-foreground p-0 [&_[cmdk-group-heading]]:sticky [&_[cmdk-group-heading]]:top-0 [&_[cmdk-group-heading]]:z-10 [&_[cmdk-group-heading]]:border-b [&_[cmdk-group-heading]]:border-[#D9D9D9] [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:dark:border-[#2E2E2E]"
|
||||
>
|
||||
{groupItems.map(renderItem)}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CommandGroup className="p-0">
|
||||
{orderedItems.map(renderItem)}
|
||||
</CommandGroup>
|
||||
<CommandGroup>{orderedItems.map(renderItem)}</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footer && (
|
||||
<div className="border-border dark:border-border shrink-0 border-t px-4 py-4">
|
||||
<div className="border-border shrink-0 border-t px-4 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
@@ -244,15 +235,18 @@ export function MultiSelectPopover({
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'border-border bg-background dark:border-border dark:bg-card flex max-h-[85vh] flex-col gap-0 overflow-hidden rounded-t-2xl p-0 pb-[env(safe-area-inset-bottom)]',
|
||||
contentClassName,
|
||||
/* eslint-disable-next-line shadcn/no-restyle, shadcn/no-arbitrary-values --
|
||||
bottom-sheet shape: rounded top, no gap, card fill in dark to match Command's
|
||||
bg-popover, and pb-[env(safe-area-inset-bottom)] to clear the iPhone home indicator. */
|
||||
'dark:bg-card flex max-h-[85vh] flex-col gap-0 overflow-hidden rounded-t-2xl p-0 pb-[env(safe-area-inset-bottom)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
{title || effectivePlaceholder}
|
||||
</SheetTitle>
|
||||
<div
|
||||
className="mx-auto mt-2 mb-1 h-1.5 w-12 shrink-0 rounded-full bg-gray-300 dark:bg-gray-600"
|
||||
className="bg-border mx-auto mt-2 mb-1 h-1.5 w-12 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{commandBody}
|
||||
@@ -268,8 +262,8 @@ export function MultiSelectPopover({
|
||||
align={align}
|
||||
side={side}
|
||||
className={cn(
|
||||
'border-border bg-background dark:border-border dark:bg-card flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden rounded-lg p-0 shadow-md',
|
||||
contentClassName,
|
||||
'flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden p-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{commandBody}
|
||||
|
||||
@@ -25,114 +25,90 @@ export default function Notification({
|
||||
}: NotificationProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2) translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.star {
|
||||
animation: twinkle var(--duration) ease-in-out infinite;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
`}</style>
|
||||
<a
|
||||
className="group absolute right-2 bottom-24 z-20 flex w-3/4 items-center justify-center gap-2 overflow-hidden rounded-lg px-2 py-4 sm:right-4 sm:bottom-6 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, #390086 0%, #6222B7 100%), linear-gradient(90deg, rgba(57, 0, 134, 0) 0%, #6222B7 53.02%, rgba(57, 0, 134, 0) 100%)',
|
||||
}}
|
||||
href={notificationLink}
|
||||
target="_blank"
|
||||
aria-label={t('notification.ariaLabel')}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{/* Animated stars background */}
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{stars.map((star) => (
|
||||
<svg
|
||||
key={star.id}
|
||||
className="star absolute"
|
||||
style={
|
||||
{
|
||||
width: `${star.size * 4}px`,
|
||||
height: `${star.size * 4}px`,
|
||||
left: `${star.left}%`,
|
||||
top: `${star.top}%`,
|
||||
opacity: star.opacity,
|
||||
filter: `drop-shadow(0 0 ${star.size}px rgba(255, 255, 255, 0.5))`,
|
||||
'--duration': `${star.animationDuration}s`,
|
||||
'--delay': `${star.animationDelay}s`,
|
||||
} as React.CSSProperties & {
|
||||
'--duration': string;
|
||||
'--delay': string;
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* 4-pointed Christmas star */}
|
||||
<path
|
||||
d="M12 0L13.5 10.5L24 12L13.5 13.5L12 24L10.5 13.5L0 12L10.5 10.5L12 0Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="relative z-10 text-xs leading-6 font-semibold text-white xl:text-sm xl:leading-7">
|
||||
{notificationText}
|
||||
</p>
|
||||
<span className="relative z-10 flex items-center">
|
||||
<a
|
||||
className="notification-banner group absolute right-2 bottom-24 z-20 flex w-3/4 items-center justify-center gap-2 overflow-hidden rounded-lg px-2 py-4 sm:right-4 sm:bottom-6 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
|
||||
href={notificationLink}
|
||||
target="_blank"
|
||||
aria-label={t('notification.ariaLabel')}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{/* Animated stars background. The banner is always the purple gradient,
|
||||
so its imagery is white in both themes. */}
|
||||
<div className="pointer-events-none absolute inset-0 text-white">
|
||||
{stars.map((star) => (
|
||||
<svg
|
||||
width="18"
|
||||
height="13"
|
||||
viewBox="0 0 18 13"
|
||||
key={star.id}
|
||||
className="notification-star absolute"
|
||||
style={
|
||||
{
|
||||
'--star-size': `${star.size * 4}px`,
|
||||
'--star-left': `${star.left}%`,
|
||||
'--star-top': `${star.top}%`,
|
||||
'--star-opacity': String(star.opacity),
|
||||
'--star-glow': `${star.size}px`,
|
||||
'--star-duration': `${star.animationDuration}s`,
|
||||
'--star-delay': `${star.animationDelay}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="overflow-visible"
|
||||
>
|
||||
{/* Arrow tail - grows leftward from arrow head's back point on hover */}
|
||||
<rect
|
||||
x="4"
|
||||
y="5.75"
|
||||
width="8"
|
||||
height="1.5"
|
||||
fill="white"
|
||||
className="scale-x-0 transition-transform duration-300 ease-out group-hover:scale-x-100"
|
||||
style={{ transformOrigin: '12px 6.5px' }}
|
||||
/>
|
||||
{/* Arrow head - pushed forward by the tail on hover */}
|
||||
{/* 4-pointed Christmas star */}
|
||||
<path
|
||||
d="M13.0303 7.03033C13.3232 6.73744 13.3232 6.26256 13.0303 5.96967L8.25736 1.1967C7.96447 0.903806 7.48959 0.903806 7.1967 1.1967C6.90381 1.48959 6.90381 1.96447 7.1967 2.25736L11.4393 6.5L7.1967 10.7426C6.90381 11.0355 6.90381 11.5104 7.1967 11.8033C7.48959 12.0962 7.96447 12.0962 8.25736 11.8033L13.0303 7.03033Z"
|
||||
fill="white"
|
||||
className="transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||
d="M12 0L13.5 10.5L24 12L13.5 13.5L12 24L10.5 13.5L0 12L10.5 10.5L12 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2 z-30 h-4 w-4 rounded-none bg-transparent p-0 hover:bg-transparent hover:opacity-70"
|
||||
aria-label={t('notification.closeAriaLabel')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleCloseNotification();
|
||||
}}
|
||||
<p className="relative z-10 text-xs leading-6 font-semibold text-white xl:text-sm xl:leading-7">
|
||||
{notificationText}
|
||||
</p>
|
||||
<span className="relative z-10 flex items-center text-white">
|
||||
<svg
|
||||
width="18"
|
||||
height="13"
|
||||
viewBox="0 0 18 13"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<img className="w-full" src={close} alt="Close notification" />
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
{/* Arrow tail - grows leftward from arrow head's back point on hover */}
|
||||
<rect
|
||||
x="4"
|
||||
y="5.75"
|
||||
width="8"
|
||||
height="1.5"
|
||||
fill="currentColor"
|
||||
className="origin-[12px_6.5px] scale-x-0 transition-transform duration-300 ease-out group-hover:scale-x-100"
|
||||
/>
|
||||
{/* Arrow head - pushed forward by the tail on hover */}
|
||||
<path
|
||||
d="M13.0303 7.03033C13.3232 6.73744 13.3232 6.26256 13.0303 5.96967L8.25736 1.1967C7.96447 0.903806 7.48959 0.903806 7.1967 1.1967C6.90381 1.48959 6.90381 1.96447 7.1967 2.25736L11.4393 6.5L7.1967 10.7426C6.90381 11.0355 6.90381 11.5104 7.1967 11.8033C7.48959 12.0962 7.96447 12.0962 8.25736 11.8033L13.0303 7.03033Z"
|
||||
fill="currentColor"
|
||||
className="transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/* eslint-disable-next-line shadcn/no-restyle -- a 16px bare close on
|
||||
the promo gradient: no size fits, and ghost's accent hover fill
|
||||
would paint a grey square on the purple. */
|
||||
className="absolute top-2 right-2 z-30 h-4 w-4 rounded-none bg-transparent p-0 hover:bg-transparent hover:opacity-70"
|
||||
aria-label={t('notification.closeAriaLabel')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleCloseNotification();
|
||||
}}
|
||||
>
|
||||
<img className="w-full" src={close} alt="Close notification" />
|
||||
</Button>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -17,19 +17,21 @@ export default function ProfileButton() {
|
||||
if (authType !== 'oidc' || (!userName && !userEmail)) return null;
|
||||
|
||||
const initial = (userName || userEmail || '?').charAt(0).toUpperCase();
|
||||
const renderAvatar = (size: string, text: string) =>
|
||||
// `default` is size-8/text-sm, `xl` is size-10/text-base; the size variant
|
||||
// also brings the flex centring and the overflow-hidden that clips both the
|
||||
// photo and the initials to the circle.
|
||||
const renderAvatar = (size: 'default' | 'xl') =>
|
||||
userPicture ? (
|
||||
<Avatar
|
||||
size={size}
|
||||
shape="circle"
|
||||
src={userPicture}
|
||||
alt={userName || userEmail || 'User avatar'}
|
||||
className={size}
|
||||
imgClassName={`${size} rounded-full object-cover`}
|
||||
imgClassName="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Avatar className={size}>
|
||||
<span
|
||||
className={`bg-primary text-primary-foreground flex ${size} items-center justify-center rounded-full ${text} font-medium`}
|
||||
>
|
||||
<Avatar size={size} shape="circle">
|
||||
<span className="bg-primary text-primary-foreground flex size-full items-center justify-center font-medium">
|
||||
{initial}
|
||||
</span>
|
||||
</Avatar>
|
||||
@@ -43,15 +45,15 @@ export default function ProfileButton() {
|
||||
aria-label={t('auth.account')}
|
||||
className="ring-offset-background focus-visible:ring-ring hover:ring-primary/40 rounded-full transition outline-none hover:ring-2 hover:ring-offset-2 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
{renderAvatar('size-8', 'text-sm')}
|
||||
{renderAvatar('default')}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={8} className="w-64 p-0">
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
{renderAvatar('size-10', 'text-base')}
|
||||
{renderAvatar('xl')}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
{userName && (
|
||||
<p className="text-foreground truncate text-sm font-medium dark:text-white">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{userName}
|
||||
</p>
|
||||
)}
|
||||
@@ -68,7 +70,7 @@ export default function ProfileButton() {
|
||||
type="button"
|
||||
onClick={logout}
|
||||
data-testid="oidc-signout"
|
||||
className="text-foreground hover:bg-sidebar-accent flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm dark:text-white"
|
||||
className="text-foreground hover:bg-sidebar-accent flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<LogOut
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
|
||||
@@ -29,9 +29,10 @@ export default function SourcesPopoverFooter({
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="outline-primary"
|
||||
shape="pill"
|
||||
onClick={onUploadClick}
|
||||
className="border-primary text-primary hover:bg-primary/90 h-auto w-auto self-start rounded-full border bg-transparent px-4 py-2 text-sm font-medium shadow-none transition-colors duration-200 hover:text-white"
|
||||
className="w-auto self-start"
|
||||
>
|
||||
{t('settings.sources.uploadNew')}
|
||||
</Button>
|
||||
|
||||
@@ -1,33 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Spinner as UiSpinner } from './ui/spinner';
|
||||
|
||||
type SpinnerProps = {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
};
|
||||
|
||||
const SIZE_MAP: Record<NonNullable<SpinnerProps['size']>, string> = {
|
||||
small: '20px',
|
||||
medium: '30px',
|
||||
large: '40px',
|
||||
};
|
||||
const SIZE_MAP = { small: 'sm', medium: 'default', large: 'lg' } as const;
|
||||
|
||||
/**
|
||||
* Compatibility wrapper over `ui/spinner`. New code should import
|
||||
* `Spinner` from `@/components/ui/spinner` and use `sm | default | lg`.
|
||||
*/
|
||||
export default function Spinner({ size = 'medium' }: SpinnerProps) {
|
||||
const spinnerSize = SIZE_MAP[size];
|
||||
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: spinnerSize,
|
||||
height: spinnerSize,
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
background: `
|
||||
radial-gradient(farthest-side, currentColor 94%, transparent) top/8px 8px no-repeat,
|
||||
conic-gradient(transparent 30%, currentColor)
|
||||
`,
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, transparent calc(100% - 8px), #000 0)',
|
||||
mask: 'radial-gradient(farthest-side, transparent calc(100% - 8px), #000 0)',
|
||||
animation: 'spinner-rotate 1s infinite linear',
|
||||
color: 'currentColor',
|
||||
};
|
||||
|
||||
return <div className="loader" role="status" style={spinnerStyle} />;
|
||||
return <UiSpinner size={SIZE_MAP[size]} />;
|
||||
}
|
||||
@@ -174,11 +174,10 @@ export default function SpeakButton({ text }: { text: string }) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant={isSpeaking || isLoading ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className={`cursor-pointer rounded-full ${
|
||||
isSpeaking || isLoading ? 'bg-accent' : ''
|
||||
}`}
|
||||
shape="pill"
|
||||
className="cursor-pointer"
|
||||
onClick={handleSpeakClick}
|
||||
aria-label={
|
||||
isLoading
|
||||
@@ -192,9 +191,9 @@ export default function SpeakButton({ text }: { text: string }) {
|
||||
{isLoading ? (
|
||||
<Spinner size="small" />
|
||||
) : isSpeaking ? (
|
||||
<Stopspeech className="fill-none" />
|
||||
<Stopspeech />
|
||||
) : (
|
||||
<Speaker className="fill-none" />
|
||||
<Speaker />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -37,12 +37,6 @@ export default function ToolIcon({ name, className, title }: ToolIconProps) {
|
||||
? { role: 'img' as const, 'aria-label': title }
|
||||
: { 'aria-hidden': true as const };
|
||||
return (
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-6 w-6 text-neutral-700 dark:text-neutral-200',
|
||||
className,
|
||||
)}
|
||||
{...a11y}
|
||||
/>
|
||||
<Icon className={cn('text-foreground h-6 w-6', className)} {...a11y} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import uploadReducer, {
|
||||
addUploadTask,
|
||||
type UploadTask,
|
||||
} from '../upload/uploadSlice';
|
||||
import UploadToast from './UploadToast';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const makeStore = () => configureStore({ reducer: { upload: uploadReducer } });
|
||||
|
||||
const task = (over: Partial<UploadTask>): UploadTask => ({
|
||||
id: 't1',
|
||||
fileName: 'a.pdf',
|
||||
progress: 40,
|
||||
status: 'training',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('UploadToast', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let store: ReturnType<typeof makeStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
store = makeStore();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<UploadToast />
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
it('renders nothing without tasks', async () => {
|
||||
await render();
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders one card of rows with no rail of its own', async () => {
|
||||
store.dispatch(
|
||||
addUploadTask(task({ id: 't1', fileName: 'a.pdf', stage: 'embedding' })),
|
||||
);
|
||||
store.dispatch(
|
||||
addUploadTask(task({ id: 't2', fileName: 'b.md', status: 'completed' })),
|
||||
);
|
||||
await render();
|
||||
expect(container.querySelectorAll('[data-slot="toast"]')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-slot="toast-viewport"]')).toBeNull();
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
expect(container.querySelector('.fixed')).toBeNull();
|
||||
const rows = container.querySelectorAll('[data-slot="toast-item"]');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('b.md');
|
||||
expect(rows[0].querySelector('[data-status="success"]')).not.toBeNull();
|
||||
expect(rows[1].textContent).toContain(
|
||||
'modals.uploadDoc.progress.embedding',
|
||||
);
|
||||
expect(rows[1].querySelector('[role="progressbar"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-slot="toast-title"]')?.textContent,
|
||||
).toBe('modals.uploadDoc.progress.upload');
|
||||
});
|
||||
|
||||
it('tints the header and explains a failure', async () => {
|
||||
store.dispatch(
|
||||
addUploadTask(task({ status: 'failed', errorMessage: 'Too big' })),
|
||||
);
|
||||
await render();
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-slot="toast-header"]')
|
||||
?.getAttribute('data-variant'),
|
||||
).toBe('destructive');
|
||||
const row = container.querySelector('[data-slot="toast-item"]');
|
||||
expect(row?.querySelector('[data-status="destructive"]')).not.toBeNull();
|
||||
const message = container.querySelector('[data-slot="toast-message"]');
|
||||
expect(message?.textContent).toBe('Too big');
|
||||
expect(message?.className).toContain('text-destructive');
|
||||
});
|
||||
|
||||
it('collapses the rows and dismisses every task', async () => {
|
||||
store.dispatch(addUploadTask(task({})));
|
||||
await render();
|
||||
const collapse = container.querySelector(
|
||||
'button[aria-label="modals.uploadDoc.progress.collapseDetails"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => collapse.click());
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="modals.uploadDoc.progress.expandDetails"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
const close = container.querySelector(
|
||||
'button[aria-label="modals.uploadDoc.progress.dismiss"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => close.click());
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { selectWorkflowPreviewOpen } from '../agents/workflow/workflowPreviewSlice';
|
||||
import CheckCircleFilled from '../assets/check-circle-filled.svg';
|
||||
import ChevronDown from '../assets/chevron-down.svg';
|
||||
import WarnIcon from '../assets/warn.svg';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
dismissUploadTask,
|
||||
selectUploadTasks,
|
||||
type UploadTask,
|
||||
} from '../upload/uploadSlice';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Toast,
|
||||
ToastActions,
|
||||
ToastContent,
|
||||
ToastHeader,
|
||||
ToastItem,
|
||||
ToastMessage,
|
||||
ToastStatus,
|
||||
ToastTitle,
|
||||
} from './ui/toast';
|
||||
|
||||
const PROGRESS_RADIUS = 10;
|
||||
const PROGRESS_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RADIUS;
|
||||
@@ -30,6 +38,11 @@ const IN_PROGRESS_STATUSES = new Set<UploadTask['status']>([
|
||||
* newest task overall if all are terminal). Per-task progress lives
|
||||
* on each row.
|
||||
*
|
||||
* Renders only its ``Toast`` card: App.tsx mounts the shared
|
||||
* ``ToastViewport`` and places this card at the bottom of the stack
|
||||
* (and moves the whole stack left while the workflow Preview drawer is
|
||||
* open).
|
||||
*
|
||||
* Dismissal: the header X dismisses every visible task at once
|
||||
* (mirrors the GDrive panel close — keeps the surface tidy without
|
||||
* per-row controls). The chevron collapses the row list.
|
||||
@@ -40,10 +53,6 @@ export default function UploadToast() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const uploadTasks = useSelector(selectUploadTasks);
|
||||
// The workflow Preview drawer occupies the right edge; shift the toast to
|
||||
// the bottom-left while it's open so it stays visible without covering the
|
||||
// drawer's attach/send controls.
|
||||
const previewOpen = useSelector(selectWorkflowPreviewOpen);
|
||||
|
||||
const visibleTasks = uploadTasks.filter((task) => !task.dismissed);
|
||||
if (visibleTasks.length === 0) return null;
|
||||
@@ -64,80 +73,62 @@ export default function UploadToast() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 z-50 flex max-w-md flex-col gap-2 ${
|
||||
previewOpen ? 'left-4' : 'right-4'
|
||||
}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
<Toast>
|
||||
<ToastHeader
|
||||
variant={primaryTask.status === 'failed' ? 'destructive' : 'default'}
|
||||
>
|
||||
<ToastTitle>{headerLabel}</ToastTitle>
|
||||
<ToastActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
aria-label={
|
||||
collapsed
|
||||
? t('modals.uploadDoc.progress.expandDetails')
|
||||
: t('modals.uploadDoc.progress.collapseDetails')
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
collapsed && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
onClick={dismissAll}
|
||||
aria-label={t('modals.uploadDoc.progress.dismiss')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</ToastActions>
|
||||
</ToastHeader>
|
||||
|
||||
<div
|
||||
className={`border-border bg-card shadow-toast w-[271px] overflow-hidden rounded-2xl border transition-all duration-300`}
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out',
|
||||
collapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
primaryTask.status !== 'failed'
|
||||
? 'bg-accent/50 dark:bg-muted'
|
||||
: 'bg-destructive/10 dark:bg-destructive/10'
|
||||
}`}
|
||||
className={cn(
|
||||
'min-h-0 overflow-hidden transition-opacity duration-300',
|
||||
collapsed ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<h3 className="dark:text-foreground text-sm leading-[16.5px] font-medium text-black">
|
||||
{headerLabel}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
aria-label={
|
||||
collapsed
|
||||
? t('modals.uploadDoc.progress.expandDetails')
|
||||
: t('modals.uploadDoc.progress.collapseDetails')
|
||||
}
|
||||
className="text-black opacity-70 hover:bg-transparent hover:opacity-100 dark:text-white dark:hover:bg-transparent"
|
||||
>
|
||||
<img
|
||||
src={ChevronDown}
|
||||
alt=""
|
||||
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${
|
||||
collapsed ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={dismissAll}
|
||||
className="text-black opacity-70 hover:bg-transparent hover:opacity-100 dark:text-white dark:hover:bg-transparent"
|
||||
aria-label={t('modals.uploadDoc.progress.dismiss')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: collapsed ? '0fr' : '1fr' }}
|
||||
>
|
||||
<div
|
||||
className={`min-h-0 overflow-hidden transition-opacity duration-300 ${
|
||||
collapsed ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<ul className="max-h-72 overflow-y-auto">
|
||||
{visibleTasks.map((task) => (
|
||||
<UploadRow key={task.id} task={task} t={t} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ToastContent scrollable>
|
||||
{visibleTasks.map((task) => (
|
||||
<UploadRow key={task.id} task={task} t={t} />
|
||||
))}
|
||||
</ToastContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,91 +145,80 @@ function UploadRow({
|
||||
const progressOffset = PROGRESS_CIRCUMFERENCE * (1 - rawProgress / 100);
|
||||
|
||||
return (
|
||||
<li className="border-border/50 border-b last:border-b-0">
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<p
|
||||
className="dark:text-muted-foreground max-w-[200px] truncate text-sm leading-[16.5px] font-normal text-black"
|
||||
title={task.fileName}
|
||||
<>
|
||||
<ToastItem
|
||||
label={<span title={task.fileName}>{task.fileName}</span>}
|
||||
meta={
|
||||
task.status === 'training' && task.stage
|
||||
? t(`modals.uploadDoc.progress.${task.stage}`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showProgress && (
|
||||
// Determinate ring: ToastStatus "pending" is an indeterminate
|
||||
// spinner, and each row reports its own percentage.
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-primary h-6 w-6 shrink-0"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={formattedProgress}
|
||||
aria-label={t('modals.uploadDoc.progress.uploadProgress', {
|
||||
progress: formattedProgress,
|
||||
})}
|
||||
>
|
||||
{task.fileName}
|
||||
</p>
|
||||
{task.status === 'training' && task.stage && (
|
||||
<span className="text-muted-foreground mt-0.5 text-xs leading-[14px]">
|
||||
{t(`modals.uploadDoc.progress.${task.stage}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showProgress && (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-primary h-6 w-6 shrink-0"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={formattedProgress}
|
||||
aria-label={t('modals.uploadDoc.progress.uploadProgress', {
|
||||
progress: formattedProgress,
|
||||
})}
|
||||
>
|
||||
<circle
|
||||
className="text-muted dark:text-muted-foreground/30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={PROGRESS_RADIUS}
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
className="text-primary"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={PROGRESS_CIRCUMFERENCE}
|
||||
strokeDashoffset={progressOffset}
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={PROGRESS_RADIUS}
|
||||
fill="none"
|
||||
transform="rotate(-90 12 12)"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{task.status === 'completed' && (
|
||||
<img
|
||||
src={CheckCircleFilled}
|
||||
alt=""
|
||||
className="h-6 w-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
<circle
|
||||
className="text-muted dark:text-muted-foreground/30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={PROGRESS_RADIUS}
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{task.status === 'failed' && (
|
||||
<img
|
||||
src={WarnIcon}
|
||||
alt=""
|
||||
className="h-6 w-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
<circle
|
||||
className="text-primary"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={PROGRESS_CIRCUMFERENCE}
|
||||
strokeDashoffset={progressOffset}
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={PROGRESS_RADIUS}
|
||||
fill="none"
|
||||
transform="rotate(-90 12 12)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{task.status === 'completed' && (
|
||||
<ToastStatus
|
||||
status="success"
|
||||
label={t('modals.uploadDoc.progress.completed')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{task.status === 'failed' && (
|
||||
<ToastStatus
|
||||
status="destructive"
|
||||
label={t('modals.uploadDoc.progress.failed')}
|
||||
/>
|
||||
)}
|
||||
</ToastItem>
|
||||
|
||||
{task.status === 'failed' &&
|
||||
(task.tokenLimitReached || task.errorMessage) && (
|
||||
<span className="block px-5 pb-3 text-xs text-red-500">
|
||||
<ToastMessage variant="destructive">
|
||||
{task.tokenLimitReached
|
||||
? t('modals.uploadDoc.progress.tokenLimit')
|
||||
: task.errorMessage}
|
||||
</span>
|
||||
</ToastMessage>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ const markdownComponents = {
|
||||
</a>
|
||||
),
|
||||
code: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className="dark:bg-accent rounded-md bg-gray-200 px-1.5 py-0.5 text-xs">
|
||||
<code className="bg-accent rounded-md px-1.5 py-0.5 text-xs">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
@@ -213,8 +213,9 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground mr-3 h-[29px] w-[29px] rounded-full p-2 dark:border-0"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
className="mr-3"
|
||||
onClick={onBackToDocuments}
|
||||
aria-label={t('settings.sources.backToAll')}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ClipIcon from '../../assets/clip.svg';
|
||||
import { ATTACHMENT_FILE_ACCEPT_ATTR } from '../../constants/fileUpload';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type AttachFileButtonProps = {
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -11,29 +12,28 @@ export default function AttachFileButton({ onChange }: AttachFileButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
// translate="no": Chrome's page translator rewrites text nodes inside
|
||||
// this label into <font> wrappers and React then loses the control — a
|
||||
// user with auto-translate on rage-clicked a dead Attach button until
|
||||
// they reloaded. Keep the composer's controls out of the translator.
|
||||
<label
|
||||
translate="no"
|
||||
className="xs:px-3 xs:py-1.5 dark:border-border border-border hover:bg-muted dark:hover:bg-muted flex cursor-pointer items-center rounded-full border px-2 py-1 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={ClipIcon}
|
||||
alt="Attach"
|
||||
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground text-xs font-medium sm:text-sm">
|
||||
{t('conversation.attachments.attach')}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept={ATTACHMENT_FILE_ACCEPT_ATTR}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</label>
|
||||
<Button asChild variant="outline" size="sm" shape="pill">
|
||||
{/* translate="no": Chrome's page translator rewrites text nodes inside
|
||||
this label into <font> wrappers and React then loses the control — a
|
||||
user with auto-translate on rage-clicked a dead Attach button until
|
||||
they reloaded. Keep the composer's controls out of the translator. */}
|
||||
<label translate="no" className="cursor-pointer justify-start">
|
||||
<img
|
||||
src={ClipIcon}
|
||||
alt="Attach"
|
||||
className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4 dark:invert"
|
||||
/>
|
||||
<span className="dark:text-foreground text-muted-foreground text-xs sm:text-sm">
|
||||
{t('conversation.attachments.attach')}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept={ATTACHMENT_FILE_ACCEPT_ATTR}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export default function AttachmentChipList({
|
||||
attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
|
||||
} ${
|
||||
draggingId === attachment.id
|
||||
? 'ring-dashed opacity-60 ring-2 ring-purple-200'
|
||||
? 'ring-primary/30 opacity-60 ring-2'
|
||||
: ''
|
||||
}`}
|
||||
title={
|
||||
@@ -98,7 +98,7 @@ export default function AttachmentChipList({
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
className="text-[#ECECF1]"
|
||||
className="text-primary-foreground"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
@@ -123,8 +123,9 @@ export default function AttachmentChipList({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-1.5 h-auto w-auto rounded-full p-1"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
className="ml-1.5"
|
||||
onClick={() => {
|
||||
onRemove(attachment.id);
|
||||
}}
|
||||
@@ -142,7 +143,7 @@ export default function AttachmentChipList({
|
||||
|
||||
{failures.length > 0 && (
|
||||
<div
|
||||
className="flex flex-col gap-0.5 px-2 pb-1 text-xs text-[#B42318] sm:px-3"
|
||||
className="text-destructive flex flex-col gap-0.5 px-2 pb-1 text-xs sm:px-3"
|
||||
role="alert"
|
||||
>
|
||||
{failures.map((attachment) => (
|
||||
@@ -155,7 +156,7 @@ export default function AttachmentChipList({
|
||||
|
||||
{unreadable.length > 0 && (
|
||||
<div
|
||||
className="flex flex-col gap-0.5 px-2 pb-1 text-xs text-[#B54708] sm:px-3 dark:text-[#FDB022]"
|
||||
className="text-warning flex flex-col gap-0.5 px-2 pb-1 text-xs sm:px-3"
|
||||
role="status"
|
||||
>
|
||||
{unreadable.map((attachment) => (
|
||||
|
||||
@@ -29,39 +29,33 @@ export default function MicButton({
|
||||
}: MicButtonProps) {
|
||||
const voiceButtonLabel = getVoiceButtonLabel(recordingState);
|
||||
const voiceButtonText = getVoiceButtonText(recordingState);
|
||||
const isRecording = recordingState === 'recording';
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant={isRecording ? 'destructive-outline' : 'outline'}
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={onClick}
|
||||
aria-label={voiceButtonLabel}
|
||||
title={voiceButtonLabel}
|
||||
disabled={loading || recordingState === 'transcribing'}
|
||||
className={`xs:px-3 xs:py-1.5 dark:border-border flex h-auto items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors ${
|
||||
recordingState === 'recording'
|
||||
? 'border-[#B42318] bg-[#FEE4E2] text-[#B42318] dark:bg-[#4A2323]'
|
||||
: 'border-border dark:hover:bg-accent hover:bg-gray-100'
|
||||
} ${
|
||||
loading || recordingState === 'transcribing'
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''
|
||||
}`}
|
||||
className="justify-start"
|
||||
>
|
||||
{recordingState === 'transcribing' ? (
|
||||
<LoaderCircle className="mr-1 h-3.5 w-3.5 animate-spin sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
) : recordingState === 'recording' ? (
|
||||
<Square className="mr-1 h-3.5 w-3.5 fill-current sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
<LoaderCircle className="size-3.5 animate-spin sm:size-4" />
|
||||
) : isRecording ? (
|
||||
<Square className="size-3.5 fill-current sm:size-4" />
|
||||
) : (
|
||||
<Mic className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
<Mic className="size-3.5 sm:size-4" />
|
||||
)}
|
||||
<span
|
||||
className={`xs:text-xs dark:text-foreground text-xs font-medium sm:text-sm ${
|
||||
recordingState === 'recording'
|
||||
? 'text-[#B42318]'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
className={
|
||||
isRecording
|
||||
? 'text-xs sm:text-sm'
|
||||
: 'text-muted-foreground dark:text-foreground text-xs sm:text-sm'
|
||||
}
|
||||
>
|
||||
{voiceButtonText}
|
||||
</span>
|
||||
|
||||
@@ -51,7 +51,8 @@ export default function SourcesTrigger({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="xs:px-3 xs:py-1.5 dark:border-border border-border hover:bg-accent dark:hover:bg-muted flex h-auto max-w-[130px] items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors sm:max-w-[150px]"
|
||||
shape="pill"
|
||||
className="max-w-[130px] justify-start sm:max-w-[150px]"
|
||||
title={
|
||||
selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.map((doc) => doc.name).join(', ')
|
||||
@@ -61,9 +62,9 @@ export default function SourcesTrigger({
|
||||
<img
|
||||
src={SourceIcon}
|
||||
alt="Sources"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
|
||||
className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4 dark:invert"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs font-medium sm:text-sm">
|
||||
<span className="dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs sm:text-sm">
|
||||
{selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.length === 1
|
||||
? selectedDocs[0].name
|
||||
|
||||
@@ -57,14 +57,15 @@ export default function ToolsTrigger({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="xs:px-3 xs:py-1.5 xs:max-w-[150px] dark:border-border border-border hover:bg-muted dark:hover:bg-muted flex h-auto max-w-[130px] items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors"
|
||||
shape="pill"
|
||||
className="max-w-[130px] justify-start"
|
||||
>
|
||||
<img
|
||||
src={ToolIcon}
|
||||
alt="Tools"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
|
||||
className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4 dark:invert"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs font-medium sm:text-sm">
|
||||
<span className="dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs sm:text-sm">
|
||||
{t('settings.tools.label')}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -443,8 +443,9 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="dark:hover:bg-muted h-[35px] w-6 shrink-0 p-0 hover:bg-[#EBEBEB]"
|
||||
className="h-[35px] w-6 shrink-0"
|
||||
aria-label={t('settings.sources.menuAlt')}
|
||||
>
|
||||
<img
|
||||
@@ -629,11 +630,12 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.sources.searchFiles')}
|
||||
className={`h-[38px] px-4 py-2 ${searchQuery ? 'rounded-t-3xl' : 'rounded-3xl'}`}
|
||||
shape="pill"
|
||||
className="h-[38px]"
|
||||
/>
|
||||
|
||||
{searchQuery && (
|
||||
<div className="border-border bg-card dark:border-border dark:bg-card absolute top-full right-0 left-0 z-20 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-xl border border-t-0 shadow-lg transition-all duration-200">
|
||||
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-xl border shadow-md transition-all duration-200">
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="text-muted-foreground py-2 text-center text-sm">
|
||||
@@ -676,8 +678,9 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground mr-3 h-[29px] w-[29px] rounded-full p-2 dark:border-0"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
className="mr-3"
|
||||
onClick={handleBackNavigation}
|
||||
>
|
||||
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
|
||||
@@ -692,9 +695,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
<span className="text-muted-foreground mx-1 shrink-0">/</span>
|
||||
{currentPath.map((dir, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<span className="dark:text-foreground wrap-break-word text-gray-700">
|
||||
{dir}
|
||||
</span>
|
||||
<span className="text-foreground wrap-break-word">{dir}</span>
|
||||
{index < currentPath.length - 1 && (
|
||||
<span className="text-muted-foreground mx-1 shrink-0">
|
||||
/
|
||||
@@ -707,7 +708,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
{selectedFile && (
|
||||
<>
|
||||
<span className="text-muted-foreground mx-1 shrink-0">/</span>
|
||||
<span className="dark:text-foreground wrap-break-word text-gray-700">
|
||||
<span className="text-foreground wrap-break-word">
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from './accordion';
|
||||
|
||||
function renderAccordion(): string {
|
||||
return renderToStaticMarkup(
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="audit">
|
||||
<AccordionTrigger>Recent activity</AccordionTrigger>
|
||||
<AccordionContent>Rows</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The class attribute of the element carrying the given data-slot. */
|
||||
function slotClasses(html: string, slot: string): string {
|
||||
const tag = new RegExp(`<[^>]*data-slot="${slot}"[^>]*>`).exec(html)?.[0];
|
||||
return /class="([^"]*)"/.exec(tag ?? '')?.[1] ?? '';
|
||||
}
|
||||
|
||||
describe('AccordionTrigger', () => {
|
||||
it('carries its own inset and section-heading type', () => {
|
||||
const classes = slotClasses(renderAccordion(), 'accordion-trigger');
|
||||
expect(classes).toContain('px-4');
|
||||
expect(classes).toContain('py-3');
|
||||
expect(classes).toContain('text-sm');
|
||||
expect(classes).toContain('font-medium');
|
||||
});
|
||||
|
||||
it('draws the focus ring inside the item, which clips outside it', () => {
|
||||
const classes = slotClasses(renderAccordion(), 'accordion-trigger');
|
||||
expect(classes).toContain('focus-visible:ring-3');
|
||||
expect(classes).toContain('focus-visible:ring-inset');
|
||||
expect(classes).not.toContain('focus-visible:ring-2');
|
||||
});
|
||||
|
||||
it('keeps the chevron in muted-foreground in dark mode', () => {
|
||||
const html = renderAccordion();
|
||||
expect(html).toContain('text-muted-foreground');
|
||||
expect(html).not.toContain('dark:invert');
|
||||
});
|
||||
});
|
||||
@@ -33,14 +33,14 @@ function AccordionTrigger({
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:ring-ring/50 flex flex-1 items-center justify-between gap-4 text-left transition-all outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
'focus-visible:ring-ring/50 flex flex-1 items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium transition-all outline-none focus-visible:ring-3 focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0 transition-transform duration-200 dark:invert"
|
||||
className="text-muted-foreground h-5 w-5 shrink-0 transition-transform duration-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { Alert, AlertDescription } from './alert';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (element: React.ReactElement) => {
|
||||
await act(async () => root.render(element));
|
||||
};
|
||||
|
||||
const alertRole = () => container.firstElementChild!.getAttribute('role');
|
||||
|
||||
describe('Alert', () => {
|
||||
it.each(['default', 'destructive', 'warning', 'info'] as const)(
|
||||
'announces %s assertively',
|
||||
async (variant) => {
|
||||
await render(
|
||||
<Alert variant={variant}>
|
||||
<AlertDescription>Notice</AlertDescription>
|
||||
</Alert>,
|
||||
);
|
||||
expect(alertRole()).toBe('alert');
|
||||
},
|
||||
);
|
||||
|
||||
it('announces success politely', async () => {
|
||||
await render(
|
||||
<Alert variant="success">
|
||||
<AlertDescription>Done</AlertDescription>
|
||||
</Alert>,
|
||||
);
|
||||
expect(alertRole()).toBe('status');
|
||||
});
|
||||
|
||||
it('lets the caller override the role', async () => {
|
||||
await render(<Alert variant="destructive" role="status" />);
|
||||
expect(alertRole()).toBe('status');
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,12 @@ const alertVariants = cva(
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
'border-destructive/50 bg-destructive/10 text-destructive [&>svg]:text-destructive',
|
||||
success:
|
||||
'border-success/50 bg-success/10 text-success [&>svg]:text-success',
|
||||
warning:
|
||||
'border-warning/50 bg-warning/10 text-warning [&>svg]:text-warning',
|
||||
info: 'border-info/50 bg-info/10 text-info [&>svg]:text-info',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -25,7 +30,8 @@ const Alert = React.forwardRef<
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
// A success notice confirms rather than interrupts, so it is polite.
|
||||
role={variant === 'success' ? 'status' : 'alert'}
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Avatar, avatarVariants } from './avatar';
|
||||
|
||||
describe('Avatar variants', () => {
|
||||
it('adds no sizing or radius unless asked, so image avatars are unchanged', () => {
|
||||
const html = renderToStaticMarkup(<Avatar src="/a.png" />);
|
||||
expect(html).toContain('class="shrink-0"');
|
||||
});
|
||||
|
||||
it('sizes and shapes the initials box', () => {
|
||||
const classes = avatarVariants({
|
||||
size: 'lg',
|
||||
shape: 'circle',
|
||||
variant: 'primary',
|
||||
});
|
||||
expect(classes).toContain('size-9');
|
||||
expect(classes).toContain('rounded-full');
|
||||
expect(classes).toContain('bg-primary/10');
|
||||
expect(classes).toContain('items-center');
|
||||
});
|
||||
|
||||
it('renders children instead of the image when given', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Avatar size="sm" shape="square" variant="muted">
|
||||
LK
|
||||
</Avatar>,
|
||||
);
|
||||
expect(html).toContain('LK');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('rounded-md');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import Robot from '../../assets/robot.svg';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type AvatarProps = {
|
||||
const avatarVariants = cva('shrink-0', {
|
||||
variants: {
|
||||
// `none` keeps the historical behaviour where the image sets the size.
|
||||
size: {
|
||||
none: '',
|
||||
sm: 'flex size-7 items-center justify-center overflow-hidden text-xs',
|
||||
default:
|
||||
'flex size-8 items-center justify-center overflow-hidden text-sm',
|
||||
lg: 'flex size-9 items-center justify-center overflow-hidden text-sm',
|
||||
xl: 'flex size-10 items-center justify-center overflow-hidden text-base',
|
||||
},
|
||||
shape: {
|
||||
none: '',
|
||||
circle: 'rounded-full',
|
||||
square: 'rounded-md',
|
||||
},
|
||||
// Background for initials or icon fallbacks rendered via `children`.
|
||||
variant: {
|
||||
default: '',
|
||||
primary: 'bg-primary/10 text-primary font-medium dark:bg-primary/20',
|
||||
muted: 'bg-muted-foreground/15 text-foreground font-medium',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'none',
|
||||
shape: 'none',
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
type AvatarProps = VariantProps<typeof avatarVariants> & {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
fallbackSrc?: string;
|
||||
@@ -20,6 +51,9 @@ function Avatar({
|
||||
className,
|
||||
imgClassName,
|
||||
children,
|
||||
size = 'none',
|
||||
shape = 'none',
|
||||
variant = 'default',
|
||||
}: AvatarProps) {
|
||||
const resolvedSrc = src && src.trim() !== '' ? src : fallbackSrc;
|
||||
const [currentSrc, setCurrentSrc] = useState(resolvedSrc);
|
||||
@@ -32,7 +66,13 @@ function Avatar({
|
||||
}, [src, fallbackSrc]);
|
||||
|
||||
return (
|
||||
<div data-slot="avatar" className={cn('shrink-0', className)}>
|
||||
<div
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
data-shape={shape}
|
||||
data-variant={variant}
|
||||
className={cn(avatarVariants({ size, shape, variant }), className)}
|
||||
>
|
||||
{children ?? (
|
||||
<img
|
||||
src={currentSrc}
|
||||
@@ -49,4 +89,4 @@ function Avatar({
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar };
|
||||
export { Avatar, avatarVariants };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Badge, badgeVariants } from './badge';
|
||||
|
||||
describe('Badge', () => {
|
||||
it('is a soft brand pill by default', () => {
|
||||
const html = renderToStaticMarkup(<Badge>Beta</Badge>);
|
||||
expect(html).toContain('rounded-full');
|
||||
expect(html).toContain('bg-primary/10');
|
||||
expect(html).toContain('data-variant="default"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['success', 'text-success'],
|
||||
['warning', 'text-warning'],
|
||||
['destructive', 'text-destructive'],
|
||||
['info', 'text-info'],
|
||||
['neutral', 'text-muted-foreground'],
|
||||
] as const)('variant %s uses its semantic token', (variant, cls) => {
|
||||
expect(badgeVariants({ variant })).toContain(cls);
|
||||
});
|
||||
|
||||
it('never uses raw palette colours', () => {
|
||||
for (const variant of [
|
||||
'default',
|
||||
'neutral',
|
||||
'success',
|
||||
'warning',
|
||||
'destructive',
|
||||
'info',
|
||||
'outline',
|
||||
] as const) {
|
||||
expect(badgeVariants({ variant })).not.toMatch(
|
||||
/\b(bg|text|border)-(red|green|amber|yellow|blue|gray|purple)-\d+/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user