mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 02:15:06 +00:00
Agent menu in new style
This commit is contained in:
1 parent
20bcd92942
commit
d4c97a49ce
102 files changed
+4476
-1580
No files matched your search
@@ -317,6 +317,55 @@ class AgentSchedules(Resource):
|
||||
return _ok({"schedule": _format_schedule(created)}, status=201)
|
||||
|
||||
|
||||
@schedules_ns.route("/agents/<string:agent_id>/schedules/stats")
|
||||
class AgentScheduleStats(Resource):
|
||||
@api.doc(
|
||||
description="Run stats for an agent's schedules over a recent window.",
|
||||
params={"days": "Window in days (default 30, clamped to 1..365)"},
|
||||
)
|
||||
@_safe_route
|
||||
def get(self, agent_id: str):
|
||||
"""Return run count, failures, tokens and latest failure for an agent.
|
||||
|
||||
Args:
|
||||
agent_id: Agent id from the URL.
|
||||
|
||||
Returns:
|
||||
A Flask response with ``days``, ``runs``, ``failed``, ``tokens``
|
||||
and ``latest_failure`` (or an error envelope).
|
||||
"""
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
days = max(1, min(int(request.args.get("days", 30)), 365))
|
||||
except (TypeError, ValueError):
|
||||
days = 30
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
stats = ScheduleRunsRepository(conn).stats_for_agent(
|
||||
str(agent["id"]), user_id, days=days,
|
||||
)
|
||||
except Exception as exc:
|
||||
current_app.logger.error(
|
||||
"schedule stats failed: %s", exc, exc_info=True,
|
||||
)
|
||||
return _err("internal error", 500)
|
||||
latest = stats.get("latest_failure")
|
||||
return _ok(
|
||||
{
|
||||
"days": days,
|
||||
"runs": stats["runs"],
|
||||
"failed": stats["failed"],
|
||||
"tokens": stats["tokens"],
|
||||
"latest_failure": _format_run(latest) if latest else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@schedules_ns.route("/schedules/<string:schedule_id>")
|
||||
class ScheduleResource(Resource):
|
||||
@api.doc(description="Get schedule by id.")
|
||||
|
||||
@@ -167,6 +167,79 @@ class ScheduleRunsRepository:
|
||||
).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def stats_for_agent(
|
||||
self, agent_id: str, user_id: str, *, days: int = 30,
|
||||
) -> dict:
|
||||
"""Aggregate run stats for an agent's owned schedules over a window.
|
||||
|
||||
Only runs of schedules owned by ``user_id`` on ``agent_id`` whose
|
||||
``scheduled_for`` falls within the last ``days`` days are counted.
|
||||
|
||||
Args:
|
||||
agent_id: Agent UUID the schedules belong to.
|
||||
user_id: Owner of the schedules (and runs).
|
||||
days: Window length in days, counted back from now.
|
||||
|
||||
Returns:
|
||||
Dict with ``runs``, ``failed`` (``failed``/``timeout``),
|
||||
``tokens`` (prompt + generated) and ``latest_failure`` — the
|
||||
most recent failed run's ``scheduled_for``/``status``/
|
||||
``error_type``, or ``None`` when there is none.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
WITH window_runs AS (
|
||||
SELECT r.scheduled_for, r.status, r.error_type,
|
||||
r.prompt_tokens, r.generated_tokens
|
||||
FROM schedule_runs r
|
||||
JOIN schedules s ON s.id = r.schedule_id
|
||||
WHERE s.agent_id = CAST(:agent_id AS uuid)
|
||||
AND s.user_id = :user_id
|
||||
AND r.user_id = :user_id
|
||||
AND r.scheduled_for >= now() - make_interval(days => :days)
|
||||
),
|
||||
latest_failure AS (
|
||||
SELECT scheduled_for, status, error_type
|
||||
FROM window_runs
|
||||
WHERE status IN ('failed', 'timeout')
|
||||
ORDER BY scheduled_for DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT agg.runs, agg.failed, agg.tokens,
|
||||
lf.scheduled_for AS latest_failure_scheduled_for,
|
||||
lf.status AS latest_failure_status,
|
||||
lf.error_type AS latest_failure_error_type
|
||||
FROM (
|
||||
SELECT count(*) AS runs,
|
||||
count(*) FILTER (
|
||||
WHERE status IN ('failed', 'timeout')
|
||||
) AS failed,
|
||||
COALESCE(
|
||||
SUM(prompt_tokens + generated_tokens), 0
|
||||
) AS tokens
|
||||
FROM window_runs
|
||||
) agg
|
||||
LEFT JOIN latest_failure lf ON true
|
||||
"""
|
||||
),
|
||||
{"agent_id": str(agent_id), "user_id": user_id, "days": int(days)},
|
||||
).fetchone()
|
||||
data = row_to_dict(row)
|
||||
latest_failure = None
|
||||
if data.get("latest_failure_scheduled_for") is not None:
|
||||
latest_failure = {
|
||||
"scheduled_for": data["latest_failure_scheduled_for"],
|
||||
"status": data["latest_failure_status"],
|
||||
"error_type": data["latest_failure_error_type"],
|
||||
}
|
||||
return {
|
||||
"runs": int(data.get("runs") or 0),
|
||||
"failed": int(data.get("failed") or 0),
|
||||
"tokens": int(data.get("tokens") or 0),
|
||||
"latest_failure": latest_failure,
|
||||
}
|
||||
|
||||
def update(self, run_id: str, fields: dict) -> Optional[dict]:
|
||||
"""Apply a whitelisted partial update to a run row."""
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES}
|
||||
|
||||
+308
-189
@@ -1,8 +1,9 @@
|
||||
# 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
|
||||
`src/components/ui/`. `@shadcn/lint` and the `no-restricted-syntax`
|
||||
selectors in `eslint/` enforce the rules below through `npm run lint`; their
|
||||
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.
|
||||
|
||||
@@ -36,7 +37,7 @@ through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and
|
||||
| `popover`, `popover-foreground` | floating menus and popovers | same as card |
|
||||
| `muted`, `muted-foreground` | quiet fills and secondary text, icons, placeholders, timestamps | `bg-gray-100/200`, `text-gray-400/500/600`, `dark:text-gray-400` |
|
||||
| `accent`, `accent-foreground` | hover and selected fills | `hover:bg-gray-100`, `dark:hover:bg-gray-700` |
|
||||
| `border`, `input`, `ring` | dividers and field borders (fields use `border-border`), focus rings | `border-gray-200/300`, `dark:border-gray-600/700` |
|
||||
| `border`, `input`, `ring` | dividers, field borders (fields use `border-input`, `fieldFrame`), 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` |
|
||||
@@ -45,7 +46,7 @@ through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and
|
||||
| `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 | |
|
||||
| `answer-bubble` | the source cards under an answer and the answer-side panels (AnswerFlow) | |
|
||||
|
||||
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
|
||||
@@ -82,7 +83,8 @@ One tint scale, by purpose:
|
||||
`selected`, Dropzone drag-active and drag-reject). The border carries the
|
||||
state; the wash only warms the surface. Never on a chip or a text fill.
|
||||
- Brand soft fill: `bg-secondary text-secondary-foreground` (Badge `default`,
|
||||
Avatar `primary`, the OptionCard icon square), never `bg-primary/10`.
|
||||
Avatar `primary`, the OptionCard icon square), never `bg-primary/10`
|
||||
(enforced).
|
||||
- Status soft fill: `bg-<role>/10` in both themes (Badge, Alert, ToastHeader,
|
||||
a danger-zone panel); no `dark:` twin.
|
||||
- Status border: `border-<role>/50`.
|
||||
@@ -102,12 +104,11 @@ Patterns:
|
||||
- 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
|
||||
- `secondary` is a brand tint, not a grey: primary at 10% (light) or the
|
||||
lighter #976af3 at 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
|
||||
@@ -142,7 +143,7 @@ Patterns:
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `variant` | `default`, `secondary`, `outline`, `outline-primary`, `ghost`, `ghost-muted`, `ghost-destructive`, `ghost-on-accent`, `ghost-destructive-on-accent`, `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) |
|
||||
| `shape` | `default` (rounded-md), `pill` (rounded-full, wider padding at `default`, `lg` and `field`) |
|
||||
|
||||
- Round brand buttons (`rounded-3xl px-5`, `rounded-full px-6`): `shape="pill"`,
|
||||
plus `size="lg"` if they were `px-6`.
|
||||
@@ -159,9 +160,10 @@ Patterns:
|
||||
ghost's accent square would not.
|
||||
- Icons are `lucide-react` at the default stroke (2). Don't pass
|
||||
`strokeWidth`, and don't use an `<img>` for a glyph lucide has. The one
|
||||
exception is a tiny check mark inside a status circle (the custom-model
|
||||
test result, the artifact and research step ticks), drawn at 2.5 to 3 so
|
||||
it stays legible at 10 to 12px; raw progress-ring `<svg>`s are not icons.
|
||||
exception is a tiny glyph inside a status circle or chip (the custom-model
|
||||
test result and capability chips, the artifact and research step ticks,
|
||||
`ToastStatus`'s four status icons), drawn at 2.5 to 3 so it stays legible
|
||||
at 10 to 12px; raw progress-ring `<svg>`s are not icons.
|
||||
- Icon size is one `size-N` class (never `h-N w-N`, never lucide's `size`
|
||||
prop); colour goes on the icon (`text-muted-foreground`), spacing on the
|
||||
parent (`gap-*`, not `mr-2` on the icon). Use current lucide names
|
||||
@@ -181,9 +183,9 @@ Patterns:
|
||||
hint passes `text-xs font-normal` (an approved exception), and a link that
|
||||
must keep the colour of what it sits in (a status Alert, a dark overlay, a
|
||||
source card's URL row) passes `text-current`. Never style a raw `<a>` as a
|
||||
link.
|
||||
- Icon-only buttons are `IconButton` (below), never a `Button` with a
|
||||
`title`.
|
||||
link (enforced).
|
||||
- Icon-only buttons: see IconButton below. A `title` on a `Button` is
|
||||
rejected (enforced).
|
||||
- An action whose label doesn't fit beside a name on a phone (the shared
|
||||
agent card's Edit, in a long locale) is two elements: an `IconButton`
|
||||
with `sm:hidden` and the labelled `Button` with `hidden sm:inline-flex`.
|
||||
@@ -196,7 +198,7 @@ Patterns:
|
||||
editor. A Cancel inside a line of text (the composer's queued send) is
|
||||
`link inline`.
|
||||
- The composer controls under the chat field (Attach, Voice, Tools,
|
||||
Sources) are `size="sm" shape="pill"`. Their icons are `size-3.5 sm:size-4`
|
||||
Sources) are `outline sm 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"`,
|
||||
@@ -211,7 +213,28 @@ Patterns:
|
||||
retrieval, Sync) are `size="field" shape="pill"` too, with no min-width
|
||||
or hand height. With `shape="pill"` its text starts 21px in, like the
|
||||
Input and Select pills beside it. The agent form's pickers are
|
||||
`combobox field pill`, its Add button `outline-primary field pill`.
|
||||
`combobox field pill`, each labelled by a `FormField` (the Prompt picker
|
||||
through `Prompts titleAs="field"`), its Add button `outline-primary field
|
||||
pill`, the only `outline-primary` on the agent pages themselves (the Access
|
||||
details modal they open has its own).
|
||||
- One variant and size per role on the agent pages (Overview, Logs,
|
||||
Schedules, the workflow builder's toolbar): the page's primary action is
|
||||
`default field pill` (Publish, Save, New schedule), a secondary action
|
||||
`outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost
|
||||
field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`)
|
||||
at the end of the title row, through `SectionShell titleAction`: Access
|
||||
details and Share with team on Overview, and Preview until the agent is
|
||||
published (before that the preview only says "Publish to preview"). So the
|
||||
row never holds more than three buttons and fits a phone, where the main
|
||||
one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot
|
||||
spans the stacked row below `sm`). The workflow builder's toolbar has no
|
||||
title row and keeps its ⋯ at the row's end. The
|
||||
agent's Delete is in Overview's danger zone; only the workflow builder's
|
||||
toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is
|
||||
`agents/components/AgentPageToolbar` (see Page chrome). A row's common
|
||||
action is `outline sm pill` (a schedule's Run now or Resume) with its other
|
||||
actions in the row's `ActionMenu`; a delete there is a `destructive` item
|
||||
that opens the confirm modal.
|
||||
- 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
|
||||
@@ -226,24 +249,30 @@ rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal
|
||||
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.
|
||||
Use them for a row of route links (the agent sub-nav: a `<nav>` whose
|
||||
current link carries `aria-current="page"`). Tabs that switch a panel in
|
||||
place are `ui/tabs` with `variant="underline"` on `TabsList` and each
|
||||
`TabsTrigger`: the same pixels, plus `role="tablist"`/`"tab"`,
|
||||
`aria-selected` and arrow-key focus; wrap the panel in `TabsContent`
|
||||
(FilePicker's My Files / Shared with Me). The `default` variant is a pill
|
||||
tab, unused in the app.
|
||||
- Underline route tabs 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. Its one use is `agents/AgentPageHeader.tsx`, the workflow
|
||||
builder's fixed toolbar: a breadcrumb and the Overview / Logs / Schedules
|
||||
tabs in a `<nav>`, the others `<Link>`s and the current one a `<span
|
||||
aria-current="page">` with `data-active`. The
|
||||
agent section pages switch with the section sidebar (the phone menu below
|
||||
`lg`) and carry no pill row; the agent tile's ⋯ also opens Logs. Tabs that switch a panel in place are
|
||||
`ui/tabs` with `variant="underline"` on `TabsList` and each `TabsTrigger`:
|
||||
the same pixels, plus `role="tablist"`/`"tab"`, `aria-selected` and
|
||||
arrow-key focus; wrap the panel in `TabsContent` (FilePicker's My Files /
|
||||
Shared with Me, Schedules' Recurring / One-time in
|
||||
`agents/schedules/SchedulesView.tsx`). The `default` variant is a pill tab,
|
||||
unused in the app.
|
||||
- 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,
|
||||
primary underline on hover. Both call sites wrap the Button in an `<h2>`
|
||||
(a button flattens heading children, so the heading goes outside) and put
|
||||
the title in a `text-lg font-semibold` span. The button draws no focus ring; the panel does,
|
||||
so keyboard focus outlines the whole panel. The panel is a `Card` (a place,
|
||||
`subtle lg`), and every Card carries that ring
|
||||
(`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`,
|
||||
@@ -276,6 +305,7 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
|
||||
| `tone` | `default`, `destructive` (the status soft fill and border, `border-destructive/50 bg-destructive/10`, over any variant) |
|
||||
| `padding` | `none`, `sm` (p-3, row boxes and code blocks), `default` (p-4), `lg` (p-6, tiles, panels and stat tiles) |
|
||||
| `interactive` | whole card is the target: hover, focus ring and `selected` highlight; pair with `asChild` around a `<button>` or `<Link>` |
|
||||
| `selected` | an `interactive` card that is the current choice: `border-primary` and the `/5` wash |
|
||||
|
||||
Parts: `CardHeader` (title left, `CardAction` top-right), `CardTitle`,
|
||||
`CardDescription`, `CardContent`, `CardFooter` (meta row, sticks to the
|
||||
@@ -291,19 +321,19 @@ question: is it a **thing** you open, move, share or delete as a whole, or a
|
||||
**place** where you read or edit content? The fill marks objects; the border
|
||||
marks structure.
|
||||
|
||||
| Role | Variant | Examples |
|
||||
| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Thing (a tile) | `filled`, on a page or in a modal | agents, folders, sources, tools, custom models, teams, chunks, the shared agent card |
|
||||
| Place (a panel) | `subtle` on the page or a right drawer, `outline` on a card (modal, bottom sheet) | the agent form's sections, chart panels, stat tiles, log tables, schedule rows, the agent preview |
|
||||
| Choice (picker tile) | `outline interactive`, or `OptionCard` | the Add tool tiles; selection is drawn by the border, so the border stays |
|
||||
| Well | `filled padding="sm"` | code, a token to copy, run output, inside a panel (see Code blocks below) |
|
||||
| Role | Variant | Examples |
|
||||
| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Thing (a tile) | `filled`, on a page or in a modal | agents, folders, sources, tools, custom models, teams, chunks, the shared agent card |
|
||||
| Place (a panel) | `subtle` on the page or a right drawer, `outline` on a card (modal, bottom sheet) | the agent form's sections, chart panels, stat tiles, log tables, schedule rows |
|
||||
| Choice (picker tile) | `outline interactive`, or `OptionCard` | the Add tool tiles; selection is drawn by the border, so the border stays |
|
||||
| Well | `filled padding="sm"` | code, a token to copy, run output, inside a panel (see Code blocks below) |
|
||||
|
||||
- Layers go page → panel (border) → well (fill), or page → tile (fill).
|
||||
`subtle` and `outline` are one role on two surfaces; in light mode they
|
||||
look the same.
|
||||
- An item that you read inside, rather than open elsewhere, is a place: a
|
||||
schedule is an item, but its row expands into a run table, so it is a
|
||||
`subtle` panel. The tile look is for a summary you open.
|
||||
schedule is an item, but its row (`agents/schedules/ScheduleRow`) expands
|
||||
into a run table, so it is a `subtle` panel. The tile look is for a summary you open.
|
||||
- **No fill on a fill.** Nothing on a `filled` tile repeats its muted fill,
|
||||
or it disappears: no `bg-muted` strip or box, no filled Card inside it, and
|
||||
Skeleton bars take `surface="muted"`. Put a meta line such as a token count
|
||||
@@ -314,9 +344,8 @@ marks structure.
|
||||
(`eslint/card-surfaces.js`).
|
||||
- Never a page-sized `bg-muted` panel around a page's cards: panels sit on
|
||||
the page background, as on Logs and Analytics.
|
||||
- A field in a `subtle` panel sits on the page background, so its floating
|
||||
label passes `labelSurface="background"`; in an `outline` panel it keeps
|
||||
the default `card`.
|
||||
- A field in a `subtle` panel passes `labelSurface="background"` (see
|
||||
FormField).
|
||||
|
||||
Recipes by role:
|
||||
|
||||
@@ -325,13 +354,20 @@ Recipes by role:
|
||||
choices: `outline interactive lg` with `asChild` around a `<button>`.
|
||||
- Chart panels are `subtle lg` with fixed heights passed as layout; a chart
|
||||
panel inside a modal is `outline` (the modal is already `bg-card`).
|
||||
- Form sections (the agent form's Meta, Source, Prompt, Advanced,
|
||||
Guardrails) are `subtle lg`; a disclosure section's header is a
|
||||
`section-toggle`, and Card draws its focus ring.
|
||||
- Row boxes inside a form or panel (a guardrail check, a schedule row, the
|
||||
schedule's timezone box, the discovered MCP tools) are `padding="sm"`, in
|
||||
the panel variant of the surface they sit on (`subtle` in a page panel,
|
||||
`outline` in a modal).
|
||||
- Form sections (the agent form's Basics, Knowledge and behaviour, Model,
|
||||
Advanced, Guardrails) are `subtle lg` with `gap-5`; Basics, Knowledge and
|
||||
behaviour and Model are titled by a `SectionHeader`, Advanced and
|
||||
Guardrails by a `section-toggle` (see Button). A disclosure panel's body
|
||||
under its toggle is one `flex flex-col gap-5` wrapper, its rows carrying
|
||||
no `mt-*`, so every section has 20px under its title. The destructive
|
||||
danger-zone Card follows them.
|
||||
- Row boxes inside a form or panel (a guardrail check, the schedule's
|
||||
timezone box, the discovered MCP tools) are `padding="sm"`, in the panel
|
||||
variant of the surface they sit on (`subtle` in a page panel, `outline` in
|
||||
a modal).
|
||||
- A schedule is `agents/schedules/ScheduleRow`: `subtle`, `padding="none"`,
|
||||
with its run log flush under it; a chevron `IconButton ghost-muted
|
||||
icon-xs` toggles the runs.
|
||||
- A danger zone (Delete agent, Revoke a device) and a failing stat are
|
||||
`tone="destructive"`; the title beside it is `SectionHeader
|
||||
tone="destructive"`. Muted text fails AA on the red fill, so the tone turns
|
||||
@@ -358,8 +394,10 @@ well) on a card or background surface, `subtle padding="sm"` on a muted one
|
||||
(the tool-approval card, an expanded log row). A copy row is the same Card
|
||||
with `className="flex-row items-start gap-2"` and the `CopyButton` inside.
|
||||
Inside an Alert or a trace's tool panel, and in a full-pane viewer, the
|
||||
`<pre>` takes the recipe with no Card, so boxes don't nest. Put scroll caps
|
||||
(`max-h-* overflow-y-auto`) on the Card; never `break-all`.
|
||||
`<pre>` takes the recipe with no Card, so boxes don't nest. A scroll cap
|
||||
goes on an inner `<div className="scrollbar-overlay max-h-* overflow-y-auto">`
|
||||
around the `<pre>`, inside the Card's padding, never on the Card: on the Card
|
||||
the scrollbar runs into its rounded corners. Never `break-all` (enforced).
|
||||
This recipe is for app output. A fenced code block in a markdown answer is
|
||||
source code: it keeps its lines (`white-space: pre`, indentation intact) and
|
||||
scrolls sideways inside its own bordered frame (see Chat answer column).
|
||||
@@ -408,7 +446,8 @@ modal) leave it out and stay plain buttons. Only layout classes on it.
|
||||
`variant`: `default` (brand), `neutral`, `success`, `warning`, `destructive`,
|
||||
`info`, `outline`. Replaces every hand-rolled
|
||||
`rounded-full bg-<colour>-100 px-2 py-0.5 text-xs text-<colour>-700 dark:...`
|
||||
pill, including schedule and run status pills, trace chips and statuses,
|
||||
pill, including schedule and run status pills (`agents/schedules/StatusBadge.tsx`
|
||||
maps schedule and run statuses to Badge variants), trace chips and statuses,
|
||||
token scope chips, "Disabled" and tool chips. A grey chip is `neutral`, never
|
||||
a `bg-muted` pill. The admin role is `default` wherever it shows (Teams, Admin
|
||||
→ Users); every other role is `neutral`. Chips that show code (token scopes) pass `font-mono`, and
|
||||
@@ -419,7 +458,22 @@ stat chips `tabular-nums`, as approved exceptions. HTTP method pills take their
|
||||
`default` Badges with a remove X, then "+N more", on a `Button
|
||||
variant="combobox" size="field"` trigger that grows past 38px when the chips
|
||||
wrap, with SelectTrigger's turning chevron; each row in its list shows a
|
||||
`Checkbox size="sm"`.
|
||||
`Checkbox size="sm"`. Inside a Modal pass `modal`, or its popover can't
|
||||
scroll and doesn't close on an outside click.
|
||||
|
||||
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.
|
||||
|
||||
A picker's footer (`MultiSelectPopover footer`) links to the page that manages
|
||||
the list, as a `link inline` Button with a 12px `ArrowRight` (Go to Sources, Go
|
||||
to Tools). A shortcut action (Upload new, an `outline-primary pill`) sits at the
|
||||
right end of the same row: `flex flex-wrap items-center justify-between gap-3`,
|
||||
so on a narrow sheet or in a long locale it wraps under the link rather than
|
||||
taking a row of its own everywhere (`SourcesPopoverFooter`).
|
||||
|
||||
### Input (`ui/input.tsx`)
|
||||
|
||||
@@ -431,11 +485,8 @@ wrap, with SelectTrigger's turning chevron; each row in its list shows a
|
||||
|
||||
Text alignment classes (`text-right`) and `font-mono` (code fields) are
|
||||
allowed on Input and Textarea. `<Input label>` is the shorthand for a
|
||||
one-field `FormField`: the same floating label on the border, the same
|
||||
`labelSurface` (`card` default, forms in cards and modals; `background`,
|
||||
fields straight on a page; `muted`, fields on a muted panel). Never pass a
|
||||
background class for it. A placeholder under a floating label is an example:
|
||||
it stays hidden while the label rests inside and shows on focus. The chat and hero
|
||||
one-field `FormField`: the same floating label on the border and the same
|
||||
`labelSurface` (see FormField). The chat and hero
|
||||
fields (`rounded-3xl px-5 py-3`) are `size="lg" shape="pill"`. A
|
||||
default-size pill (38px, the form-row height) pads `px-5` like the large
|
||||
one, so its text lines up with the Select pills. Compact table
|
||||
@@ -475,9 +526,11 @@ variable highlighter and the chunk editor behind its line-number gutter.
|
||||
|
||||
Every boxed form control is labelled by a floating label:
|
||||
`<FormField label required hint error disabled labelSurface>` with the field
|
||||
as its only child. The label sits on the field's border (12px muted,
|
||||
`labelSurface` `card` | `background` | `muted` to match the surface behind
|
||||
the field, so the notch hides the line). It rests inside an empty, unfocused
|
||||
as its only child. The label sits on the field's border (12px muted), and
|
||||
`labelSurface` matches the surface behind the field so the notch hides the
|
||||
line: `card` (the default: forms in cards, modals and `outline` panels),
|
||||
`background` (fields straight on a page or in a `subtle` panel), `muted`
|
||||
(fields on a muted panel). Never pass a background class for it. It rests inside an empty, unfocused
|
||||
`Input` or `Textarea` and moves up on focus; on a `SelectTrigger`, combobox,
|
||||
`MultiSelect`, number field or `Dropzone` it stays on the border. It turns
|
||||
red (`text-destructive`) with an `error`, and dims while `disabled`. Under
|
||||
@@ -526,13 +579,30 @@ toggle) are not SettingRows.
|
||||
|
||||
### Checkbox (`ui/checkbox.tsx`)
|
||||
|
||||
`<Checkbox checked onCheckedChange>` (Radix), never `<input type="checkbox">`:
|
||||
native boxes take the OS accent and ignore dark mode. `size`: `default`
|
||||
`<Checkbox checked onCheckedChange>` (Radix), never `<input type="checkbox">`
|
||||
(enforced): native boxes take the OS accent and ignore dark mode. `size`: `default`
|
||||
(16px, option rows), `sm` (14px, table cells). Unchecked it is an
|
||||
`border-input` box; checked, `primary` with a `primary-foreground` check.
|
||||
Name it with a `Label htmlFor` or `aria-label`. For an on/off setting with a
|
||||
description use a Switch in a SettingRow instead.
|
||||
|
||||
### Switch, TimePicker and Calendar (`ui/switch.tsx`, `ui/time-picker.tsx`, `ui/calendar.tsx`)
|
||||
|
||||
`Switch` (Radix) is an on/off setting, placed inside a `SettingRow` that names
|
||||
it. The track is `primary` when on and `bg-input` when off, with a white
|
||||
thumb in both themes (see Elevation).
|
||||
|
||||
`TimePicker` picks a time of day as two `SelectTrigger`s, hours and minutes,
|
||||
with a `value`/`onChange` pair in 24-hour `"HH:MM"`. `minuteStep` sets the
|
||||
minute list's step (default 1), `ariaLabel` names the group and each select,
|
||||
and `showIcon={false}` drops the leading clock. Never a native
|
||||
`<input type="time">`, which ignores dark mode.
|
||||
|
||||
`Calendar` is react-day-picker styled with the theme tokens, its days `ghost`
|
||||
Buttons; open it in a `Popover` from a field (ScheduleFormModal's date). Its
|
||||
day button carries the one approved lint exception in the file (see Approved
|
||||
exceptions).
|
||||
|
||||
### Tooltip (`ui/tooltip.tsx`) and IconButton (`ui/icon-button.tsx`)
|
||||
|
||||
Every icon-only button is an `IconButton`: `<IconButton label icon hint?
|
||||
@@ -579,24 +649,32 @@ Replaces every `<hr>` and every `border-b` or `h-px` div that is only a line.
|
||||
`currentColor`, so colour it with a `text-*` token on it or its parent. `xs`
|
||||
is the icon-sized step: a busy Button, a step's status in a Preview or
|
||||
research row. Never shrink a larger size with `className="size-*"`, and don't
|
||||
use lucide `LoaderCircle` (formerly `Loader2`) or a hand-drawn SVG as a loader. `Skeleton` is a pulsing
|
||||
use lucide `LoaderCircle`/`Loader2` or a hand-drawn SVG as a loader (the
|
||||
`Loader`, `Loader2` and `LoaderCircle` imports are enforced). `Skeleton` is a pulsing
|
||||
muted block sized with layout classes; its default radius (`rounded-sm`, 6px)
|
||||
is the bar radius, so pass none (`rounded-full` for an avatar or switch
|
||||
stand-in). Bars inside a muted surface (a `Card variant="filled"` tile) take
|
||||
`surface="muted"` (`bg-muted-foreground/20`), since `bg-muted` would vanish
|
||||
there. A tile's loading mirror is the same `Card` as the tile (variant,
|
||||
padding, height) with Skeleton bars laid out like its content; the Card
|
||||
itself never pulses, only the bars do. Never put a Skeleton inside an
|
||||
element that pulses itself; the two animations multiply.
|
||||
itself never pulses, only the bars do. A loader inside a panel that is
|
||||
already on screen (a chart panel whose title shows while its data loads) is
|
||||
bars only, shaped like the content; the panel is the Card. A loader for an
|
||||
`Alert` or row box is an `outline sm` Card in a modal with bars inside. Never
|
||||
put a Skeleton inside an element that pulses itself; the two animations
|
||||
multiply.
|
||||
|
||||
### LoadingState and EmptyState (`ui/loading-state.tsx`, `ui/empty-state.tsx`)
|
||||
|
||||
A page, panel or dialog that is still loading shows `LoadingState`, never a
|
||||
hand-centred `Spinner`. `fill`: `parent` (`h-full`, needs a parent with a
|
||||
height: the artifact panel, a drawer body), `screen` (`h-screen`, the app and
|
||||
height: the artifact panel, a drawer body), `screen` (`h-dvh`, the app and
|
||||
admin guards) or `block` (`py-10`, a page section or dialog body with no
|
||||
height of its own). `label` puts a muted caption under the ring (a long job:
|
||||
Convert to wiki, Enable GraphRAG); it is also the ring's name. Spinners
|
||||
Convert to wiki, Enable GraphRAG); it is also the ring's name. `size` takes
|
||||
the Spinner sizes (`default` unless set): `sm` inside a picker (the
|
||||
MultiSelect popover's list), `lg` for a full panel (a remote device's
|
||||
config). Spinners
|
||||
inside a control (a busy Button, a picker's `sm` ring) stay `Spinner`.
|
||||
|
||||
Nothing to show is `EmptyState`: `size` `default | sm | xs` (128 / 96 / 64px
|
||||
@@ -618,20 +696,33 @@ width-percent divs in quota, indexing and guardrail views.
|
||||
`size`: `none` (image decides, the default), `xs` (28px), `sm` (32px),
|
||||
`default` (36px), `lg` (40px): Button's names at Button's heights;
|
||||
`shape`: `none`, `circle`, `square`; `variant`: `default`, `primary` (brand
|
||||
initials on `secondary`), `muted` (grey initials). Initials boxes pass the letters as
|
||||
initials on `secondary`), `muted` (a grey `bg-muted-foreground/15` box with
|
||||
`foreground` initials). Initials boxes pass the letters as
|
||||
children.
|
||||
|
||||
### Dropzone (`ui/dropzone.tsx`)
|
||||
|
||||
`size`: `default` (tall target), `compact` (one row, for forms and modals).
|
||||
`size`: `default` (tall target, for a step whose whole job is the file: Upload
|
||||
from device, Import agent, Import API specification), `compact` (one row, a
|
||||
file field among other fields),
|
||||
`tile` (a square beside a form's fields, the agent's avatar: 64px and
|
||||
icon-only on a phone beside the Name field, 88px with a one-word title from
|
||||
`sm` beside Name and Description; no description). `FileUpload size="tile"` fills the tile
|
||||
with the picked image, or with `currentImage` (the saved one) until then.
|
||||
Props: `onDrop`, `accept`, `multiple`, `maxFiles`, `maxSize`, `disabled`,
|
||||
`title`, `description`, `icon`, `error`. Border and fill follow the drag
|
||||
`title`, `description`, `icon`, `error`, `validator` (react-dropzone's
|
||||
per-file check) and `children`, which replaces the icon, title and
|
||||
description block. Border and fill follow the drag
|
||||
state through `data-drag-active` and `data-drag-reject` (the `/5` wash);
|
||||
it hovers to solid `accent`. Every drop
|
||||
target renders it, including `components/FileUpload.tsx` (which keeps its
|
||||
preview and validation logic) and the Import agent / Import API
|
||||
specification dialogs; don't hand-roll a `border-2 border-dashed` target
|
||||
around `useDropzone`.
|
||||
around `useDropzone`. Picked files show under it as `ListRows` (name and
|
||||
size) in an `outline` Card, and rejected files (too large, wrong type) as the
|
||||
Dropzone's `error`, never dropped silently. The chat composer is the one other
|
||||
`useDropzone` host: the whole composer is the drop area, with its own
|
||||
overlay, whose prompt is a Title (see Typography roles).
|
||||
|
||||
### Toast (`ui/toast.tsx`)
|
||||
|
||||
@@ -644,8 +735,8 @@ overlay. The app has one
|
||||
(`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
|
||||
`UploadToast` and `ActionToast`, and it moves to the bottom-left while any
|
||||
agent preview drawer is open (workflow or classic). 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
|
||||
@@ -770,15 +861,42 @@ Previous / Next buttons. Don't hand-roll a Previous / Next row.
|
||||
|
||||
Every section page (settings, admin, agents, teams) is wrapped in
|
||||
`navigation/SectionShell` (`width` `default` 6xl, `wide` 7xl for admin,
|
||||
`narrow` 5xl; `pills` for the phone destination pills); it draws the title and
|
||||
`narrow` 5xl; `title` replaces the section title; `titleAction` puts a page-level
|
||||
control, such as a toolbar `ActionMenu`, at the title row's end;
|
||||
`header={false}` drops the title for the phone section index, which draws
|
||||
its own);
|
||||
it draws the title and
|
||||
starts the content 32px below it, so pages carry no root `mt-*`. The block
|
||||
under the title is `components/PageToolbar`: `intro`, then the page `search`
|
||||
(left, `max-w-md`) and the page `action` (right, `Button size="field"
|
||||
shape="pill"`), then `divider` (a `Separator`). A page search is
|
||||
`components/SearchInput`: the 38px pill with a search icon and a floating
|
||||
`label`. The chunk viewer's and file tree's searches stay `CommandInput` in
|
||||
shape="pill"`), then `children` (notices that belong to the action, such as
|
||||
a limit warning), then `divider` (a `Separator`). With no `search` the intro
|
||||
moves into the row's left slot (`max-w-2xl`) beside the action. A page search
|
||||
is `components/SearchInput`: the 38px pill (`size="sm"` for 32px) with a
|
||||
search icon and a floating `label` on `labelSurface="background"` (its
|
||||
default); a placeholder-only search is named by its placeholder. The chunk viewer's and file tree's searches stay `CommandInput` in
|
||||
their frame, because their results are `CommandItem`s.
|
||||
|
||||
An agent's tabs (Overview, Logs, Schedules) share one header,
|
||||
`agents/components/AgentPageToolbar` on top of `PageToolbar`, under
|
||||
`SectionShell`'s title (the tab's name, or "New agent" through SectionShell's
|
||||
`title` prop). Its props: `name` (the agent, first in a muted byline),
|
||||
`status` (a Badge beside the name, Overview's Published / Draft), `meta` (the
|
||||
tab's context after a middle dot, "Last used" on Logs and Schedules),
|
||||
`actions` (the tab's buttons on the right, at most three; the page's ⋯ goes
|
||||
in the title row, not here), `intro` (a line that replaces
|
||||
the byline: the new-agent form) and `children` (notices between the row and
|
||||
the rule, such as the failed-save `Alert`). With a status badge the meta
|
||||
takes its own line on a phone. The row keeps the 38px field height when a
|
||||
tab has no actions, so the rule lands in the same place on every tab.
|
||||
`AgentPageHeader` remains only in the workflow builder's fixed toolbar. All
|
||||
three tabs are `SectionShell` pages at the default width; the agent preview
|
||||
is a drawer (see Modal, not Dialog). Overview groups its fields into three
|
||||
`subtle lg` panels (Basics, Knowledge and behaviour, Model) with the two-up
|
||||
field grid, then Advanced, Guardrails and the danger zone. Schedules shows a
|
||||
`StatCard` row, Recurring / One-time as `ui/tabs variant="underline"`, and
|
||||
each schedule as a `ScheduleRow` (see Card surfaces).
|
||||
|
||||
### App chrome: the phone top bar and New Chat
|
||||
|
||||
Below `lg` the top of every page is `navigation/MobileTopBar` (56px, `h-14`):
|
||||
@@ -829,12 +947,15 @@ wrapper).
|
||||
|
||||
### Grids
|
||||
|
||||
Two recipes, no component. Tiles (sources, tools, custom models, agents):
|
||||
Three recipes, no component. Tiles (sources, tools, custom models, agents):
|
||||
`grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`; tiles
|
||||
take the column width, never a fixed `w-[300px]`. Team cards stop at three
|
||||
columns (`lg:grid-cols-3`): their header row holds an initial, the name, a
|
||||
role badge and a chevron. Stat rows: `grid grid-cols-2 gap-4 md:grid-cols-4`
|
||||
(five tiles: `md:grid-cols-3 lg:grid-cols-5`; a dialog: `grid-cols-3`). A
|
||||
(five tiles: `md:grid-cols-3 lg:grid-cols-5`; a dialog: `grid-cols-3`). Text
|
||||
tiles whose body is the content (chunks) follow their container, since the
|
||||
same list renders beside a file-search column: `grid grid-cols-1 gap-4
|
||||
sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]`. A
|
||||
grid inside a Modal keeps its own column counts, because breakpoints follow
|
||||
the window, not the dialog.
|
||||
Skeleton mirrors follow the grid they stand in for.
|
||||
@@ -853,12 +974,12 @@ rejects imports of it elsewhere. App code uses `Modal` (sizes `sm` to
|
||||
`full`, `mobileVariant="sheet"`) or `CommandDialog`.
|
||||
|
||||
The width comes from `size` only: `sm` 384, `md` 512 (the default), `lg` 672
|
||||
(a form or a one-column list: Move to folder, Upload), `xl` 896 (a grid of
|
||||
(a form or a one-column list: Move to folder), `xl` 896 (a grid of
|
||||
tiles or a wide editor: Add tool, Test retrieval, the prompt editor), `full`.
|
||||
Never add a width or height class. The dialog caps itself at `85dvh` and
|
||||
its body is the one scroller, so the header and footer stay put; don't cap
|
||||
the body with `contentClassName`. `contentClassName="overflow-visible"`
|
||||
(`!overflow-visible`) is only for a body whose popover must escape it (the
|
||||
(`cn` drops the body's `overflow-y-auto` for it) is only for a body whose popover must escape it (the
|
||||
prompt editor, Share conversation, Move to folder). `className` is placement
|
||||
only.
|
||||
|
||||
@@ -894,23 +1015,31 @@ the same (`modals/SearchConversationsModal.tsx`). `CommandDialog` sits on
|
||||
`DialogContent`, which has Modal's surface (`bg-card rounded-2xl
|
||||
shadow-modal`, no border, `p-8`, a 20px title, a `gap-3` footer, a left-aligned
|
||||
header, and the `flex-col max-h-[85dvh]` column whose body the caller makes the
|
||||
scroller) and the same blurred overlay. Modal, DialogContent and every Sheet share one scrim,
|
||||
scroller) and the same blurred overlay. Modal, DialogContent, every Sheet and
|
||||
the phone sidebar's backdrop (`Navigation.tsx`, at `z-20` so an open sidebar
|
||||
dims the top bar, with SheetOverlay's `animate-in fade-in-0`) share one scrim,
|
||||
`overlayScrim` in `lib/utils.ts` (`bg-black/25 backdrop-blur-xs
|
||||
dark:bg-black/50`).
|
||||
|
||||
A right drawer (`SheetContent side="right"`) is on `bg-background`, like the
|
||||
page: its content paints no surface of its own, and panels in it are
|
||||
`subtle` (see Card surfaces). An agent is previewed in one shared drawer,
|
||||
`agents/components/AgentPreviewSheet`, for workflow and classic agents alike:
|
||||
`w-full sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]`, a `SheetTitle`
|
||||
`subtle` (see Card surfaces). Its width is `size`, by role, never a `w-*` or
|
||||
`max-w-*` class (enforced): `default` (384px, 75% on a phone) for a companion
|
||||
list read beside the chat (an answer's sources, the phone artifact sheet);
|
||||
`detail` (576px, full width on a phone) for one record's fields (a schedule
|
||||
run); `wide` (600 / 700 / 800px at `sm` / `md` / `lg`, full width on a phone)
|
||||
for a working surface (a trace, an agent preview). An agent is previewed in
|
||||
one shared drawer, `agents/components/AgentPreviewSheet`, for workflow and
|
||||
classic agents alike: `size="wide"`, a `SheetTitle`
|
||||
and a one-line `SheetDescription` (the agent's name) in a header padded
|
||||
`pr-12` to clear the close X, an `info` Running badge while it answers, a
|
||||
`Separator`, then the preview. The workflow preview's Execution details and
|
||||
Artifacts rows are the answer's step-row recipe (see Alert), each step a
|
||||
`subtle sm` panel with its output in a `filled sm` well.
|
||||
|
||||
Every phone bottom sheet has one shape: `bg-card`, 16px top corners, no top
|
||||
border, `max-h-sheet` (the visible viewport less the top safe-area inset and a
|
||||
Every phone bottom sheet has one shape: `bg-card`, 18px top corners (`rounded-t-2xl`), no top
|
||||
border, `shadow-lg` (both `SheetContent side="bottom"` and Modal's phone
|
||||
sheet), `max-h-sheet` (the visible viewport less the top safe-area inset and a
|
||||
3rem strip, so the scrim above it can always be tapped to close), and bottom
|
||||
padding that clears the iPhone home indicator. Never cap a sheet with `vh` (see
|
||||
Viewport heights): a `90vh` sheet slides under Safari's URL bar and covers its
|
||||
@@ -918,8 +1047,9 @@ scrim. `SheetContent side="bottom"` gives the
|
||||
shape with `pb-safe-0` (the bare inset); pass `handle` for the grab bar, which
|
||||
also hides the X (the overlay dismisses it; the handle is only a cue and doesn't
|
||||
drag; pass `showCloseButton` to keep an X).
|
||||
`Modal mobileVariant="sheet"` shares the shape and the `SheetHandle`, with
|
||||
`pb-safe` (the inset, at least 1rem) under its footer. `pb-safe`, `pb-safe-0`
|
||||
`Modal mobileVariant="sheet"` shares the shape and the `SheetHandle` (and,
|
||||
like a handled sheet, has no X), with `pb-safe` (the inset, at least 1rem)
|
||||
under its footer. `pb-safe`, `pb-safe-0`
|
||||
and `max-h-sheet` are the `index.css` utilities for the safe-area insets; never
|
||||
spell `env()` in a class.
|
||||
|
||||
@@ -933,35 +1063,14 @@ unmounts, and Safari samples the page colour again. `useDarkTheme` calls
|
||||
`resetBottomTint()` and `resetTopTint()` (a 16px strip on the top edge) when the
|
||||
theme changes. Anything else fixed to the bottom edge that can go away (a
|
||||
drawer, a bottom banner) calls `resetBottomTint()` when it is removed. The sizes
|
||||
and the one-frame life were measured on an iPhone: 4px at the bottom, 6px at
|
||||
the top and a 16ms timer all failed, and a strip behind the content,
|
||||
transparent or at `opacity-0` isn't sampled, so don't shrink or hide it.
|
||||
and the one-frame life were measured on an iPhone (6px at the bottom, 16px at
|
||||
the top), and a strip behind the content, transparent or at `opacity-0` isn't
|
||||
sampled, so don't shrink or hide the strip.
|
||||
|
||||
**Open question: side panel or right sheet for chat content.** Chat has two
|
||||
ways to show something beside an answer. Notes, todos and files open in
|
||||
`components/ArtifactSidebar`, a panel that takes a column and leaves the chat
|
||||
usable. An answer's full source list opens in a right `Sheet`, which blurs
|
||||
and blocks the chat, so the answer being checked is hidden while its sources
|
||||
are read. No rule picks between them yet. The leading proposal is one
|
||||
surface: content read alongside the chat (artifacts, sources, a cited
|
||||
source) opens in the side panel, with citation chips opening it at that
|
||||
source; overlays stay for tasks that interrupt (forms, confirmations,
|
||||
pickers); phones keep the bottom sheet. Until that is decided, don't add a
|
||||
third pattern: new "read beside the chat" content uses the side panel.
|
||||
|
||||
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.
|
||||
|
||||
A picker's footer (`MultiSelectPopover footer`) links to the page that manages
|
||||
the list, as a `link inline` Button with a 12px `ArrowRight` (Go to Sources, Go
|
||||
to Tools). A shortcut action (Upload new, an `outline-primary pill`) sits at the
|
||||
right end of the same row: `flex flex-wrap items-center justify-between gap-3`,
|
||||
so on a narrow sheet or in a long locale it wraps under the link rather than
|
||||
taking a row of its own everywhere (`SourcesPopoverFooter`).
|
||||
Content read alongside the chat (notes, todos, files) opens in
|
||||
`components/ArtifactSidebar`: a split column beside the chat on desktop, a
|
||||
right `Sheet` on phones. An answer's full source list is a right `Sheet`.
|
||||
Don't add a third pattern.
|
||||
|
||||
### ActionMenu (`ui/dropdown-menu.tsx`)
|
||||
|
||||
@@ -970,23 +1079,30 @@ The three-dots menu on a card, tile or row. Pass `options: MenuOption[]`
|
||||
`disabled`) and a `triggerLabel`. It renders a `ghost-on-accent` `icon-xs`
|
||||
trigger with `EllipsisVertical`, and stops clicks and keys on the trigger and
|
||||
the menu from reaching the host, so it can sit inside a clickable card.
|
||||
`className` takes layout only (position, margin) and lands on the trigger;
|
||||
In a page header or toolbar on a plain surface (the agent Overview's title
|
||||
row, through `SectionShell titleAction`; the workflow builder's toolbar), pass
|
||||
`size="toolbar"`: the trigger is a 36px `ghost-muted` icon button like the
|
||||
others there, hovering to `accent`, with its tooltip below. The default
|
||||
`row` trigger's darker hover is only for hosts that turn `accent` themselves.
|
||||
`align` (default `end`) places the menu, `disabled` greys the trigger, and
|
||||
`triggerTestId` puts a `data-testid` on it. `className` takes layout only (position, margin) and lands on the trigger;
|
||||
the menu is at least 144px wide and grows with its labels. `open`/`onOpenChange` make it controlled. Don't hand-build
|
||||
this menu from `DropdownMenu`, and don't declare a local option type.
|
||||
|
||||
### Table, Label, dialog text
|
||||
|
||||
Every table is `ui/table`; never a raw `<table>` or a table utility class (the
|
||||
chat's markdown tables are the one exception). The header row is dense by
|
||||
default (`px-2 py-1 text-sm font-normal text-foreground`, 28px, on
|
||||
Every table is `ui/table`; never a raw `<table>` (enforced) or a table
|
||||
utility class (the chat's markdown tables are the one exception, see Approved
|
||||
exceptions). The header row is dense by
|
||||
default (`px-2 py-1 lg:px-3 text-sm font-normal text-foreground`, 28px, on
|
||||
`TableHead`'s sticky `bg-muted` strip). A `TableRow` hovers (`bg-accent`,
|
||||
pointer) only when it has an `onClick`; a read-only row does not. Use
|
||||
`TableContainer` for the bordered, scrolling frame; a table that already sits
|
||||
in a frame renders `Table` alone. `Table` keeps a 600px minimum so wide
|
||||
tables scroll sideways; a narrow one inside a panel passes
|
||||
`minWidth="min-w-0"`. A fixed icon column is `width="50px" align="center"`.
|
||||
`minWidth="min-w-0"`. A fixed icon column is `width="40px" align="center"`.
|
||||
`TableCell` and `TableHeader` accept typography and alignment classes and
|
||||
`text-muted-foreground` (RunLog's headers pass the eyebrow). `Label`, `DialogTitle`, `DialogDescription`,
|
||||
`text-muted-foreground`. `Label`, `DialogTitle`, `DialogDescription`,
|
||||
`SheetTitle` and `SheetDescription` accept typography plus
|
||||
`text-foreground` and `text-muted-foreground`. `DialogContent`,
|
||||
`PopoverContent`, `SheetContent`, `SheetHeader`, `SheetFooter`,
|
||||
@@ -1019,8 +1135,8 @@ runs under the tab bar, and the app shell can be dragged and bounce. ESLint
|
||||
(`no-restricted-syntax`) rejects both. Pick by what the height does:
|
||||
|
||||
- **`dvh`** (the visible viewport, follows the toolbars): the app shell
|
||||
(`h-dvh`), full-screen states (`min-h-dvh`: 404, MobileBlocker, the `screen`
|
||||
`LoadingState`), and caps on things that pop up over the page (a search
|
||||
and the `screen` `LoadingState` (`h-dvh`), full-screen states (`min-h-dvh`:
|
||||
404, MobileBlocker), and caps on things that pop up over the page (a search
|
||||
dropdown's `max-h-[calc(100dvh-200px)]`, `Modal`'s `85dvh`).
|
||||
- **`svh`** (the smallest viewport, steady): a fixed-size panel inside a page
|
||||
that scrolls (the Logs panel's `h-[55svh]`, a list's `max-h-[45svh]`, the
|
||||
@@ -1033,12 +1149,16 @@ runs under the tab bar, and the app shell can be dragged and bounce. ESLint
|
||||
|
||||
### Typography roles
|
||||
|
||||
- **Page title**: `SectionPageHeader` (24px bold); the only other text at
|
||||
that size is a stat figure (`StatCard`).
|
||||
- **Page title**: `SectionPageHeader` (24px bold; the phone section index,
|
||||
`SectionIndexPage`, draws the same `h1`); the only other text at that size
|
||||
is a stat figure (`StatCard`).
|
||||
- **Title** of a dialog, sheet, drawer header or detail page: `text-xl
|
||||
leading-tight font-semibold`. `DialogTitle` and `SheetTitle` default to it;
|
||||
`font-bold` is never a title weight. A picker popover's header is a
|
||||
sub-heading, not a title.
|
||||
sub-heading, not a title. The composer's drag-and-drop prompt is a Title
|
||||
in `foreground`. The artifact panel's title (phone sheet and desktop split
|
||||
column alike) is a sub-heading, `text-sm font-semibold wrap-anywhere`: the
|
||||
panel sits beside the chat and its title is often a long generated name.
|
||||
- **Section title**: `SectionHeader` (18px). **Sub-heading** inside a panel,
|
||||
drawer or modal: `SectionHeader size="xs"` (14px semibold). **Eyebrow**
|
||||
(anything set in caps): `SectionHeader size="sm"`; never `uppercase` on a
|
||||
@@ -1064,7 +1184,7 @@ leading-tight font-semibold`. `DialogTitle` and `SheetTitle` default to it;
|
||||
is `wrap-anywhere`, which also lets a table cell or flex item shrink below
|
||||
the token instead of widening the table. Both break at spaces first and
|
||||
split a token only when it would overflow. Never `break-all`: it splits
|
||||
ordinary words mid-word ("notif / ications").
|
||||
ordinary words mid-word ("notif / ications"); the lint rejects it.
|
||||
|
||||
### Rhythm
|
||||
|
||||
@@ -1078,11 +1198,14 @@ gap, so drop it).
|
||||
### Radius by role
|
||||
|
||||
`rounded-xs` 2px, `rounded-sm` 6px, `rounded-md` 8px, `rounded-lg` 10px,
|
||||
`rounded-xl` 14px, `rounded-2xl` 18px (from `--radius`); bare `rounded` is a
|
||||
fixed 4px, so don't use it. Skeleton bars: the default. Tinted icon squares:
|
||||
`rounded-xl` 14px, `rounded-2xl` 18px, `rounded-3xl` 22px, `rounded-4xl`
|
||||
26px (from `--radius`); bare `rounded` is a fixed 4px, so don't use it
|
||||
(enforced). `3xl` is the chat's own shapes (the composer, the question
|
||||
bubble) and `4xl` the answer's source cards and the landing page's model
|
||||
picker; app chrome stops at `2xl`. Skeleton bars: the default. Tinted icon squares:
|
||||
`rounded-md` at `size-7|8`, `rounded-xl` at `size-12|14`, `rounded-2xl` at
|
||||
`size-20`. `rounded-full` only for avatars, status dots and the workflow
|
||||
palette pills.
|
||||
`size-20`. `rounded-full` only for pills (`shape="pill"` controls and
|
||||
fields), avatars, status dots and the workflow palette pills.
|
||||
|
||||
## Motion
|
||||
|
||||
@@ -1093,8 +1216,8 @@ collapsibles, `duration-300 ease-in-out` on the named property for the shell
|
||||
(sidebar, main column, top buttons); a shadow or ring change is
|
||||
`transition-shadow`, several at once a bare `transition`. No `transition-all`
|
||||
(the floating labels in `form-field.tsx` and `input.tsx`, which move and
|
||||
resize, are the exception) and no `hover:scale`: hover is a fill, border or
|
||||
text-colour change.
|
||||
resize, are the exception) and no `hover:scale-*`, both enforced: hover is a
|
||||
fill, border or text-colour change.
|
||||
|
||||
New entrances are `animate-in fade-in duration-200 motion-reduce:animate-none`
|
||||
(`SectionRail`). Two chat timings are decided exceptions: the disclosure fade
|
||||
@@ -1106,16 +1229,16 @@ fade-in slide-in-from-bottom-1.5 duration-260 ease-out`, both with
|
||||
|
||||
Phone / desktop is `lg` (1024px) in classes and `isDesktop` in JS
|
||||
(`useMediaQuery`); `sm` and `md` only reflow content. Wide two-column layouts
|
||||
that need more room (Analytics' chart rows, the agent form beside its preview)
|
||||
go two-up at `xl`. No custom breakpoints (`min-[…]`, `max-[…]`,
|
||||
`[@media(…)]`). The workflow builder needs `lg`; below it `MobileBlocker`
|
||||
that need more room (Analytics' chart rows) go two-up at `xl`. No custom
|
||||
breakpoints (`min-[…]`, `max-[…]`, `[@media(…)]`; enforced). The workflow builder needs `lg`; below it `MobileBlocker`
|
||||
shows.
|
||||
|
||||
## Focus, elevation and stacking
|
||||
|
||||
- **Focus ring**: `focus-visible:ring-3 focus-visible:ring-ring/50` plus
|
||||
`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,
|
||||
and never `ring-2` or `ring-[3px]` (a `focus:` or `focus-within:` ring
|
||||
with `ring-2` is enforced); the `ui/` components already carry it,
|
||||
so plain elements should become components rather than copy the classes.
|
||||
Inside `ui/`, spell it with the `focusRing` constant from `lib/utils.ts`
|
||||
(with `invalidState` and `fieldFrame` for fields) rather than retyping it.
|
||||
@@ -1130,8 +1253,10 @@ shows.
|
||||
A dialog panel is a `tabIndex=-1` focus target that Radix can focus, so it
|
||||
carries `outline-none`: Safari draws its own blue `outline: auto` there and
|
||||
ignores our `outline-color`. Any new focusable container needs the same.
|
||||
- **Close buttons** on Modal, Sheet and DialogContent are a `ghost-muted`
|
||||
`size="icon-sm"` Button (32px, accent square on hover) at `top-2 right-2`.
|
||||
- **Close buttons**: every Modal, Sheet and DialogContent uses its built-in
|
||||
close (a phone sheet with a handle has none), a `ghost-muted` `size="icon-sm"` Button (32px, accent square on
|
||||
hover) at `top-2 right-2`, the artifact sheet included; never hand-place
|
||||
another X.
|
||||
- **Disabled**: buttons (Button, Accordion, Tabs) use
|
||||
`disabled:pointer-events-none disabled:opacity-50`, so the pointer passes
|
||||
through. Fields (Input, SelectTrigger, Switch, Textarea, CommandInput,
|
||||
@@ -1150,15 +1275,18 @@ shadow-lg` in both themes: the knob is white on any track, and a white knob
|
||||
publish-error panel, which float over the canvas at popover elevation
|
||||
(`shadow-md`, `z-20`); the chunk viewer's and file tree's search result
|
||||
lists (`shadow-md`, in-page `z-20`, their results are `CommandItem`s inside
|
||||
the search frame); and the Hero model picker's menu (an Approved
|
||||
exception). Don't add new ones: a floating panel is a `Popover`,
|
||||
the search frame); the sliding panel in `navigation/SidebarLevel.tsx`,
|
||||
which casts a horizontal shadow while it slides; and the Hero model
|
||||
picker's menu (both Approved exceptions). Don't add new ones: a floating panel is a `Popover`,
|
||||
`DropdownMenu` or `Modal`, which carry their own shadow and stacking; never
|
||||
an `absolute` div with a shadow and a hand-picked `z-*`.
|
||||
- **Stacking**: `z-10` sticky headers and table heads inside a page;
|
||||
`z-20` in-page floating chrome (banners, scroll-to-bottom, drag
|
||||
handles); `z-50` overlays, modals, sheets, toasts and the one-frame
|
||||
bar-tint strips; `z-200` every portalled floating list (popover, menu,
|
||||
select, tooltip), so it opens above a Modal without an override. Do not invent values in between. The app
|
||||
select, tooltip), so it opens above a Modal without an override. Do not
|
||||
invent values in between: a `z-*` outside 0, 10, 20, 50, 200 and `auto` is
|
||||
rejected (enforced). The app
|
||||
shell uses the low layers too: the phone top bar is `z-10`, and the
|
||||
sidebar and its phone backdrop are `z-20`, so an open sidebar dims the bar.
|
||||
|
||||
@@ -1189,7 +1317,7 @@ list stays reviewable.
|
||||
| `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | The Preview minimap's node rows are status tiles: the fill, border and ring follow the step (success, primary running + pulse, destructive, muted pending; a ring on the active row) and stay pinned on hover, pending and running rows stay unfaded while disabled, and clickable rows dim to 80% on hover. No Button variant is status-tinted. The rule reports each string inside `cn(...)`, so it's a `/* eslint-disable */` … `/* eslint-enable */` pair around the `className` attribute. |
|
||||
| `agents/schedules/ScheduleFormModal.tsx` | `shadcn/no-restyle` | The schedule's name is the dialog's editable title: a `bare` Input with title type (`text-xl font-semibold`), so the dialog passes `hideTitle`. One disable. |
|
||||
| `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom − / + buttons sit on the diagram's `bg-black/70` overlay, where ghost's accent hover paints a light square with dark text; they hover to `white/20` with white text instead, in both themes. Two disables. |
|
||||
| `Hero.tsx` | `shadcn/no-restyle` | The landing page's demo cards are `Button outline lg pill`, but each is a two-line pill (a title over a clamped 12px query), so it undoes lg's height, the base's one-row layout, weight and nowrap: `h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal`. One disable (phase 7, 36a). |
|
||||
| `Hero.tsx` | `shadcn/no-restyle` | The landing page's demo cards are `Button outline lg pill`, but each is a two-line pill (a title over a clamped 12px query), so it undoes lg's height, the base's one-row layout, weight and nowrap: `h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal`. One disable. |
|
||||
| `Navigation.tsx`, `conversation/ConversationTile.tsx` | `shadcn/no-restyle` | A sidebar row whose link has sibling buttons (an agent's pin, a conversation's menu and rename Save / Cancel) keeps its fill while the pointer is on a sibling or the menu is open (`group-hover:bg-sidebar-accent`, `bg-sidebar-accent`), and `pr-10` keeps the label clear of the buttons. ConversationTile's `cn(...)` needs a `/* eslint-disable */` … `/* eslint-enable */` pair. |
|
||||
| `admin/Usage.tsx` | `shadcn/no-restyle` | The Top users id is a `link inline` Button inside a mono table cell; it keeps the cell's type and wraps (`font-mono text-xs font-normal whitespace-normal text-left`). One disable. |
|
||||
| `agents/workflow/WorkflowBuilder.tsx` | `shadcn/no-restyle` | The "Learn more" links in the Set state and Condition nodes' 12px hints keep the sentence's size and weight (`text-xs font-normal` on `link inline`). Two disables. |
|
||||
@@ -1197,35 +1325,23 @@ list stays reviewable.
|
||||
| `settings/PersonalAccessTokens.tsx` | `shadcn/no-restyle` | Token scope chips are identifiers, so the `neutral` Badge is set in mono (`font-mono`). One disable. |
|
||||
| `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | A step's state changes (keys and values the app serialised) are `neutral` Badges set in mono (`font-mono`), like token scopes. One disable. |
|
||||
| `settings/traces/TraceChips.tsx` | `shadcn/no-restyle` | Trace stat chips (durations, counts) use tabular figures so they don't jitter between rows (`tabular-nums` on Badge). One disable. |
|
||||
| `modals/MCPServerModal.tsx` | `shadcn/no-restyle` | The authorization link inside the test-result Alert keeps the Alert's status colour (`text-current` on `link inline`). One disable (phase 11, 63g). |
|
||||
| `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom readout between − and + is a `link inline` Button on the `bg-black/70` overlay; it keeps the overlay's white 12px regular text (`text-xs font-normal text-current`). One disable, beside the two zoom-button ones above (phase 11, 63g). |
|
||||
| `conversation/SharedConversation.tsx` | `shadcn/no-restyle` | The "DocsGPT" link sits in the `/share/:id` page's regular-weight byline (`font-normal`). One disable (phase 11, 63g). |
|
||||
| `conversation/ConversationBubble.tsx` | `shadcn/no-restyle` | A source card's URL row is a `link inline` around an `<a>`: foreground at rest, primary on hover, regular weight, truncating (`text-current font-normal hover:text-primary underline-offset-2 max-w-full justify-start`). One disable (phase 11, 63g). |
|
||||
| `admin/Overview.tsx` | `shadcn/no-restyle` | "View in Audit" under the denied-sign-ins tile keeps the tile's destructive tone at hint size (`text-destructive text-xs font-normal`). One disable (phase 11, 63g). |
|
||||
| `settings/PairDeviceModal.tsx` | `shadcn/no-restyle` | The install link in Pair a remote machine sits in a 12px hint (`text-xs font-normal`, `self-start` in its column). One disable (phase 11, 63g). |
|
||||
| `modals/MCPServerModal.tsx` | `shadcn/no-restyle` | The authorization link inside the test-result Alert keeps the Alert's status colour (`text-current` on `link inline`). One disable. |
|
||||
| `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom readout between − and + is a `link inline` Button on the `bg-black/70` overlay; it keeps the overlay's white 12px regular text (`text-xs font-normal text-current`). One disable, beside the two zoom-button ones above. |
|
||||
| `conversation/SharedConversation.tsx` | `shadcn/no-restyle` | The "DocsGPT" link sits in the `/share/:id` page's regular-weight byline (`font-normal`). One disable. |
|
||||
| `conversation/ConversationBubble.tsx` | `shadcn/no-restyle` | A source card's URL row is a `link inline` around an `<a>`: foreground at rest, primary on hover, regular weight, truncating (`text-current font-normal hover:text-primary underline-offset-2 max-w-full justify-start`). One disable. |
|
||||
| `admin/Overview.tsx` | `shadcn/no-restyle` | "View in Audit" under the denied-sign-ins tile keeps the tile's destructive tone at hint size (`text-destructive text-xs font-normal`). One disable. |
|
||||
| `settings/PairDeviceModal.tsx` | `shadcn/no-restyle` | The install link in Pair a remote machine sits in a 12px hint (`text-xs font-normal`, `self-start` in its column). One disable. |
|
||||
| `agents/workflow/WorkflowBuilder.tsx` | `shadcn/no-restyle` | The publish-error Alert floats over the canvas with its close button in the top-right corner, so it pads `pr-10` to keep a long title clear of the button. One disable. |
|
||||
| `conversation/MarkdownAnswer.tsx` | `no-restricted-syntax` | Markdown answer tables render the model's own table markup with its own styling, so they stay a raw `<table>`. |
|
||||
|
||||
Note that a multi-line reason has to be a `/* ... */` block comment;
|
||||
consecutive `//` lines only disable the next comment line, not the code.
|
||||
|
||||
## Removed classes
|
||||
|
||||
The lint reports classes Tailwind cannot generate; `no-unknown-classes` is at
|
||||
`error`, so none remain. For the record, they were bugs, not missing
|
||||
configuration:
|
||||
|
||||
- `xs:` has never been a breakpoint here (not in the old Tailwind v3 config
|
||||
either), so `xs:px-3`, `xs:text-xs` never applied. Remove them rather than
|
||||
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.
|
||||
`no-unknown-classes` is at `error`. Before deleting a class the lint calls
|
||||
unknown, grep for it in `querySelector`, `classList` and `data-` lookups; a
|
||||
class can be a hook for code rather than a style.
|
||||
|
||||
## Live style guide
|
||||
|
||||
@@ -1237,20 +1353,21 @@ variant or token, add it there too.
|
||||
|
||||
## Cleanup playbook
|
||||
|
||||
Every `shadcn/*` rule is at `error`, and `lint-staged` runs the lint on
|
||||
staged files, so a violation blocks the commit. Work one file at a time and
|
||||
Every `shadcn/*` rule is at `error`, and on commit `lint-staged` runs
|
||||
`npm run lint-fix` and `npm run format` (both over the whole `src`), so a
|
||||
violation blocks the commit. Work one file at a time and
|
||||
keep every step green.
|
||||
|
||||
1. **See the state.** `npm run lint:design` prints warnings by rule and the
|
||||
worst files. `npm run lint:design -- --file src/settings/Teams.tsx` lists
|
||||
every warning in one file with the fix the message proposes;
|
||||
1. **See the state.** `npm run lint:design` prints violations (`shadcn/*`
|
||||
and the design `no-restricted-syntax` selectors) by rule and the worst
|
||||
files. `npm run lint:design -- --file src/settings/Teams.tsx`
|
||||
lists every violation 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
|
||||
2. **Pick a file, read it whole**, then fix all of its design violations 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).
|
||||
Delete the `dark:` twin of every class you tokenise.
|
||||
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
|
||||
@@ -1259,26 +1376,23 @@ keep every step green.
|
||||
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),
|
||||
5. **Verify per file**: `npx eslint <file>` (zero errors),
|
||||
`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.
|
||||
|
||||
Known traps:
|
||||
|
||||
- `npm run lint` also reports 6 pre-existing Prettier errors (union-type
|
||||
line breaks in `cronBuilder.ts`, `types/schedule.ts`, `types/workflow.ts`,
|
||||
`api/client.ts`, `models/misc.ts`, `models/types.ts`) and about 330
|
||||
TypeScript warnings unrelated to design; `npm run lint-fix` clears the
|
||||
Prettier ones.
|
||||
- `npm run lint` reports 0 errors and about 325 `@typescript-eslint`
|
||||
warnings (mostly `no-explicit-any`) unrelated to design; `tsc` is clean.
|
||||
- Port 5173 may be held by another checkout; check the Vite banner shows
|
||||
this repo's path before trusting what you see.
|
||||
- `components/SkeletonLoader.tsx` renders `Skeleton` in its table, logs,
|
||||
default and files layouts and its four tile mirrors (Card + bars). Its
|
||||
analysis, dropdown, chunk-card and connected-state layouts still pulse their
|
||||
own surface; move them onto Card + `Skeleton` the same way when you touch
|
||||
them. (`agents/schedules/StatusBadge.tsx` is a status→variant map over
|
||||
`Badge`, not a wrapper; keep it.)
|
||||
- `components/SkeletonLoader.tsx`: the chunk, source, tool, add-tool and
|
||||
agent layouts are Card mirrors with `Skeleton` bars, and the `chatbot`,
|
||||
table, logs, default, analysis, connected-state and files layouts use
|
||||
`Skeleton`; none pulses its own surface. `renderFilesSection` (and the
|
||||
GoogleDrivePicker box it mirrors) is still a raw `border rounded-lg` box;
|
||||
move both onto `Card` when you touch them.
|
||||
|
||||
## Working with the lint
|
||||
|
||||
@@ -1286,6 +1400,11 @@ Known traps:
|
||||
cd frontend && npm run lint
|
||||
```
|
||||
|
||||
All `shadcn/*` rules are `error`. Add a variant or token only when a treatment
|
||||
All `shadcn/*` rules are `error`, and so are the DESIGN.md selectors under
|
||||
`no-restricted-syntax`: `eslint/design-rules.js` (class and markup rules,
|
||||
tested in `src/design/designRules.lint.test.ts`), `eslint/card-surfaces.js`
|
||||
(tested in `src/design/cardSurfaces.lint.test.ts`) and the viewport-height
|
||||
and focus-return entries in `eslint.config.js`. Add a new checkable rule as
|
||||
a selector there, with a test and a line here. 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.
|
||||
+57
-64
@@ -8,6 +8,40 @@ import prettier from 'eslint-plugin-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
import { cardSurfaceSelectors } from './eslint/card-surfaces.js';
|
||||
import { everywhereSelectors, pageSelectors } from './eslint/design-rules.js';
|
||||
|
||||
// Selectors every file gets: viewport heights, focus return, card surfaces.
|
||||
// On iOS Safari vh (and h-screen, which is 100vh) is the viewport with the
|
||||
// toolbars hidden, so a vh height overflows the visible screen. w-screen is
|
||||
// vw, which the toolbars don't change, so it stays allowed.
|
||||
const baseSyntaxRules = [
|
||||
...['Literal[value=/\\dvh\\b/]', 'TemplateElement[value.raw=/\\dvh\\b/]'].map(
|
||||
(selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.',
|
||||
}),
|
||||
),
|
||||
...[
|
||||
'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
|
||||
'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
"Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.",
|
||||
})),
|
||||
// Modal, Sheet and DialogContent return focus to what had it on open
|
||||
// (ui/use-focus-return.ts). A hand-rolled return focuses the trigger
|
||||
// even after a tap, which lights its ring on iOS.
|
||||
{
|
||||
selector: 'JSXAttribute[name.name="onCloseAutoFocus"]',
|
||||
message:
|
||||
'Modal, Sheet and DialogContent already return focus to what had it on open (ui/use-focus-return.ts). Don\'t hand-roll onCloseAutoFocus. See DESIGN.md "Focus return".',
|
||||
},
|
||||
// Nothing on a filled tile repeats its muted fill (DESIGN.md "Card
|
||||
// surfaces").
|
||||
...cardSurfaceSelectors,
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
@@ -15,16 +49,11 @@ export default [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'prettier.config.cjs',
|
||||
'.eslintrc.cjs',
|
||||
'env.d.ts',
|
||||
'public/',
|
||||
'assets/',
|
||||
'vite-env.d.ts',
|
||||
'.prettierignore',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'postcss.config.cjs',
|
||||
'tailwind.config.cjs',
|
||||
'tsconfig.json',
|
||||
'tsconfig.node.json',
|
||||
'vite.config.ts',
|
||||
@@ -86,38 +115,14 @@ export default [
|
||||
],
|
||||
},
|
||||
],
|
||||
// On iOS Safari vh (and h-screen, which is 100vh) is the viewport with
|
||||
// the toolbars hidden, so a vh height overflows the visible screen.
|
||||
// w-screen is vw, which the toolbars don't change, so it stays allowed.
|
||||
// DESIGN.md rules a selector can check: viewport heights, focus return
|
||||
// and card surfaces (baseSyntaxRules above), plus the class and markup
|
||||
// rules in eslint/design-rules.js.
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...[
|
||||
'Literal[value=/\\dvh\\b/]',
|
||||
'TemplateElement[value.raw=/\\dvh\\b/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.',
|
||||
})),
|
||||
...[
|
||||
'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
|
||||
'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
"Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.",
|
||||
})),
|
||||
// Modal, Sheet and DialogContent return focus to what had it on open
|
||||
// (ui/use-focus-return.ts). A hand-rolled return focuses the trigger
|
||||
// even after a tap, which lights its ring on iOS.
|
||||
{
|
||||
selector: 'JSXAttribute[name.name="onCloseAutoFocus"]',
|
||||
message:
|
||||
'Modal, Sheet and DialogContent already return focus to what had it on open (ui/use-focus-return.ts). Don\'t hand-roll onCloseAutoFocus. See DESIGN.md "Focus return".',
|
||||
},
|
||||
// Nothing on a filled tile repeats its muted fill (DESIGN.md "Card
|
||||
// surfaces").
|
||||
...cardSurfaceSelectors,
|
||||
...baseSyntaxRules,
|
||||
...everywhereSelectors,
|
||||
...pageSelectors,
|
||||
],
|
||||
// Design-system rules (@shadcn/lint). Tokens, variants and the
|
||||
// approved exceptions are documented in DESIGN.md.
|
||||
@@ -210,23 +215,7 @@ export default [
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <{{component}}>: use Alert variant (default, destructive, success, warning, info) from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Checkbox$',
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Checkbox>: use size from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Dropzone$',
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Dropzone>: use size (default, compact); colours, border and radius follow the drag state in {{file}}.',
|
||||
'"{{className}}" is not allowed on <{{component}}>: use Alert variant (default, neutral, success, warning, info, destructive) from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -253,14 +242,6 @@ export default [
|
||||
pattern: '^Spinner$',
|
||||
allow: ['layout', 'color'],
|
||||
},
|
||||
{
|
||||
pattern: '^Progress$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(Toast|ToastViewport)$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(ToastContent|ToastItem|ToastFooter)$',
|
||||
allow: ['layout', 'spacing'],
|
||||
@@ -272,10 +253,6 @@ export default [
|
||||
pattern: '^MessageScroller(Viewport|Content)$',
|
||||
allow: ['layout', 'spacing'],
|
||||
},
|
||||
{
|
||||
pattern: '^OptionCard$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(CardHeader|CardContent|CardFooter|CardAction)$',
|
||||
allow: ['layout', 'spacing', 'gap-*', 'bg-muted/*'],
|
||||
@@ -339,6 +316,22 @@ export default [
|
||||
rules: {
|
||||
'shadcn/no-restyle': 'off',
|
||||
'no-restricted-imports': 'off',
|
||||
// ui/ defines the parts the page rules ask for (the table, the
|
||||
// checkbox, the floating label's transition-all), so only the
|
||||
// everywhere rules apply here.
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...baseSyntaxRules,
|
||||
...everywhereSelectors,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Tests hold class strings as fixtures (often asserting their absence),
|
||||
// so the class-token rules don't apply to them.
|
||||
files: ['src/**/*.test.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-syntax': ['error', ...baseSyntaxRules],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
type Selector = { selector: string; message: string };
|
||||
export declare const everywhereSelectors: Selector[];
|
||||
export declare const pageSelectors: Selector[];
|
||||
@@ -0,0 +1,92 @@
|
||||
// Class and markup rules from DESIGN.md that a `no-restricted-syntax`
|
||||
// selector can check. `everywhereSelectors` apply to ui/ too; `pageSelectors`
|
||||
// are about app code composing ui/ parts, so ui/ (which defines those parts)
|
||||
// is exempt from them.
|
||||
|
||||
// A class token in a string: start of string, a space or a variant colon
|
||||
// before it; a space or the end after it.
|
||||
const cls = (body) => `/(^|[\\s:])${body}(\\s|$)/`;
|
||||
|
||||
const inStrings = (regex, message) =>
|
||||
[`Literal[value=${regex}]`, `TemplateElement[value.raw=${regex}]`].map(
|
||||
(selector) => ({ selector, message }),
|
||||
);
|
||||
|
||||
/** @type {{ selector: string, message: string }[]} */
|
||||
export const everywhereSelectors = [
|
||||
...inStrings(
|
||||
cls('break-all'),
|
||||
'break-all splits ordinary words mid-word. Use wrap-break-word for prose and wrap-anywhere for a long token (URL, id, email). See DESIGN.md "Typography roles".',
|
||||
),
|
||||
...inStrings(
|
||||
cls('rounded'),
|
||||
'Bare `rounded` is a fixed 4px, outside the radius scale. Use rounded-sm (6px, bars), rounded-md (8px) or the role radius in DESIGN.md "Radius by role".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|[\\s:])(min|max)-\\[|\\[@media/',
|
||||
'No custom breakpoints (min-[…], max-[…], [@media…]). Phone / desktop is lg; sm and md only reflow content. See DESIGN.md "Breakpoints".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|[\\s:])-?z-(?!(0|10|20|50|200|auto)(\\s|$))/',
|
||||
'z-index comes from the stacking layers: z-10 sticky headers, z-20 in-page floating chrome, z-50 overlays, z-200 portalled floating lists. See DESIGN.md "Stacking".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|\\s)(focus|focus-visible|focus-within):ring-2(\\s|$)/',
|
||||
'The focus ring is ring-3 ring-ring/50 on focus-visible (fields: focus-within on a frame the same way). ring-2 is only for selection rings. See DESIGN.md "Focus ring".',
|
||||
),
|
||||
{
|
||||
selector:
|
||||
'ImportDeclaration[source.value="lucide-react"] > ImportSpecifier[imported.name=/^(Loader|Loader2|LoaderCircle)$/]',
|
||||
message:
|
||||
'A loader is <Spinner> (ui/spinner) or <LoadingState>, never a lucide loader icon. See DESIGN.md "Spinner and Skeleton".',
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {{ selector: string, message: string }[]} */
|
||||
export const pageSelectors = [
|
||||
...inStrings(
|
||||
cls('transition-all'),
|
||||
'Transition only the property that changes: transition-colors by default, transition-transform for chevrons, transition-shadow for a ring. See DESIGN.md "Motion".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|\\s)hover:scale-(?!x-|y-)/',
|
||||
'Hover is a fill, border or text-colour change, never a scale. See DESIGN.md "Motion".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|[\\s:])bg-primary\\u002f10(\\s|$)/',
|
||||
'The brand soft fill is bg-secondary text-secondary-foreground, not bg-primary/10. See DESIGN.md "Colour tokens".',
|
||||
),
|
||||
{
|
||||
selector:
|
||||
'JSXOpeningElement[name.name="input"] > JSXAttribute[name.name="type"][value.value="checkbox"]',
|
||||
message:
|
||||
'A checkbox is <Checkbox> (ui/checkbox); native boxes take the OS accent and ignore dark mode. See DESIGN.md "Checkbox".',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'JSXOpeningElement[name.name="Button"] > JSXAttribute[name.name="title"]',
|
||||
message:
|
||||
'An icon-only button is <IconButton label> (its tooltip is the label); a labelled Button needs no title. See DESIGN.md "Tooltip and IconButton".',
|
||||
},
|
||||
...[
|
||||
'JSXOpeningElement[name.name="SheetContent"] > JSXAttribute[name.name="className"] Literal[value=/(^|[\\s:])(max-)?w-/]',
|
||||
'JSXOpeningElement[name.name="SheetContent"] > JSXAttribute[name.name="className"] TemplateElement[value.raw=/(^|[\\s:])(max-)?w-/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'A drawer\'s width comes from SheetContent size (default 384px, detail 576px, wide 800px), never a w-* or max-w-* class. See DESIGN.md "Modal, not Dialog".',
|
||||
})),
|
||||
{
|
||||
selector: 'JSXOpeningElement[name.name="table"]',
|
||||
message:
|
||||
'Every table is ui/table (Table, TableHead, TableRow…), never a raw <table>. See DESIGN.md "Table".',
|
||||
},
|
||||
...[
|
||||
'JSXOpeningElement[name.name="a"] > JSXAttribute[name.name="className"] Literal[value=/(^|\\s)(underline|text-primary)(\\s|$)/]',
|
||||
'JSXOpeningElement[name.name="a"] > JSXAttribute[name.name="className"] TemplateElement[value.raw=/(^|\\s)(underline|text-primary)(\\s|$)/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'A link is <Button variant="link" size="inline" asChild> around the <a>, never a raw <a> styled as a link. See DESIGN.md "Button".',
|
||||
})),
|
||||
];
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Summarise @shadcn/lint warnings from ESLint JSON output.
|
||||
* Summarise design-lint violations from ESLint JSON output: the @shadcn/lint
|
||||
* rules and the DESIGN.md selectors under no-restricted-syntax
|
||||
* (eslint/design-rules.js, eslint/card-surfaces.js, viewport heights).
|
||||
*
|
||||
* npm run lint:design rules × counts, then the 25 worst files
|
||||
* npm run lint:design -- --rule no-raw-colors files for one rule
|
||||
@@ -26,10 +28,12 @@ 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;
|
||||
const design =
|
||||
m.ruleId?.startsWith('shadcn/') || m.ruleId === 'no-restricted-syntax';
|
||||
if (!design) continue;
|
||||
rows.push({
|
||||
file: rel,
|
||||
rule: m.ruleId.slice(7),
|
||||
rule: m.ruleId.replace(/^shadcn\//, ''),
|
||||
line: m.line,
|
||||
msg: m.message,
|
||||
});
|
||||
@@ -47,7 +51,7 @@ if (onlyFile) {
|
||||
const mine = rows.filter(
|
||||
(r) => r.file === onlyFile || r.file.endsWith(onlyFile),
|
||||
);
|
||||
console.log(`${mine.length} design warnings in ${onlyFile}\n`);
|
||||
console.log(`${mine.length} design violations 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`,
|
||||
@@ -55,14 +59,14 @@ if (onlyFile) {
|
||||
}
|
||||
} else if (onlyRule) {
|
||||
const mine = rows.filter((r) => r.rule === onlyRule);
|
||||
console.log(`${mine.length} warnings for shadcn/${onlyRule}\n`);
|
||||
console.log(`${mine.length} violations of ${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`,
|
||||
`${rows.length} design violations 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}`);
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import ConversationTile from './conversation/ConversationTile';
|
||||
import { useMediaQuery } from './hooks';
|
||||
import useTokenAuth from './hooks/useTokenAuth';
|
||||
import { cn } from './lib/utils';
|
||||
import { cn, overlayScrim } from './lib/utils';
|
||||
import ConfirmationModal from './modals/ConfirmationModal';
|
||||
import JWTModal from './modals/JWTModal';
|
||||
import SearchConversationsModal from './modals/SearchConversationsModal';
|
||||
@@ -402,7 +402,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
<>
|
||||
{isMobile && navOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 bg-black opacity-50 transition-opacity duration-300"
|
||||
className={cn(
|
||||
'animate-in fade-in-0 fixed inset-0 z-20',
|
||||
overlayScrim,
|
||||
)}
|
||||
onClick={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock('react-redux', () => ({ useSelector: () => 'token' }));
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getAgent: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'a1',
|
||||
name: 'Carrier FAQ',
|
||||
last_used_at: null,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../settings/Analytics', () => ({
|
||||
default: () => <div data-testid="analytics" />,
|
||||
}));
|
||||
vi.mock('../settings/Logs', () => ({
|
||||
default: () => <div data-testid="logs" />,
|
||||
}));
|
||||
vi.mock('./components/GuardrailEvents', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPageHeader', () => ({
|
||||
CurrentSectionHeader: () => <h1>Logs</h1>,
|
||||
}));
|
||||
vi.mock('../navigation/SectionPills', () => ({ default: () => null }));
|
||||
|
||||
import AgentLogs from './AgentLogs';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('AgentLogs', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
// A3: the same toolbar as Overview and Schedules, the agent in its byline.
|
||||
it('names the agent in the shared agent toolbar', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/manage/logs/a1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/manage/logs/:agentId"
|
||||
element={<AgentLogs />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
|
||||
expect(toolbar.textContent).toContain('Carrier FAQ');
|
||||
expect(toolbar.textContent).toContain('agents.logs.noUsageHistory');
|
||||
expect(toolbar.querySelector('[data-slot="separator"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,8 @@ import userService from '../api/services/userService';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import Analytics from '../settings/Analytics';
|
||||
import Logs from '../settings/Logs';
|
||||
import { formatDateTime } from '../utils/dateTimeUtils';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar';
|
||||
import GuardrailEvents from './components/GuardrailEvents';
|
||||
import { Agent } from './types';
|
||||
|
||||
@@ -39,26 +39,16 @@ export default function AgentLogs() {
|
||||
}, [agentId, token]);
|
||||
|
||||
return (
|
||||
<SectionShell pills>
|
||||
<div className="flex flex-col gap-3">
|
||||
{agent && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground">{agent.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{agent.last_used_at
|
||||
? t('agents.logs.lastUsedAt') +
|
||||
' ' +
|
||||
formatDateTime(agent.last_used_at)
|
||||
: t('agents.logs.noUsageHistory')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SectionShell>
|
||||
{agent && (
|
||||
<AgentPageToolbar
|
||||
name={agent.name}
|
||||
meta={<LastUsedMeta lastUsedAt={agent.last_used_at} />}
|
||||
/>
|
||||
)}
|
||||
{agentId && (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<Analytics agentId={agentId} />
|
||||
</div>
|
||||
<Analytics agentId={agentId} />
|
||||
<GuardrailEvents agentId={agentId} />
|
||||
<div className="mt-8">
|
||||
<Logs
|
||||
|
||||
@@ -39,10 +39,11 @@ type AgentPageHeaderProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared chrome for the agent sub-pages (Overview/Edit, Logs, Schedules).
|
||||
*
|
||||
* Top: shadcn Breadcrumb (`Agents > <agent name> > <current page>`).
|
||||
* Bottom: underline-style sub-nav linking between the agent's sub-pages.
|
||||
* The workflow builder's toolbar chrome: a Breadcrumb (`Agents > <agent
|
||||
* name> > <current page>`) and underline tab links to the agent's Overview,
|
||||
* Logs and Schedules. The builder is full-screen with no sidebar, so it needs
|
||||
* its own way between them. Section pages use `components/AgentPageToolbar`
|
||||
* and the sidebar (or `SectionPills` on a phone) instead.
|
||||
*/
|
||||
export default function AgentPageHeader({
|
||||
agentId,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
import MessageInput from '../components/MessageInput';
|
||||
import ConversationMessages from '../conversation/ConversationMessages';
|
||||
import { Query } from '../conversation/conversationModels';
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
fetchPreviewAnswer,
|
||||
handlePreviewAbort,
|
||||
resendQuery,
|
||||
resetPreview,
|
||||
selectPreviewQueries,
|
||||
selectPreviewStatus,
|
||||
} from './agentPreviewSlice';
|
||||
@@ -96,12 +96,13 @@ export default function AgentPreview() {
|
||||
}
|
||||
};
|
||||
|
||||
// Closing the drawer unmounts the preview: stop a stream in flight, but
|
||||
// keep the chat. The page that hosts the drawer decides when it starts
|
||||
// over (New chat, a save, leaving the page).
|
||||
useEffect(() => {
|
||||
dispatch(resetPreview());
|
||||
return () => {
|
||||
if (fetchStream.current) fetchStream.current.abort();
|
||||
handlePreviewAbort();
|
||||
dispatch(resetPreview());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -115,13 +116,12 @@ export default function AgentPreview() {
|
||||
<div className="relative h-full w-full">
|
||||
<div className="scrollbar-overlay absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:w-full! [&>div>div]:max-w-none!">
|
||||
{queries.length === 0 ? (
|
||||
<section className="flex h-full flex-col items-center justify-center">
|
||||
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
|
||||
<MessageSquare className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t('agents.preview.emptyTitle')}
|
||||
</p>
|
||||
<section className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
size="sm"
|
||||
illustration="none"
|
||||
title={t('agents.preview.emptyTitle')}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<ConversationMessages
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
|
||||
|
||||
@@ -16,16 +16,26 @@ const mockState = {
|
||||
prompts: [],
|
||||
agentFolders: [],
|
||||
},
|
||||
agentPreview: { queries: [], status: 'idle' },
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const jsonResponse = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
return {
|
||||
jsonResponse,
|
||||
dispatch: vi.fn(),
|
||||
getAgent: vi.fn(() => jsonResponse({})),
|
||||
createAgent: vi.fn(() => jsonResponse({ message: 'Name is taken' }, false)),
|
||||
};
|
||||
});
|
||||
const { jsonResponse } = mocks;
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) => selector(mockState),
|
||||
useDispatch: () => vi.fn(),
|
||||
useDispatch: () => mocks.dispatch,
|
||||
}));
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getUserTools: () =>
|
||||
@@ -40,8 +50,8 @@ vi.mock('../api/services/userService', () => ({
|
||||
],
|
||||
}),
|
||||
getAgentFolders: () => jsonResponse({ folders: [] }),
|
||||
getAgent: () => jsonResponse({}),
|
||||
createAgent: () => jsonResponse({ message: 'Name is taken' }, false),
|
||||
getAgent: mocks.getAgent,
|
||||
createAgent: mocks.createAgent,
|
||||
updateAgent: () => jsonResponse({}),
|
||||
deleteAgent: () => jsonResponse({}),
|
||||
createPrompt: () => jsonResponse({}),
|
||||
@@ -95,9 +105,22 @@ 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/SectionPills', () => ({
|
||||
default: () => <div data-testid="section-pills" />,
|
||||
}));
|
||||
vi.mock('../navigation/SectionPageHeader', () => ({
|
||||
CurrentSectionHeader: () => null,
|
||||
CurrentSectionHeader: ({
|
||||
title,
|
||||
titleAction,
|
||||
}: {
|
||||
title?: React.ReactNode;
|
||||
titleAction?: React.ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
{title ? <h1>{title}</h1> : null}
|
||||
{titleAction}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../components/FileUpload', () => ({ FileUpload: () => null }));
|
||||
vi.mock('../components/SourcesPopoverFooter', () => ({ default: () => null }));
|
||||
@@ -132,6 +155,9 @@ describe('NewAgent form', () => {
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.getAgent.mockClear();
|
||||
mocks.createAgent.mockClear();
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
@@ -182,33 +208,176 @@ describe('NewAgent form', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('titles each form panel with a SectionHeader spaced by the panel gap', async () => {
|
||||
// A1a: three grouped sections instead of six one-field panels.
|
||||
it('groups the form into Basics, Knowledge and behaviour, and Model', async () => {
|
||||
await render();
|
||||
const titles = Array.from(
|
||||
container.querySelectorAll('[data-slot="section-header"] > h2'),
|
||||
).map((h) => h.textContent);
|
||||
expect(titles).toEqual(
|
||||
expect(titles).toEqual([
|
||||
'agents.form.sections.basics',
|
||||
'agents.form.sections.knowledge',
|
||||
'agents.form.sections.model',
|
||||
]);
|
||||
const basics = Array.from(
|
||||
container.querySelectorAll('[data-slot="section-header"]'),
|
||||
).find((h) => h.textContent === 'agents.form.sections.basics')!;
|
||||
expect(basics.parentElement!.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
|
||||
);
|
||||
});
|
||||
|
||||
// On a phone the avatar sits beside Name and Description spans the row;
|
||||
// from sm the avatar spans both rows beside the fields.
|
||||
it('lays Basics out as avatar beside Name, Description full width on a phone', async () => {
|
||||
await render();
|
||||
const name = container.querySelector(
|
||||
'input[placeholder="agents.form.placeholders.agentName"]',
|
||||
)!;
|
||||
const grid = name.closest('.grid')!;
|
||||
expect(grid.className).toContain('grid-cols-[auto_1fr]');
|
||||
const description = container
|
||||
.querySelector(
|
||||
'textarea[placeholder="agents.form.placeholders.describeAgent"]',
|
||||
)!
|
||||
.closest('[data-slot="form-field"]')!;
|
||||
expect(description.className).toContain('col-span-2');
|
||||
expect(description.className).toContain('sm:col-start-2');
|
||||
});
|
||||
|
||||
it('labels every picker with a floating label', async () => {
|
||||
await render();
|
||||
const labels = Array.from(
|
||||
container.querySelectorAll('[data-slot="form-field-label"]'),
|
||||
).map((l) => l.textContent);
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining([
|
||||
'agents.form.sections.meta',
|
||||
'agents.form.sections.source',
|
||||
'agents.form.labels.name',
|
||||
'agents.form.labels.description',
|
||||
'agents.form.labels.sources',
|
||||
'agents.form.sections.tools',
|
||||
'agents.form.sections.agentType',
|
||||
'agents.form.sections.models',
|
||||
]),
|
||||
);
|
||||
const meta = Array.from(
|
||||
container.querySelectorAll('[data-slot="section-header"]'),
|
||||
).find((h) => h.textContent === 'agents.form.sections.meta')!;
|
||||
expect(meta.parentElement!.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
|
||||
});
|
||||
|
||||
it('puts Sources beside Tools in a two-up field grid', async () => {
|
||||
await render();
|
||||
const [sources, tools] = Array.from(
|
||||
container.querySelectorAll('[data-testid="picker"] > button'),
|
||||
);
|
||||
expect((meta.nextElementSibling as HTMLElement).className).not.toContain(
|
||||
'mt-5',
|
||||
const grid = sources.closest('.grid')!;
|
||||
expect(grid.className).toContain('sm:grid-cols-2');
|
||||
expect(grid.contains(tools)).toBe(true);
|
||||
});
|
||||
|
||||
it('titles the new-agent page and puts its actions in the agent toolbar', async () => {
|
||||
await render();
|
||||
expect(container.querySelector('h1')?.textContent).toBe('agents.newAgent');
|
||||
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
|
||||
expect(toolbar.textContent).toContain('agents.form.byline.new');
|
||||
expect(toolbar.contains(buttonByText('agents.form.buttons.publish'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Decision 64: the Advanced header is a section-toggle with a leading
|
||||
// lucide chevron; the panel draws the focus ring.
|
||||
// Before publishing, Preview can only say "Publish to preview", so it is a
|
||||
// ⋯ item beside the title rather than a toolbar button.
|
||||
it('drops the New agent title once the agent is saved', async () => {
|
||||
await render();
|
||||
mocks.createAgent.mockImplementationOnce(() =>
|
||||
jsonResponse({ id: 'agent-1' }),
|
||||
);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.buttons.saveDraft').click(),
|
||||
);
|
||||
expect(container.querySelector('h1')?.textContent).not.toBe(
|
||||
'agents.newAgent',
|
||||
);
|
||||
});
|
||||
|
||||
// The preview talks to the saved agent; Redux must hold that snapshot, not
|
||||
// the unsaved form, or the preview picks up an unsaved model.
|
||||
it('keeps the preview on the saved agent while editing a published one', async () => {
|
||||
mocks.getAgent.mockImplementationOnce(() =>
|
||||
jsonResponse({
|
||||
id: 'agent-1',
|
||||
name: 'Saved name',
|
||||
description: 'Saved description',
|
||||
status: 'published',
|
||||
agent_type: 'classic',
|
||||
prompt_id: 'default',
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/edit/agent-1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/edit/:agentId"
|
||||
element={<NewAgent mode="edit" />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const name = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="agents.form.placeholders.agentName"]',
|
||||
)!;
|
||||
await act(async () => setNativeValue(name, 'Unsaved name'));
|
||||
|
||||
const pushed = mocks.dispatch.mock.calls
|
||||
.map(([action]) => action)
|
||||
.filter((a) => a?.type === 'preference/setSelectedAgent' && a.payload);
|
||||
expect(pushed.length).toBeGreaterThan(0);
|
||||
for (const action of pushed) {
|
||||
expect(action.payload.name).toBe('Saved name');
|
||||
}
|
||||
});
|
||||
|
||||
it('offers Preview from the title-row menu until the agent is published', async () => {
|
||||
await render();
|
||||
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
|
||||
expect(
|
||||
Array.from(toolbar.querySelectorAll('button')).some((b) =>
|
||||
b.textContent?.includes('agents.form.sections.preview'),
|
||||
),
|
||||
).toBe(false);
|
||||
const menu = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.form.buttons.moreActions"]',
|
||||
)!;
|
||||
expect(menu.getAttribute('data-size')).toBe('icon');
|
||||
// Beside the page title, not in the toolbar row.
|
||||
expect(menu.closest('[data-slot="page-toolbar"]')).toBeNull();
|
||||
expect(menu.parentElement!.querySelector('h1')).not.toBeNull();
|
||||
await act(async () => {
|
||||
menu.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
const preview = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent === 'agents.form.sections.preview')!;
|
||||
await act(async () => preview.click());
|
||||
const sheet = document.querySelector('[data-slot="sheet-content"]')!;
|
||||
expect(sheet.textContent).toContain('agents.form.preview.publishTitle');
|
||||
});
|
||||
|
||||
it('stretches the main button on a phone', async () => {
|
||||
await render();
|
||||
const publish = buttonByText('agents.form.buttons.publish');
|
||||
expect(publish.className).toContain('flex-1');
|
||||
expect(publish.className).toContain('sm:flex-none');
|
||||
});
|
||||
|
||||
// The sidebar (and the agent card's menu) already switch between an
|
||||
// agent's pages, so the pages carry no pill row.
|
||||
it('has no destination pills', async () => {
|
||||
await render();
|
||||
expect(container.querySelector('[data-testid="section-pills"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Advanced header as a section-toggle', async () => {
|
||||
await render();
|
||||
const toggle = buttonByText('agents.form.sections.advanced');
|
||||
@@ -265,11 +434,14 @@ describe('NewAgent form', () => {
|
||||
|
||||
it('renders Save draft and Publish as pill button variants', async () => {
|
||||
await render();
|
||||
// A4: one purple button per page; every header button is field pill.
|
||||
const draft = buttonByText('agents.form.buttons.saveDraft');
|
||||
expect(draft.getAttribute('data-variant')).toBe('outline-primary');
|
||||
expect(draft.getAttribute('data-variant')).toBe('outline');
|
||||
expect(draft.getAttribute('data-size')).toBe('field');
|
||||
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-size')).toBe('field');
|
||||
expect(publish.getAttribute('data-shape')).toBe('pill');
|
||||
expect(publish.disabled).toBe(true);
|
||||
});
|
||||
@@ -283,6 +455,7 @@ describe('NewAgent form', () => {
|
||||
await act(async () => setNativeValue(name, 'Support bot'));
|
||||
const cancel = buttonByText('agents.form.buttons.cancel');
|
||||
expect(cancel.getAttribute('data-variant')).toBe('ghost');
|
||||
expect(cancel.getAttribute('data-size')).toBe('field');
|
||||
expect(cancel.getAttribute('data-shape')).toBe('pill');
|
||||
});
|
||||
|
||||
@@ -360,22 +533,4 @@ describe('NewAgent form', () => {
|
||||
expect(label.className).toContain('bg-background');
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the preview area as a subtle panel', async () => {
|
||||
await render();
|
||||
const hint = Array.from(container.querySelectorAll('p')).find(
|
||||
(p) => p.textContent === 'agents.form.preview.publishedPreview',
|
||||
)!;
|
||||
const panel = hint.closest<HTMLElement>('[data-slot="card"]')!;
|
||||
expect(panel.dataset.variant).toBe('subtle');
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
+633
-521
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,67 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import AgentPageToolbar from './AgentPageToolbar';
|
||||
|
||||
describe('AgentPageToolbar', () => {
|
||||
it('puts the agent byline beside the actions and closes with a rule', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentPageToolbar
|
||||
name="Carrier onboarding FAQ"
|
||||
status={<span data-testid="status">Published</span>}
|
||||
meta="Last used at 21 Sep"
|
||||
actions={<button type="button">Save</button>}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="page-toolbar"');
|
||||
expect(html).toContain('Carrier onboarding FAQ');
|
||||
expect(html).toContain('data-testid="status"');
|
||||
expect(html).toContain('Last used at 21 Sep');
|
||||
expect(html).toContain('<button type="button">Save</button>');
|
||||
expect(html).toContain('data-slot="separator"');
|
||||
});
|
||||
|
||||
// On a phone, a status badge fills the first line: the meta takes a line
|
||||
// of its own there, without a dangling separator.
|
||||
it('moves the meta to its own line on a phone when there is a status', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentPageToolbar
|
||||
name="FAQ"
|
||||
status={<span>Published</span>}
|
||||
meta="Last used"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<span aria-hidden="true" class="hidden sm:inline">·</span>',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<span class="basis-full sm:basis-auto">Last used</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the meta on the name line when there is no status', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentPageToolbar name="FAQ" meta="Last used" />,
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<span aria-hidden="true">·</span><span>Last used</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the row at field height when a tab has no actions', () => {
|
||||
const html = renderToStaticMarkup(<AgentPageToolbar name="FAQ" />);
|
||||
// A 38px spacer stands in for the actions, so the rule lands in the same
|
||||
// place on every agent tab.
|
||||
expect(html).toContain('h-9.5');
|
||||
});
|
||||
|
||||
it('renders notices between the row and the rule', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentPageToolbar name="FAQ">
|
||||
<p>Save failed</p>
|
||||
</AgentPageToolbar>,
|
||||
);
|
||||
expect(html.indexOf('Save failed')).toBeLessThan(
|
||||
html.indexOf('data-slot="separator"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PageToolbar from '../../components/PageToolbar';
|
||||
import { formatDateTime } from '../../utils/dateTimeUtils';
|
||||
|
||||
type AgentPageToolbarProps = {
|
||||
/** The agent's name, first in the byline. */
|
||||
name?: string;
|
||||
/** A status Badge beside the name (Overview's Published / Draft). */
|
||||
status?: ReactNode;
|
||||
/** The tab's context after the name, e.g. "Last used at …". */
|
||||
meta?: ReactNode;
|
||||
/** The tab's buttons, on the right of the row. */
|
||||
actions?: ReactNode;
|
||||
/** A line of its own in place of the name byline (the new-agent form). */
|
||||
intro?: ReactNode;
|
||||
/** Notices under the row, above the rule (a failed save). */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The block under an agent tab's title (Overview, Logs, Schedules): a muted
|
||||
* byline naming the agent, the tab's actions on the right, then a rule. The
|
||||
* row keeps the field height when a tab has no actions, so the rule lands in
|
||||
* the same place on every tab and nothing jumps when switching.
|
||||
*
|
||||
* Args:
|
||||
* name: The agent's name.
|
||||
* status: A Badge shown beside the name.
|
||||
* meta: Tab context shown after the name, separated by a middle dot.
|
||||
* actions: The tab's buttons.
|
||||
* intro: Replaces the name byline.
|
||||
* children: Notices between the row and the rule.
|
||||
*/
|
||||
export default function AgentPageToolbar({
|
||||
name,
|
||||
status,
|
||||
meta,
|
||||
actions,
|
||||
intro,
|
||||
children,
|
||||
}: AgentPageToolbarProps) {
|
||||
const byline = intro ?? (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{name ? <span className="wrap-break-word">{name}</span> : null}
|
||||
{status}
|
||||
{meta ? (
|
||||
status ? (
|
||||
// With a status badge the first line is full on a phone: the meta
|
||||
// takes its own line there, so no separator is left hanging.
|
||||
<>
|
||||
<span aria-hidden className="hidden sm:inline">
|
||||
·
|
||||
</span>
|
||||
<span className="basis-full sm:basis-auto">{meta}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{meta}</span>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageToolbar
|
||||
intro={byline}
|
||||
action={actions ?? <div aria-hidden className="h-9.5" />}
|
||||
divider
|
||||
>
|
||||
{children}
|
||||
</PageToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent tabs' usage meta: "Last used at …", or "no usage history".
|
||||
*
|
||||
* Args:
|
||||
* lastUsedAt: The agent's `last_used_at` timestamp.
|
||||
*/
|
||||
export function LastUsedMeta({ lastUsedAt }: { lastUsedAt?: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{lastUsedAt
|
||||
? `${t('agents.logs.lastUsedAt')} ${formatDateTime(lastUsedAt)}`
|
||||
: t('agents.logs.noUsageHistory')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { act } from 'react';
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const dispatch = vi.fn();
|
||||
vi.mock('react-redux', () => ({ useDispatch: () => dispatch }));
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import AgentPreviewSheet from './AgentPreviewSheet';
|
||||
@@ -90,6 +93,22 @@ describe('AgentPreviewSheet', () => {
|
||||
expect(badge.textContent).toBe('agents.schedules.status.running');
|
||||
});
|
||||
|
||||
// The toast stack moves bottom-left while any agent preview is open.
|
||||
it('reports whether it is open, and closed when it goes away', async () => {
|
||||
dispatch.mockClear();
|
||||
await render();
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'workflowPreview/setPreviewOpen',
|
||||
payload: true,
|
||||
});
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
expect(dispatch).toHaveBeenLastCalledWith({
|
||||
type: 'workflowPreview/setPreviewOpen',
|
||||
payload: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the body under a separator on the sheet surface', async () => {
|
||||
await render();
|
||||
const body = document.querySelector('[data-testid="body"]')!;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
import { setPreviewOpen } from '../workflow/workflowPreviewSlice';
|
||||
|
||||
type AgentPreviewSheetProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -48,12 +51,21 @@ export default function AgentPreviewSheet({
|
||||
children,
|
||||
}: AgentPreviewSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// App moves the toast stack bottom-left while a preview drawer is open, so
|
||||
// it doesn't sit over the chat. The flag lives in the workflow preview
|
||||
// slice for both agent types.
|
||||
useEffect(() => {
|
||||
dispatch(setPreviewOpen(open));
|
||||
return () => {
|
||||
dispatch(setPreviewOpen(false));
|
||||
};
|
||||
}, [dispatch, open]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
|
||||
>
|
||||
<SheetContent side="right" size="wide" className="p-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* pr-12 keeps the header clear of the close X at top-2 right-2. */}
|
||||
<div className="flex items-center gap-3 px-6 pt-6 pr-12 pb-4">
|
||||
|
||||
@@ -78,6 +78,14 @@ describe('GuardrailEvents tones', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
// A3: the section lines up with Analytics and the log table on Logs.
|
||||
it('is not indented against the sections around it', () => {
|
||||
const root = document.querySelector<HTMLElement>(
|
||||
'[data-testid="guardrail-events"]',
|
||||
)!;
|
||||
expect(root.className).not.toMatch(/(^|\s)px-/);
|
||||
});
|
||||
|
||||
it('colours outcome badges by meaning', () => {
|
||||
const variants = Array.from(
|
||||
container.querySelectorAll(
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function GuardrailEvents({ agentId }: Props) {
|
||||
const tableHeadingId = React.useId();
|
||||
|
||||
return (
|
||||
<div className="mt-8 px-4" data-testid="guardrail-events">
|
||||
<div className="mt-8" data-testid="guardrail-events">
|
||||
<SectionHeader
|
||||
title={t('agents.guardrailEvents.heading')}
|
||||
description={t('agents.guardrailEvents.description')}
|
||||
|
||||
@@ -195,6 +195,28 @@ describe('GuardrailsSection', () => {
|
||||
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
|
||||
});
|
||||
|
||||
// Decision 74: the panel spaces header and body with gap-5 like the other
|
||||
// form sections, and the body stacks its rows with a gap-5 flex column
|
||||
// rather than per-child margins.
|
||||
it('spaces the panel and its body with gap-5, not margins', async () => {
|
||||
await render({ disabled: true });
|
||||
const panel = q('guardrails-section')!;
|
||||
expect(panel.className.split(' ')).toContain('gap-5');
|
||||
expect(panel.className.split(' ')).not.toContain('gap-3');
|
||||
const body = panel.children[1] as HTMLElement;
|
||||
expect(body.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
|
||||
);
|
||||
// Notices, Enable row, mode, checks, block message, fail-open, timeout.
|
||||
expect(body.children.length).toBeGreaterThanOrEqual(9);
|
||||
for (const child of Array.from(body.children)) {
|
||||
expect(child.getAttribute('class') ?? '').not.toMatch(/(^|\s)mt-\d/);
|
||||
}
|
||||
// The checks list keeps its own tighter gap.
|
||||
const checks = q('guardrail-orphan-gone')!.parentElement!;
|
||||
expect(checks.className.split(' ')).toContain('gap-3');
|
||||
});
|
||||
|
||||
it('renders the header pills as badges in their status roles', async () => {
|
||||
await render();
|
||||
expect(q('guardrails-active-badge')?.dataset.slot).toBe('badge');
|
||||
|
||||
@@ -216,7 +216,12 @@ export default function GuardrailsSection({
|
||||
const instanceDisabled = catalog !== null && catalog.enabled === false;
|
||||
|
||||
return (
|
||||
<Card variant="subtle" padding="lg" data-testid="guardrails-section">
|
||||
<Card
|
||||
variant="subtle"
|
||||
padding="lg"
|
||||
className="gap-5"
|
||||
data-testid="guardrails-section"
|
||||
>
|
||||
<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. */}
|
||||
@@ -262,7 +267,7 @@ export default function GuardrailsSection({
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div>
|
||||
<div className="flex flex-col gap-5">
|
||||
{loadError && (
|
||||
<EmptyState
|
||||
tone="destructive"
|
||||
@@ -283,22 +288,14 @@ export default function GuardrailsSection({
|
||||
)}
|
||||
|
||||
{disabled && disabledNotice && (
|
||||
<Alert
|
||||
role="status"
|
||||
className="mt-3"
|
||||
data-testid="guardrails-read-only"
|
||||
>
|
||||
<Alert role="status" data-testid="guardrails-read-only">
|
||||
<Lock aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>{disabledNotice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{instanceDisabled && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
className="mt-3"
|
||||
data-testid="guardrails-instance-disabled"
|
||||
>
|
||||
<Alert variant="warning" data-testid="guardrails-instance-disabled">
|
||||
<TriangleAlert aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>
|
||||
{t('agents.form.guardrails.instanceDisabled')}
|
||||
@@ -307,7 +304,7 @@ export default function GuardrailsSection({
|
||||
)}
|
||||
|
||||
{floorControls.size > 0 && (
|
||||
<Alert variant="info" role="status" className="mt-3">
|
||||
<Alert variant="info" role="status">
|
||||
<Info aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>
|
||||
{t('agents.form.guardrails.floorNotice', {
|
||||
@@ -318,7 +315,6 @@ export default function GuardrailsSection({
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
className="mt-4"
|
||||
label={t('agents.form.guardrails.enable')}
|
||||
description={t('agents.form.guardrails.enableDescription')}
|
||||
htmlFor={enabledId}
|
||||
@@ -334,46 +330,42 @@ export default function GuardrailsSection({
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<div className="mt-5">
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.form.guardrails.mode')}
|
||||
hint={
|
||||
config.mode === 'monitor_only'
|
||||
? t('agents.form.guardrails.monitorHint')
|
||||
: undefined
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.form.guardrails.mode')}
|
||||
hint={
|
||||
config.mode === 'monitor_only'
|
||||
? t('agents.form.guardrails.monitorHint')
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Select
|
||||
value={config.mode}
|
||||
onValueChange={(mode) =>
|
||||
patch({ mode: mode as GuardrailsConfig['mode'] })
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Select
|
||||
value={config.mode}
|
||||
onValueChange={(mode) =>
|
||||
patch({ mode: mode as GuardrailsConfig['mode'] })
|
||||
}
|
||||
disabled={disabled}
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
size="field"
|
||||
shape="pill"
|
||||
data-testid="guardrails-mode"
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
size="field"
|
||||
shape="pill"
|
||||
data-testid="guardrails-mode"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catalog?.modes ?? Object.keys(MODE_KEYS)).map(
|
||||
(mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{t(MODE_KEYS[mode] ?? mode)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catalog?.modes ?? Object.keys(MODE_KEYS)).map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{t(MODE_KEYS[mode] ?? mode)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<div className="mt-5">
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-medium">
|
||||
{t('agents.form.guardrails.checks')}
|
||||
</p>
|
||||
@@ -425,7 +417,6 @@ export default function GuardrailsSection({
|
||||
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
className="mt-6"
|
||||
label={t('agents.form.guardrails.blockMessage')}
|
||||
hint={t('agents.form.guardrails.blockMessageDescription')}
|
||||
disabled={disabled}
|
||||
@@ -441,7 +432,6 @@ export default function GuardrailsSection({
|
||||
</FormField>
|
||||
|
||||
<SettingRow
|
||||
className="mt-6"
|
||||
label={t('agents.form.guardrails.failOpen')}
|
||||
description={t('agents.form.guardrails.failOpenDescription')}
|
||||
htmlFor={failOpenId}
|
||||
@@ -457,7 +447,6 @@ export default function GuardrailsSection({
|
||||
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
className="mt-5"
|
||||
label={t('agents.form.guardrails.timeout')}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -81,7 +81,13 @@ describe('RunDetailDrawer', () => {
|
||||
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
|
||||
);
|
||||
const pre = content()!.querySelector('pre')!;
|
||||
const card = pre.parentElement!;
|
||||
// The scroll cap sits on an inner scroller inside the Card's padding,
|
||||
// so the scrollbar stays clear of the rounded corners.
|
||||
const scroller = pre.parentElement!;
|
||||
expect(scroller.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['scrollbar-overlay', 'overflow-y-auto']),
|
||||
);
|
||||
const card = scroller.parentElement!;
|
||||
expect(card.getAttribute('data-slot')).toBe('card');
|
||||
expect(card.getAttribute('data-variant')).toBe('filled');
|
||||
expect(card.className.split(' ')).toContain('bg-muted');
|
||||
|
||||
@@ -29,11 +29,7 @@ export default function RunDetailDrawer({
|
||||
if (!run) return null;
|
||||
return (
|
||||
<Sheet open onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="sm:max-w-xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<SheetContent side="right" size="detail" aria-describedby={undefined}>
|
||||
{/* The sheet owns no padding of its own; the body sets it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 p-6">
|
||||
<SheetTitle>{t('agents.schedules.runDetails.title')}</SheetTitle>
|
||||
@@ -78,14 +74,12 @@ export default function RunDetailDrawer({
|
||||
: t('agents.schedules.runDetails.error')
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
variant="filled"
|
||||
padding="sm"
|
||||
className="max-h-48 overflow-y-auto"
|
||||
>
|
||||
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
|
||||
{run.error}
|
||||
</pre>
|
||||
<Card variant="filled" padding="sm">
|
||||
<div className="scrollbar-overlay max-h-48 overflow-y-auto">
|
||||
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
|
||||
{run.error}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const runs = [
|
||||
{
|
||||
id: 'r1',
|
||||
schedule_id: 's1',
|
||||
status: 'success',
|
||||
scheduled_for: '2026-09-21T09:00:00Z',
|
||||
started_at: '2026-09-21T09:00:00Z',
|
||||
finished_at: '2026-09-21T09:00:42Z',
|
||||
trigger_source: 'cron',
|
||||
prompt_tokens: 4000,
|
||||
generated_tokens: 812,
|
||||
},
|
||||
];
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
preference: { token: 't' },
|
||||
schedules: { byAgent: {}, runsBySchedule: { s1: runs } },
|
||||
}),
|
||||
useDispatch: () => vi.fn(),
|
||||
}));
|
||||
|
||||
import RunLog from './RunLog';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('RunLog', () => {
|
||||
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('opens a run from its whole row, with duration and grouped tokens', async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(<RunLog scheduleId="s1" onSelect={onSelect} />);
|
||||
});
|
||||
const heads = Array.from(container.querySelectorAll('th')).map(
|
||||
(th) => th.textContent,
|
||||
);
|
||||
expect(heads).toContain('agents.schedules.runLog.duration');
|
||||
// The default table head, not the eyebrow.
|
||||
expect(container.querySelector('th')!.className).not.toContain('uppercase');
|
||||
const row = container.querySelector('tbody tr') as HTMLTableRowElement;
|
||||
expect(row.textContent).toContain('42.0 s');
|
||||
expect(row.textContent).toContain('4.8k');
|
||||
expect(row.querySelector('svg.lucide-chevron-right')).not.toBeNull();
|
||||
// No separate Details link: the row is the target.
|
||||
expect(row.querySelector('button')).toBeNull();
|
||||
await act(async () => row.click());
|
||||
expect(onSelect).toHaveBeenCalledWith(runs[0]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { EmptyState } from '../../components/ui/empty-state';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -12,16 +13,16 @@ import {
|
||||
TableRow,
|
||||
} from '../../components/ui/table';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
import {
|
||||
formatDurationMs,
|
||||
formatTokens,
|
||||
} from '../../settings/traces/traceUtils';
|
||||
import type { AppDispatch, RootState } from '../../store';
|
||||
import { formatDateTime } from '../../utils/dateTimeUtils';
|
||||
import type { ScheduleRun } from '../types/schedule';
|
||||
import ScheduleStatusBadge from './StatusBadge';
|
||||
import { loadRunsForSchedule, selectRunsForSchedule } from './schedulesSlice';
|
||||
|
||||
// The column heads are a label row, so they take the eyebrow recipe.
|
||||
const HEADER_CELL =
|
||||
'text-muted-foreground text-xs font-semibold tracking-wider uppercase';
|
||||
|
||||
export type RunLogProps = {
|
||||
scheduleId: string;
|
||||
onSelect?: (run: ScheduleRun) => void;
|
||||
@@ -31,7 +32,18 @@ const formatTimestamp = (value?: string | null): string => {
|
||||
return value ? formatDateTime(value) : '—';
|
||||
};
|
||||
|
||||
/** Paginated run log for a schedule (SSE updates merge via schedulesSlice). */
|
||||
/** How long a run took, or a dash while it hasn't started or finished. */
|
||||
const runDuration = (run: ScheduleRun): string => {
|
||||
if (!run.started_at || !run.finished_at) return '—';
|
||||
return formatDurationMs(
|
||||
Date.parse(run.finished_at) - Date.parse(run.started_at),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A schedule's run log (SSE updates merge via schedulesSlice). Each row opens
|
||||
* the run's details when `onSelect` is given.
|
||||
*/
|
||||
export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
@@ -47,9 +59,11 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-3 text-sm">
|
||||
{t('agents.schedules.runLog.empty')}
|
||||
</p>
|
||||
<EmptyState
|
||||
size="xs"
|
||||
illustration="none"
|
||||
title={t('agents.schedules.runLog.empty')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,24 +71,26 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
<Table minWidth="min-w-0">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader className={HEADER_CELL}>
|
||||
{t('agents.schedules.runLog.when')}
|
||||
</TableHeader>
|
||||
<TableHeader className={HEADER_CELL}>
|
||||
{t('agents.schedules.runDetails.status')}
|
||||
</TableHeader>
|
||||
<TableHeader className={HEADER_CELL}>
|
||||
{t('agents.schedules.runDetails.tokens')}
|
||||
</TableHeader>
|
||||
<TableHeader className={HEADER_CELL}>
|
||||
{t('agents.schedules.runDetails.trigger')}
|
||||
</TableHeader>
|
||||
<TableHeader className={HEADER_CELL}></TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runLog.when')}</TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runDetails.status')}</TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runLog.duration')}</TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runDetails.tokens')}</TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runDetails.trigger')}</TableHeader>
|
||||
{onSelect && (
|
||||
<TableHeader width="40px" align="center">
|
||||
<span className="sr-only">
|
||||
{t('agents.schedules.runLog.details')}
|
||||
</span>
|
||||
</TableHeader>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{runs.map((run) => (
|
||||
<TableRow key={run.id}>
|
||||
<TableRow
|
||||
key={run.id}
|
||||
onClick={onSelect ? () => onSelect(run) : undefined}
|
||||
>
|
||||
<TableCell>{formatTimestamp(run.scheduled_for)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -86,25 +102,23 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{run.prompt_tokens + run.generated_tokens}</TableCell>
|
||||
<TableCell className="tabular-nums">{runDuration(run)}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{formatTokens(run.prompt_tokens + run.generated_tokens)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(`agents.schedules.trigger.${run.trigger_source}`, {
|
||||
defaultValue: run.trigger_source,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{onSelect && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
onClick={() => onSelect(run)}
|
||||
className="-mx-2 -my-1"
|
||||
>
|
||||
{t('agents.schedules.runLog.details')}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
{onSelect && (
|
||||
<TableCell align="center">
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && 'count' in opts ? `${key}:${opts.count}` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock('./RunLog', () => ({ default: () => <div data-testid="runlog" /> }));
|
||||
|
||||
import ScheduleRow from './ScheduleRow';
|
||||
import type { Schedule } from '../types/schedule';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const base: Schedule = {
|
||||
id: 's1',
|
||||
user_id: 'u',
|
||||
agent_id: 'a1',
|
||||
trigger_type: 'recurring',
|
||||
name: 'Weekly compliance digest',
|
||||
instruction: 'Summarise expiring certificates.',
|
||||
status: 'active',
|
||||
cron: '0 9 * * 1',
|
||||
timezone: 'Europe/Berlin',
|
||||
next_run_at: '2026-09-28T07:00:00Z',
|
||||
last_run_at: '2026-09-21T07:00:00Z',
|
||||
tool_allowlist: [],
|
||||
created_via: 'ui',
|
||||
consecutive_failure_count: 0,
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('ScheduleRow', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const handlers = {
|
||||
onToggleRuns: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onSetPaused: vi.fn(),
|
||||
onRunNow: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onSelectRun: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (schedule: Schedule, expanded = false) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ScheduleRow schedule={schedule} expanded={expanded} {...handlers} />,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const buttons = () =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('button'));
|
||||
|
||||
it('is a subtle panel with one visible action and a menu', async () => {
|
||||
await render(base);
|
||||
const card = container.querySelector<HTMLElement>('[data-slot="card"]')!;
|
||||
expect(card.dataset.variant).toBe('subtle');
|
||||
const runNow = buttons().find((b) =>
|
||||
b.textContent?.includes('agents.schedules.runNow'),
|
||||
)!;
|
||||
expect(runNow.dataset.variant).toBe('outline');
|
||||
expect(runNow.dataset.size).toBe('sm');
|
||||
expect(runNow.dataset.shape).toBe('pill');
|
||||
// Edit, Pause and Delete live in the menu, not as pills.
|
||||
expect(
|
||||
buttons().some((b) => b.textContent === 'agents.schedules.edit'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
container.querySelector('[aria-label="agents.schedules.actions"]'),
|
||||
).not.toBeNull();
|
||||
await act(async () => runNow.click());
|
||||
expect(handlers.onRunNow).toHaveBeenCalledWith(base);
|
||||
});
|
||||
|
||||
it('reads its meta with icons, and shows the instruction under a name', async () => {
|
||||
await render(base);
|
||||
expect(container.textContent).toContain('Summarise expiring certificates.');
|
||||
expect(container.textContent).toContain('Europe/Berlin');
|
||||
expect(container.textContent).toContain('agents.schedules.meta.nextRun');
|
||||
expect(container.textContent).not.toContain('tz:');
|
||||
expect(container.querySelector('svg.lucide-repeat')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers Resume while paused and flags a failure streak', async () => {
|
||||
await render({ ...base, status: 'paused', consecutive_failure_count: 2 });
|
||||
expect(
|
||||
buttons().some((b) => b.textContent?.includes('agents.schedules.resume')),
|
||||
).toBe(true);
|
||||
const streak = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-slot="badge"]'),
|
||||
).find((b) => b.textContent?.includes('agents.schedules.failureStreak'))!;
|
||||
expect(streak.dataset.variant).toBe('destructive');
|
||||
expect(streak.textContent).toContain(':2');
|
||||
});
|
||||
|
||||
it('toggles its runs with a chevron and shows them flush in the card', async () => {
|
||||
await render(base, true);
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.schedules.hideRuns"]',
|
||||
)!;
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(container.querySelector('[data-testid="runlog"]')).not.toBeNull();
|
||||
await act(async () => toggle.click());
|
||||
expect(handlers.onToggleRuns).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
|
||||
it('shows a one-time task with its run time and only a menu', async () => {
|
||||
await render({
|
||||
...base,
|
||||
trigger_type: 'once',
|
||||
cron: null,
|
||||
run_at: '2026-10-01T08:00:00Z',
|
||||
});
|
||||
expect(container.textContent).toContain('agents.schedules.meta.runsAt');
|
||||
expect(
|
||||
buttons().some((b) => b.textContent?.includes('agents.schedules.runNow')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="agents.schedules.showRuns"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
History,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
Repeat,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { ActionMenu, type MenuOption } from '../../components/ui/dropdown-menu';
|
||||
import { IconButton } from '../../components/ui/icon-button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDateTime, formatRelative } from '../../utils/dateTimeUtils';
|
||||
import type { Schedule, ScheduleRun } from '../types/schedule';
|
||||
import RunLog from './RunLog';
|
||||
import ScheduleStatusBadge from './StatusBadge';
|
||||
import { formatCron } from './cronBuilder';
|
||||
|
||||
// Timezones and dates carry slashes: React escapes on render, so i18next
|
||||
// must not escape them first.
|
||||
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
|
||||
const STATUS_DOT: Record<Schedule['status'], string> = {
|
||||
active: 'bg-success',
|
||||
paused: 'bg-warning',
|
||||
completed: 'bg-muted-foreground',
|
||||
cancelled: 'bg-muted-foreground',
|
||||
};
|
||||
|
||||
export type ScheduleRowProps = {
|
||||
schedule: Schedule;
|
||||
/** Whether the run log is open under the row (recurring schedules). */
|
||||
expanded: boolean;
|
||||
onToggleRuns: (scheduleId: string) => void;
|
||||
onEdit: (schedule: Schedule) => void;
|
||||
onSetPaused: (schedule: Schedule, paused: boolean) => void;
|
||||
onRunNow: (schedule: Schedule) => void;
|
||||
onDelete: (schedule: Schedule) => void;
|
||||
onSelectRun: (run: ScheduleRun) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One schedule on an agent's Schedules page: a subtle panel with a status dot,
|
||||
* the name and status, the instruction, one meta line, the common action as
|
||||
* a button (Run now, or Resume while paused) and the rest in a menu. A
|
||||
* recurring schedule opens its run log flush under the row.
|
||||
*
|
||||
* Args:
|
||||
* schedule: The schedule to show.
|
||||
* expanded: Whether its run log is open.
|
||||
* onToggleRuns: Opens or closes the run log.
|
||||
* onEdit: Opens the schedule form.
|
||||
* onSetPaused: Pauses (true) or resumes (false) a recurring schedule.
|
||||
* onRunNow: Runs the schedule once now.
|
||||
* onDelete: Asks to delete the schedule (or cancel a one-time task).
|
||||
* onSelectRun: Opens a run's details.
|
||||
*/
|
||||
export default function ScheduleRow({
|
||||
schedule,
|
||||
expanded,
|
||||
onToggleRuns,
|
||||
onEdit,
|
||||
onSetPaused,
|
||||
onRunNow,
|
||||
onDelete,
|
||||
onSelectRun,
|
||||
}: ScheduleRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const recurring = schedule.trigger_type === 'recurring';
|
||||
const active = schedule.status === 'active';
|
||||
const paused = schedule.status === 'paused';
|
||||
const title = schedule.name || schedule.instruction.slice(0, 80);
|
||||
|
||||
const edit: MenuOption = {
|
||||
label: t('agents.schedules.edit'),
|
||||
icon: Pencil,
|
||||
onClick: () => onEdit(schedule),
|
||||
};
|
||||
const remove: MenuOption = {
|
||||
label: t('agents.schedules.delete'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
onClick: () => onDelete(schedule),
|
||||
};
|
||||
const menu: MenuOption[] = recurring
|
||||
? active
|
||||
? [
|
||||
edit,
|
||||
{
|
||||
label: t('agents.schedules.pause'),
|
||||
icon: Pause,
|
||||
onClick: () => onSetPaused(schedule, true),
|
||||
},
|
||||
remove,
|
||||
]
|
||||
: paused
|
||||
? [
|
||||
edit,
|
||||
{
|
||||
label: t('agents.schedules.runNow'),
|
||||
icon: Play,
|
||||
onClick: () => onRunNow(schedule),
|
||||
},
|
||||
remove,
|
||||
]
|
||||
: [remove]
|
||||
: active
|
||||
? [
|
||||
edit,
|
||||
{
|
||||
label: t('agents.schedules.cancelTask'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
onClick: () => onDelete(schedule),
|
||||
},
|
||||
]
|
||||
: [remove];
|
||||
|
||||
const meta: { icon: typeof Clock; text: string }[] = [];
|
||||
if (recurring) {
|
||||
meta.push({
|
||||
icon: Repeat,
|
||||
text: `${formatCron(schedule.cron, t)}, ${schedule.timezone}`,
|
||||
});
|
||||
if (active && schedule.next_run_at) {
|
||||
meta.push({
|
||||
icon: Clock,
|
||||
text: t('agents.schedules.meta.nextRun', {
|
||||
...NO_ESCAPE,
|
||||
// A past next run (the scheduler is behind) reads as a date, not
|
||||
// "… ago".
|
||||
time:
|
||||
Date.parse(schedule.next_run_at) > Date.now()
|
||||
? formatRelative(schedule.next_run_at, { future: true })
|
||||
: formatDateTime(schedule.next_run_at),
|
||||
}),
|
||||
});
|
||||
}
|
||||
} else if (schedule.run_at) {
|
||||
meta.push({
|
||||
icon: Clock,
|
||||
text: t('agents.schedules.meta.runsAt', {
|
||||
...NO_ESCAPE,
|
||||
time: `${formatDateTime(schedule.run_at)}, ${schedule.timezone}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (schedule.last_run_at) {
|
||||
meta.push({
|
||||
icon: History,
|
||||
text: t('agents.schedules.meta.lastRun', {
|
||||
...NO_ESCAPE,
|
||||
time: formatRelative(schedule.last_run_at),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="subtle" padding="none" data-schedule-id={schedule.id}>
|
||||
<div className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'mt-1.5 size-2 shrink-0 rounded-full',
|
||||
STATUS_DOT[schedule.status],
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium wrap-break-word">{title}</span>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
{schedule.consecutive_failure_count > 0 && (
|
||||
<Badge variant="destructive">
|
||||
<TriangleAlert aria-hidden />
|
||||
{t('agents.schedules.failureStreak', {
|
||||
count: schedule.consecutive_failure_count,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{schedule.name && (
|
||||
<p className="text-muted-foreground line-clamp-1 text-sm">
|
||||
{schedule.instruction}
|
||||
</p>
|
||||
)}
|
||||
{meta.length > 0 && (
|
||||
<p className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{meta.map(({ icon: Icon, text }) => (
|
||||
<span key={text} className="inline-flex items-center gap-1">
|
||||
<Icon aria-hidden className="size-3.5 shrink-0" />
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 pl-5 sm:pl-0">
|
||||
{recurring && (active || paused) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
active ? onRunNow(schedule) : onSetPaused(schedule, false)
|
||||
}
|
||||
>
|
||||
<Play />
|
||||
{active
|
||||
? t('agents.schedules.runNow')
|
||||
: t('agents.schedules.resume')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionMenu
|
||||
triggerLabel={t('agents.schedules.actions')}
|
||||
options={menu}
|
||||
/>
|
||||
{recurring && (
|
||||
<IconButton
|
||||
label={
|
||||
expanded
|
||||
? t('agents.schedules.hideRuns')
|
||||
: t('agents.schedules.showRuns')
|
||||
}
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onToggleRuns(schedule.id)}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recurring && expanded && (
|
||||
<div className="border-border border-t">
|
||||
<RunLog scheduleId={schedule.id} onSelect={onSelectRun} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && 'count' in opts ? `${key}:${opts.count}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const schedules = [
|
||||
{
|
||||
id: 's1',
|
||||
agent_id: 'a1',
|
||||
trigger_type: 'recurring',
|
||||
name: 'Weekly digest',
|
||||
instruction: 'Summarise.',
|
||||
status: 'active',
|
||||
cron: '0 9 * * 1',
|
||||
timezone: 'Europe/Berlin',
|
||||
next_run_at: '2099-09-28T07:00:00Z',
|
||||
consecutive_failure_count: 0,
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
agent_id: 'a1',
|
||||
trigger_type: 'recurring',
|
||||
name: 'Daily queue',
|
||||
instruction: 'Check.',
|
||||
status: 'paused',
|
||||
cron: '30 8 * * 1-5',
|
||||
timezone: 'Europe/Berlin',
|
||||
consecutive_failure_count: 2,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
preference: { token: 't' },
|
||||
schedules: { byAgent: { a1: schedules }, runsBySchedule: {} },
|
||||
}),
|
||||
useDispatch: () => vi.fn(() => ({ unwrap: () => Promise.resolve() })),
|
||||
}));
|
||||
vi.mock('../../api/services/userService', () => ({
|
||||
default: {
|
||||
getAgent: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ id: 'a1', name: 'Carrier FAQ', tools: [] }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../api/services/schedulesService', () => ({
|
||||
default: {
|
||||
statsForAgent: () =>
|
||||
Promise.resolve({
|
||||
days: 30,
|
||||
runs: 14,
|
||||
failed: 3,
|
||||
tokens: 48230,
|
||||
latest_failure: {
|
||||
scheduled_for: '2026-09-14T09:00:00Z',
|
||||
status: 'timeout',
|
||||
error_type: 'timeout',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('./ScheduleRow', () => ({
|
||||
default: ({ schedule }: { schedule: { id: string } }) => (
|
||||
<div data-testid={`row-${schedule.id}`} />
|
||||
),
|
||||
}));
|
||||
vi.mock('./ScheduleFormModal', () => ({ default: () => null }));
|
||||
vi.mock('./RunDetailDrawer', () => ({ default: () => null }));
|
||||
vi.mock('../../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../../navigation/SectionPageHeader', () => ({
|
||||
CurrentSectionHeader: () => <h1>Schedules</h1>,
|
||||
}));
|
||||
vi.mock('../../navigation/SectionPills', () => ({ default: () => null }));
|
||||
|
||||
import SchedulesView from './SchedulesView';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('SchedulesView', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(async () => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/manage/schedules/a1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/manage/schedules/:agentId"
|
||||
element={<SchedulesView />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('puts New schedule in the agent toolbar, with no second heading', async () => {
|
||||
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
|
||||
expect(toolbar.textContent).toContain('Carrier FAQ');
|
||||
const create = Array.from(toolbar.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes('agents.schedules.newRecurring'),
|
||||
)!;
|
||||
expect(create.dataset.variant).toBe('default');
|
||||
expect(create.dataset.size).toBe('field');
|
||||
expect(create.dataset.shape).toBe('pill');
|
||||
expect(container.querySelectorAll('h1, h2')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows the stat row as StatCards, failures in the destructive tone', async () => {
|
||||
const tiles = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-slot="card"]'),
|
||||
);
|
||||
expect(tiles).toHaveLength(4);
|
||||
expect(container.textContent).toContain('agents.schedules.stats.active');
|
||||
expect(container.textContent).toContain('14');
|
||||
const failed = tiles.find((c) =>
|
||||
c.textContent?.includes('agents.schedules.stats.failed'),
|
||||
)!;
|
||||
expect(failed.dataset.tone).toBe('destructive');
|
||||
expect(failed.textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('switches Recurring and One-time with underline tabs', async () => {
|
||||
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
|
||||
expect(tabs.map((t) => t.textContent)).toEqual([
|
||||
'agents.schedules.recurring2',
|
||||
'agents.schedules.oneTime0',
|
||||
]);
|
||||
expect(tabs[0].getAttribute('data-variant')).toBe('underline');
|
||||
expect(container.querySelector('[data-testid="row-s1"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="row-s2"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows an empty tab as an EmptyState with New schedule', async () => {
|
||||
const oneTime = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]'),
|
||||
)[1];
|
||||
await act(async () => {
|
||||
oneTime.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
oneTime.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
oneTime.click();
|
||||
});
|
||||
const empty = container.querySelector('[data-slot="empty-state"]')!;
|
||||
expect(empty.textContent).toContain('agents.schedules.noOneTime');
|
||||
expect(empty.querySelector('button')?.textContent).toContain(
|
||||
'agents.schedules.newRecurring',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,27 +4,38 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../../api/services/userService';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import schedulesService from '../../api/services/schedulesService';
|
||||
import StatCard from '../../components/StatCard';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import ConfirmationModal from '../../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../../models/misc';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
import type { AppDispatch, RootState } from '../../store';
|
||||
import { formatDateTime } from '../../utils/dateTimeUtils';
|
||||
import { formatTokens } from '../../settings/traces/traceUtils';
|
||||
import {
|
||||
formatDateOnly,
|
||||
formatDateTime,
|
||||
formatRelative,
|
||||
} from '../../utils/dateTimeUtils';
|
||||
import AgentPageToolbar, { LastUsedMeta } from '../components/AgentPageToolbar';
|
||||
import SectionShell from '../../navigation/SectionShell';
|
||||
import type { Agent } from '../types';
|
||||
import type {
|
||||
Schedule,
|
||||
ScheduleCreatePayload,
|
||||
ScheduleRun,
|
||||
ScheduleStats,
|
||||
} from '../types/schedule';
|
||||
import RunDetailDrawer from './RunDetailDrawer';
|
||||
import RunLog from './RunLog';
|
||||
import ScheduleFormModal from './ScheduleFormModal';
|
||||
import ScheduleStatusBadge from './StatusBadge';
|
||||
import { formatCron } from './cronBuilder';
|
||||
import ScheduleRow from './ScheduleRow';
|
||||
import {
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
@@ -39,9 +50,8 @@ import {
|
||||
// must not escape them first.
|
||||
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
|
||||
const formatTimestamp = (value?: string | null): string => {
|
||||
return value ? formatDateTime(value) : '—';
|
||||
};
|
||||
// The stat row's window, in days.
|
||||
const STATS_DAYS = 30;
|
||||
|
||||
/** Standalone Schedules page for an agent: list, create, edit, pause, run, delete. */
|
||||
export default function SchedulesView() {
|
||||
@@ -66,6 +76,28 @@ export default function SchedulesView() {
|
||||
const schedules = useSelector((state: RootState) =>
|
||||
selectSchedulesForAgent(state, agentId ?? ''),
|
||||
);
|
||||
// undefined while loading, null when the stats request failed.
|
||||
const [stats, setStats] = useState<ScheduleStats | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Refresh the totals whenever the list changes (a run finished, a
|
||||
// schedule was paused or removed): the SSE feed updates `schedules`.
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
schedulesService
|
||||
.statsForAgent(agentId, token, STATS_DAYS)
|
||||
.then((next) => {
|
||||
if (!cancelled) setStats(next);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStats(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, token, schedules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
@@ -152,228 +184,165 @@ export default function SchedulesView() {
|
||||
}
|
||||
};
|
||||
|
||||
const activeCount = schedules.filter((s) => s.status === 'active').length;
|
||||
const pausedCount = schedules.filter((s) => s.status === 'paused').length;
|
||||
const nextRunAt = schedules
|
||||
.filter((s) => s.status === 'active' && s.next_run_at)
|
||||
.map((s) => s.next_run_at as string)
|
||||
.sort()[0];
|
||||
// A next run in the past means the scheduler hasn't picked it up yet.
|
||||
const nextRunOverdue =
|
||||
Boolean(nextRunAt) && Date.parse(nextRunAt as string) < Date.now();
|
||||
const statsLoading = stats === undefined;
|
||||
const failedCount = stats?.failed ?? 0;
|
||||
const latestFailure = stats?.latest_failure;
|
||||
|
||||
const newScheduleButton = (
|
||||
<Button type="button" size="field" shape="pill" onClick={openCreate}>
|
||||
<Plus />
|
||||
{t('agents.schedules.newRecurring')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderList = (list: Schedule[], emptyKey: string) =>
|
||||
list.length === 0 ? (
|
||||
<Card variant="subtle" padding="lg">
|
||||
<EmptyState
|
||||
size="sm"
|
||||
illustration="none"
|
||||
title={t(emptyKey)}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus />
|
||||
{t('agents.schedules.newRecurring')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{list.map((schedule) => (
|
||||
<li key={schedule.id}>
|
||||
<ScheduleRow
|
||||
schedule={schedule}
|
||||
expanded={expanded === schedule.id}
|
||||
onToggleRuns={(id) => setExpanded(expanded === id ? null : id)}
|
||||
onEdit={openEdit}
|
||||
onSetPaused={(target, paused) =>
|
||||
dispatch(
|
||||
setSchedulePaused({
|
||||
id: target.id,
|
||||
action: paused ? 'pause' : 'resume',
|
||||
token,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onRunNow={(target) =>
|
||||
dispatch(runScheduleNow({ id: target.id, token }))
|
||||
}
|
||||
onDelete={requestDelete}
|
||||
onSelectRun={(run) => setActiveRun(run)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionShell pills>
|
||||
<div className="flex flex-col gap-3">
|
||||
{agent && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground">{agent.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{agent.last_used_at
|
||||
? t('agents.logs.lastUsedAt') +
|
||||
' ' +
|
||||
formatDateTime(agent.last_used_at)
|
||||
: t('agents.logs.noUsageHistory')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SectionShell>
|
||||
{agent && (
|
||||
<AgentPageToolbar
|
||||
name={agent.name}
|
||||
meta={<LastUsedMeta lastUsedAt={agent.last_used_at} />}
|
||||
actions={newScheduleButton}
|
||||
/>
|
||||
)}
|
||||
{loadingAgent ? (
|
||||
<LoadingState fill="block" />
|
||||
) : (
|
||||
agent && (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<SectionHeader
|
||||
title={t('agents.schedules.heading')}
|
||||
actions={
|
||||
<Button type="button" size="sm" onClick={openCreate}>
|
||||
{t('agents.schedules.newRecurring')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="sm"
|
||||
title={`${t('agents.schedules.recurring')} (${recurring.length})`}
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('agents.schedules.stats.active')}
|
||||
value={activeCount}
|
||||
sub={
|
||||
pausedCount > 0
|
||||
? t('agents.schedules.stats.paused', {
|
||||
count: pausedCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{recurring.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('agents.schedules.noRecurring')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{recurring.map((schedule) => (
|
||||
<Card
|
||||
asChild
|
||||
key={schedule.id}
|
||||
variant="subtle"
|
||||
padding="sm"
|
||||
className="gap-0"
|
||||
>
|
||||
<li>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">
|
||||
{schedule.name ||
|
||||
schedule.instruction.slice(0, 80)}
|
||||
</p>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatCron(schedule.cron, t)} ·{' '}
|
||||
{t('agents.schedules.timezoneMeta', {
|
||||
...NO_ESCAPE,
|
||||
timezone: schedule.timezone,
|
||||
})}{' '}
|
||||
·{' '}
|
||||
{t('agents.schedules.nextRunMeta', {
|
||||
...NO_ESCAPE,
|
||||
time: formatTimestamp(schedule.next_run_at),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openEdit(schedule)}
|
||||
>
|
||||
{t('agents.schedules.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setSchedulePaused({
|
||||
id: schedule.id,
|
||||
action:
|
||||
schedule.status === 'active'
|
||||
? 'pause'
|
||||
: 'resume',
|
||||
token,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{schedule.status === 'active'
|
||||
? t('agents.schedules.pause')
|
||||
: t('agents.schedules.resume')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
runScheduleNow({ id: schedule.id, token }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('agents.schedules.runNow')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => requestDelete(schedule)}
|
||||
>
|
||||
{t('agents.schedules.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
setExpanded(
|
||||
expanded === schedule.id ? null : schedule.id,
|
||||
)
|
||||
}
|
||||
className="mt-0.5 -ml-2"
|
||||
>
|
||||
{expanded === schedule.id
|
||||
? t('agents.schedules.hideRuns')
|
||||
: t('agents.schedules.showRuns')}
|
||||
</Button>
|
||||
{expanded === schedule.id && (
|
||||
<div className="mt-2">
|
||||
<RunLog
|
||||
scheduleId={schedule.id}
|
||||
onSelect={(run) => setActiveRun(run)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="sm"
|
||||
title={`${t('agents.schedules.oneTime')} (${oneTime.length})`}
|
||||
<StatCard
|
||||
label={t('agents.schedules.stats.nextRun')}
|
||||
value={
|
||||
!nextRunAt
|
||||
? '—'
|
||||
: nextRunOverdue
|
||||
? t('agents.schedules.stats.overdue')
|
||||
: formatRelative(nextRunAt, { future: true })
|
||||
}
|
||||
valueTone={nextRunOverdue ? 'warning' : undefined}
|
||||
sub={nextRunAt ? formatDateTime(nextRunAt) : undefined}
|
||||
/>
|
||||
{oneTime.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('agents.schedules.noOneTime')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{oneTime.map((schedule) => (
|
||||
<Card
|
||||
asChild
|
||||
key={schedule.id}
|
||||
variant="subtle"
|
||||
padding="sm"
|
||||
className="gap-0"
|
||||
>
|
||||
<li>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">
|
||||
{schedule.name ||
|
||||
schedule.instruction.slice(0, 80)}
|
||||
</p>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
runs at {formatTimestamp(schedule.run_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{schedule.status === 'active' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openEdit(schedule)}
|
||||
>
|
||||
{t('agents.schedules.edit')}
|
||||
</Button>
|
||||
)}
|
||||
{schedule.status === 'active' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => requestDelete(schedule)}
|
||||
>
|
||||
{t('agents.schedules.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<StatCard
|
||||
label={t('agents.schedules.stats.runs', { days: STATS_DAYS })}
|
||||
value={stats ? stats.runs : '—'}
|
||||
loading={statsLoading}
|
||||
sub={
|
||||
stats
|
||||
? t('agents.schedules.stats.tokens', {
|
||||
tokens: formatTokens(stats.tokens),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('agents.schedules.stats.failed', {
|
||||
days: STATS_DAYS,
|
||||
})}
|
||||
value={stats ? failedCount : '—'}
|
||||
loading={statsLoading}
|
||||
tone={failedCount > 0 ? 'destructive' : 'default'}
|
||||
valueTone={failedCount > 0 ? 'destructive' : undefined}
|
||||
sub={
|
||||
latestFailure
|
||||
? t('agents.schedules.stats.latestFailure', {
|
||||
...NO_ESCAPE,
|
||||
status: t(
|
||||
`agents.schedules.status.${latestFailure.status}`,
|
||||
),
|
||||
date: formatDateOnly(latestFailure.scheduled_for),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Tabs defaultValue="recurring">
|
||||
<TabsList variant="underline">
|
||||
<TabsTrigger value="recurring" variant="underline">
|
||||
{t('agents.schedules.recurring')}
|
||||
<Badge variant="neutral">{recurring.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="once" variant="underline">
|
||||
{t('agents.schedules.oneTime')}
|
||||
<Badge variant="neutral">{oneTime.length}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="recurring" className="mt-4">
|
||||
{renderList(recurring, 'agents.schedules.noRecurring')}
|
||||
</TabsContent>
|
||||
<TabsContent value="once" className="mt-4">
|
||||
{renderList(oneTime, 'agents.schedules.noOneTime')}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<RunDetailDrawer
|
||||
run={activeRun}
|
||||
onClose={() => setActiveRun(null)}
|
||||
|
||||
@@ -268,3 +268,13 @@ describe('schedulesSlice SSE event handling', () => {
|
||||
expect(stub.trigger_source).toBe('cron');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectSchedulesForAgent', () => {
|
||||
it('returns a stable empty list for an agent that has not loaded', () => {
|
||||
// A fresh [] per call re-runs every effect that depends on the list.
|
||||
const state = { schedules: reducer(undefined, { type: '@@init' }) };
|
||||
expect(selectSchedulesForAgent(state, 'missing')).toBe(
|
||||
selectSchedulesForAgent(state, 'missing'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -333,12 +333,17 @@ const schedulesSlice = createSlice({
|
||||
export const { applyEvent, resetSchedules } = schedulesSlice.actions;
|
||||
export default schedulesSlice.reducer;
|
||||
|
||||
// Shared fallbacks: a fresh [] per call would change identity on every render
|
||||
// and re-run any effect that depends on the selected list.
|
||||
const NO_SCHEDULES: Schedule[] = [];
|
||||
const NO_RUNS: ScheduleRun[] = [];
|
||||
|
||||
export const selectSchedulesForAgent = (
|
||||
state: { schedules: SchedulesState },
|
||||
agentId: string,
|
||||
): Schedule[] => state.schedules.byAgent[agentId] ?? [];
|
||||
): Schedule[] => state.schedules.byAgent[agentId] ?? NO_SCHEDULES;
|
||||
|
||||
export const selectRunsForSchedule = (
|
||||
state: { schedules: SchedulesState },
|
||||
scheduleId: string,
|
||||
): ScheduleRun[] => state.schedules.runsBySchedule[scheduleId] ?? [];
|
||||
): ScheduleRun[] => state.schedules.runsBySchedule[scheduleId] ?? NO_RUNS;
|
||||
@@ -68,6 +68,19 @@ export type ScheduleRun = {
|
||||
};
|
||||
|
||||
export type ScheduleListResponse = { schedules: Schedule[] };
|
||||
|
||||
/** Run totals for an agent's schedules over the last `days` days. */
|
||||
export type ScheduleStats = {
|
||||
days: number;
|
||||
runs: number;
|
||||
failed: number;
|
||||
tokens: number;
|
||||
latest_failure: {
|
||||
scheduled_for: string;
|
||||
status: ScheduleRunStatus;
|
||||
error_type?: ScheduleRunErrorType | null;
|
||||
} | null;
|
||||
};
|
||||
export type ScheduleResponse = { schedule: Schedule };
|
||||
export type ScheduleRunListResponse = {
|
||||
runs: ScheduleRun[];
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
} from '../../preferences/preferenceSlice';
|
||||
import { getToolDisplayName } from '../../utils/toolUtils';
|
||||
import { agentEditPath, agentsListPath } from '../paths';
|
||||
import { ActionMenu } from '@/components/ui/dropdown-menu';
|
||||
import AgentPageHeader from '../AgentPageHeader';
|
||||
import AgentPreviewSheet from '../components/AgentPreviewSheet';
|
||||
import { Agent } from '../types';
|
||||
@@ -133,7 +134,7 @@ type PaletteTone = 'primary' | 'success' | 'warning' | 'info';
|
||||
// Whole class strings per tone so Tailwind sees them.
|
||||
const PALETTE_TONE_CLASSES: Record<PaletteTone, string> = {
|
||||
primary:
|
||||
'bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground',
|
||||
'bg-secondary text-secondary-foreground group-hover:bg-primary group-hover:text-primary-foreground',
|
||||
success:
|
||||
'bg-success/10 text-success group-hover:bg-success group-hover:text-success-foreground',
|
||||
warning:
|
||||
@@ -1795,6 +1796,7 @@ function WorkflowBuilderInner() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={navigateBackToAgents}
|
||||
>
|
||||
@@ -1931,32 +1933,10 @@ function WorkflowBuilderInner() {
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canManageAgent && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
shape="pill"
|
||||
onClick={() => setAgentDetails('ACTIVE')}
|
||||
>
|
||||
<Link />
|
||||
{t('agents.form.buttons.accessDetails')}
|
||||
</Button>
|
||||
)}
|
||||
{canManageAgent && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
shape="pill"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
loading={isDeletingAgent}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('agents.form.buttons.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
const validationErrors = validateWorkflow();
|
||||
@@ -1976,11 +1956,31 @@ function WorkflowBuilderInner() {
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
loading={showPrimaryActionSpinner}
|
||||
size="lg"
|
||||
size="field"
|
||||
shape="pill"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
{canManageAgent && (
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel={t('agents.form.buttons.moreActions')}
|
||||
options={[
|
||||
{
|
||||
label: t('agents.form.buttons.accessDetails'),
|
||||
icon: Link,
|
||||
onClick: () => setAgentDetails('ACTIVE'),
|
||||
},
|
||||
{
|
||||
label: t('agents.form.buttons.delete'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
disabled: isDeletingAgent,
|
||||
onClick: () => setDeleteConfirmation('ACTIVE'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -103,8 +103,8 @@ describe('WorkflowMiniMap', () => {
|
||||
);
|
||||
expect(tokens(r.busy)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'hover:bg-primary/10',
|
||||
'dark:hover:bg-primary/10',
|
||||
'hover:bg-secondary',
|
||||
'dark:hover:bg-secondary',
|
||||
'animate-pulse',
|
||||
]),
|
||||
);
|
||||
@@ -125,8 +125,8 @@ describe('WorkflowMiniMap', () => {
|
||||
expect.arrayContaining([
|
||||
'ring-2',
|
||||
'ring-primary',
|
||||
'hover:bg-primary/10',
|
||||
'dark:hover:bg-primary/10',
|
||||
'hover:bg-secondary',
|
||||
'dark:hover:bg-secondary',
|
||||
]),
|
||||
);
|
||||
expect(done).not.toContain('hover:bg-accent');
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
resendQuery,
|
||||
resetWorkflowPreview,
|
||||
selectActiveNodeId,
|
||||
setPreviewOpen,
|
||||
selectWorkflowExecutionSteps,
|
||||
selectWorkflowPreviewQueries,
|
||||
selectWorkflowPreviewStatus,
|
||||
@@ -409,11 +408,11 @@ export function WorkflowMiniMap({
|
||||
className={cn(
|
||||
'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'
|
||||
? 'bg-secondary ring-primary hover:bg-secondary dark:bg-secondary dark:hover:bg-secondary 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'
|
||||
? 'border-primary/50 bg-secondary hover:bg-secondary dark:border-primary/50 dark:bg-secondary dark:hover:bg-secondary 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',
|
||||
@@ -596,12 +595,10 @@ export default function WorkflowPreview({
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(resetWorkflowPreview());
|
||||
dispatch(setPreviewOpen(true));
|
||||
return () => {
|
||||
if (fetchStream.current) fetchStream.current.abort();
|
||||
handleWorkflowPreviewAbort();
|
||||
dispatch(resetWorkflowPreview());
|
||||
dispatch(setPreviewOpen(false));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
@@ -442,7 +442,7 @@ export default function PromptTextArea({
|
||||
// so typing keeps filtering it.
|
||||
const field = (
|
||||
<PopoverAnchor asChild>
|
||||
<div className="border-border focus-within:ring-ring bg-card relative rounded-xl border transition-shadow focus-within:ring-2">
|
||||
<div className="border-border focus-within:border-ring focus-within:ring-ring/50 bg-card relative rounded-xl border transition-shadow focus-within:ring-3">
|
||||
<div
|
||||
ref={overlayRef}
|
||||
aria-hidden
|
||||
|
||||
@@ -141,6 +141,8 @@ const endpoints = {
|
||||
CUSTOM_MODEL_TEST: (id: string) => `/api/user/models/${id}/test`,
|
||||
CUSTOM_MODEL_TEST_PAYLOAD: '/api/user/models/test',
|
||||
AGENT_SCHEDULES: (agentId: string) => `/api/agents/${agentId}/schedules`,
|
||||
AGENT_SCHEDULE_STATS: (agentId: string, days?: number) =>
|
||||
`/api/agents/${agentId}/schedules/stats?days=${days ?? 30}`,
|
||||
SCHEDULE: (id: string) => `/api/schedules/${id}`,
|
||||
SCHEDULE_RUN_NOW: (id: string) => `/api/schedules/${id}/run`,
|
||||
SCHEDULE_RUNS: (id: string, limit?: number, offset?: number) =>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import apiClient from '../client';
|
||||
import schedulesService from './schedulesService';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const response = (body: unknown, status = 200) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('schedulesService.statsForAgent', () => {
|
||||
it('returns the stats body on success', async () => {
|
||||
const body = { days: 30, runs: 4, failed: 1, tokens: 120 };
|
||||
vi.spyOn(apiClient, 'get').mockResolvedValue(response(body));
|
||||
|
||||
await expect(schedulesService.statsForAgent('a1', 't')).resolves.toEqual(
|
||||
body,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects on an error response instead of returning its body as stats', async () => {
|
||||
vi.spyOn(apiClient, 'get').mockResolvedValue(
|
||||
response({ success: false, message: 'Agent not found' }, 404),
|
||||
);
|
||||
|
||||
await expect(schedulesService.statsForAgent('a1', 't')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ScheduleResponse,
|
||||
ScheduleRunListResponse,
|
||||
ScheduleRunResponse,
|
||||
ScheduleStats,
|
||||
ScheduleUpdatePayload,
|
||||
} from '../../agents/types/schedule';
|
||||
|
||||
@@ -27,6 +28,23 @@ const schedulesService = {
|
||||
return (await json(r)) as ScheduleListResponse;
|
||||
},
|
||||
|
||||
statsForAgent: async (
|
||||
agentId: string,
|
||||
token: string | null,
|
||||
days = 30,
|
||||
): Promise<ScheduleStats> => {
|
||||
const r = await apiClient.get(
|
||||
endpoints.USER.AGENT_SCHEDULE_STATS(agentId, days),
|
||||
token,
|
||||
);
|
||||
// The error body ({success: false, message}) is not stats: reject so the
|
||||
// caller shows "—" instead of empty totals.
|
||||
if (!(r as Response).ok) {
|
||||
throw new Error(`Schedule stats failed: ${(r as Response).status}`);
|
||||
}
|
||||
return (await json(r)) as ScheduleStats;
|
||||
},
|
||||
|
||||
create: async (
|
||||
agentId: string,
|
||||
payload: ScheduleCreatePayload,
|
||||
|
||||
@@ -43,6 +43,31 @@ describe('ArtifactSidebar', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
// Every Sheet closes with its built-in X (ghost-muted icon-sm, top-2
|
||||
// right-2); the header only names the artifact.
|
||||
it('closes the phone sheet with the built-in close button', async () => {
|
||||
documentMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
legacyMock.mockRejectedValue(new Error('offline'));
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ArtifactSidebar
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
artifactId="a1"
|
||||
conversationId="c1"
|
||||
variant="overlay"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const sheet = document.querySelector('[data-slot="sheet-content"]')!;
|
||||
// One close: the Sheet's built-in X, named in the UI language.
|
||||
const closes = sheet.querySelectorAll('button[aria-label="agents.close"]');
|
||||
expect(closes).toHaveLength(1);
|
||||
expect(closes[0].getAttribute('data-variant')).toBe('ghost-muted');
|
||||
expect(sheet.querySelector('[aria-label="Close"]')).toBeNull();
|
||||
expect(sheet.querySelector('[data-slot="sheet-title"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows a failed load as an alert with Retry that refetches', async () => {
|
||||
documentMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
legacyMock.mockRejectedValue(new Error('offline'));
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { IconButton } from './ui/icon-button';
|
||||
import { Sheet, SheetContent } from './ui/sheet';
|
||||
import { Sheet, SheetContent, SheetTitle } from './ui/sheet';
|
||||
|
||||
type TodoItem = {
|
||||
todo_id: number;
|
||||
@@ -528,10 +528,10 @@ export default function ArtifactSidebar({
|
||||
<div className="h-14 shrink-0" />
|
||||
{/* Artifact panel */}
|
||||
<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-muted-foreground text-sm font-medium">
|
||||
<div className="flex w-full items-center justify-between gap-2 px-4 py-2">
|
||||
<h2 className="text-foreground min-w-0 text-sm leading-tight font-semibold wrap-anywhere">
|
||||
{title}
|
||||
</span>
|
||||
</h2>
|
||||
<IconButton
|
||||
label={t('agents.close')}
|
||||
side="bottom"
|
||||
@@ -556,27 +556,13 @@ export default function ArtifactSidebar({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
title={title || t('components.artifact.preview')}
|
||||
className="h-full w-80 p-0 sm:w-96 sm:max-w-none"
|
||||
>
|
||||
<SheetContent side="right" closeLabel={t('agents.close')} className="p-0">
|
||||
<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>
|
||||
<IconButton
|
||||
label={t('agents.close')}
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</IconButton>
|
||||
{/* pr-12 keeps the title clear of the built-in close X. */}
|
||||
<div className="border-border flex w-full items-center border-b px-4 py-3 pr-12">
|
||||
<SheetTitle className="min-w-0 text-sm font-semibold wrap-anywhere">
|
||||
{title || t('components.artifact.preview')}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +124,18 @@ describe('Chunks', () => {
|
||||
expect(tile.querySelector('.bg-muted')).toBeNull();
|
||||
});
|
||||
|
||||
// Decision 79: text tiles follow the container with an auto-fit track that
|
||||
// can't overflow a container narrower than 400px.
|
||||
it('lays the tiles out on the guarded auto-fit text-tile grid', async () => {
|
||||
await render();
|
||||
const grid = container.querySelector<HTMLButtonElement>(
|
||||
'button[data-slot="card"]',
|
||||
)!.parentElement!;
|
||||
expect(grid.className).toBe(
|
||||
'grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]',
|
||||
);
|
||||
});
|
||||
|
||||
const renderPath = async (props: {
|
||||
onPathSelect?: (depth: number) => void;
|
||||
handleGoBack?: () => void;
|
||||
|
||||
@@ -596,11 +596,11 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="grid w-full grid-cols-1 justify-items-start gap-4 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]">
|
||||
<SkeletonLoader component="chunkCards" count={perPage} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full grid-cols-1 justify-items-start gap-4 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]">
|
||||
{filteredChunks.length === 0 ? (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
|
||||
@@ -113,13 +113,6 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
shape="pill"
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
title={
|
||||
isSyncing
|
||||
? `${t('settings.sources.syncing')} ${syncProgress}%`
|
||||
: syncDone
|
||||
? 'Done'
|
||||
: t('settings.sources.sync')
|
||||
}
|
||||
>
|
||||
{syncDone ? (
|
||||
<Check />
|
||||
|
||||
@@ -65,6 +65,28 @@ describe('FileUpload', () => {
|
||||
).toBe('compact');
|
||||
});
|
||||
|
||||
it('shows the current image inside a tile until a new one is picked', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<FileUpload
|
||||
onUpload={vi.fn()}
|
||||
size="tile"
|
||||
showPreview
|
||||
currentImage="https://example.com/agent.png"
|
||||
uploadText="Avatar"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const zone = container.querySelector('[data-slot="dropzone"]')!;
|
||||
expect(zone.getAttribute('data-size')).toBe('tile');
|
||||
const img = zone.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe('https://example.com/agent.png');
|
||||
expect(img.className).toContain('object-cover');
|
||||
expect(container.textContent).not.toContain(
|
||||
'components.fileUpload.fileTypes',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders highlighted upload text segments', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
||||
@@ -21,7 +21,9 @@ interface FileUploadProps {
|
||||
showPreview?: boolean;
|
||||
previewSize?: number;
|
||||
/** `compact` is a one-row target for forms and panels. */
|
||||
size?: 'default' | 'compact';
|
||||
size?: 'default' | 'compact' | 'tile';
|
||||
/** An image already saved (an agent's avatar), shown in a tile until a new one is picked. */
|
||||
currentImage?: string;
|
||||
|
||||
children?: React.ReactNode;
|
||||
/** Layout classes only; the Dropzone owns colours, border and radius. */
|
||||
@@ -53,6 +55,7 @@ export const FileUpload = ({
|
||||
showPreview = false,
|
||||
previewSize = 80,
|
||||
size = 'default',
|
||||
currentImage,
|
||||
children,
|
||||
className,
|
||||
uploadText,
|
||||
@@ -196,8 +199,36 @@ export const FileUpload = ({
|
||||
|
||||
// With a preview, the image takes the icon's place; the text block mirrors
|
||||
// the Dropzone's own title/description layout.
|
||||
// A tile shows the picked image (with Remove) or the saved one filling it.
|
||||
const tileImage = preview ?? currentImage;
|
||||
const tileContent =
|
||||
size === 'tile' && showPreview && tileImage ? (
|
||||
<span className="relative size-full">
|
||||
<img
|
||||
src={tileImage}
|
||||
alt={t('components.fileUpload.preview')}
|
||||
className="size-full rounded-xl object-cover"
|
||||
/>
|
||||
{preview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove();
|
||||
}}
|
||||
className="bg-primary hover:bg-primary/90 absolute -top-2 -right-2 rounded-full p-1 transition-colors"
|
||||
aria-label={t('components.fileUpload.remove')}
|
||||
>
|
||||
<X className="text-primary-foreground size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
) : undefined;
|
||||
|
||||
const previewContent =
|
||||
showPreview && preview ? (
|
||||
size === 'tile' ? (
|
||||
tileContent
|
||||
) : showPreview && preview ? (
|
||||
<>
|
||||
{renderPreview()}
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { renderMermaidDiagram } from './mermaidSecurity';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Button } from './ui/button';
|
||||
import { IconButton } from './ui/icon-button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -357,18 +358,22 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
>
|
||||
-
|
||||
</IconButton>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="inline"
|
||||
onClick={() => setZoomFactor(2)}
|
||||
title={t('mermaid.resetZoom')}
|
||||
/* eslint-disable-next-line shadcn/no-restyle --
|
||||
on the bg-black/70 zoom overlay, like its − / + siblings: keeps the overlay's white 12px regular */
|
||||
className="text-xs font-normal text-current"
|
||||
>
|
||||
{zoomFactor.toFixed(1)}x
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="inline"
|
||||
onClick={() => setZoomFactor(2)}
|
||||
/* eslint-disable-next-line shadcn/no-restyle --
|
||||
on the bg-black/70 zoom overlay, like its − / + siblings: keeps the overlay's white 12px regular */
|
||||
className="text-xs font-normal text-current"
|
||||
>
|
||||
{zoomFactor.toFixed(1)}x
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mermaid.resetZoom')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
label={t('mermaid.increaseZoom')}
|
||||
side="bottom"
|
||||
|
||||
@@ -1851,7 +1851,7 @@ export default function MessageInput({
|
||||
createPortal(
|
||||
<div className="bg-background/85 pointer-events-none fixed top-0 left-0 z-50 flex size-full flex-col items-center justify-center">
|
||||
<CloudUpload className="size-18" />
|
||||
<span className="text-muted-foreground px-2 text-2xl font-bold">
|
||||
<span className="text-foreground px-2 text-xl leading-tight font-semibold">
|
||||
{t('modals.uploadDoc.drag.title')}
|
||||
</span>
|
||||
<span className="text-muted-foreground w-48 p-2 text-center text-sm">
|
||||
|
||||
@@ -33,6 +33,15 @@ describe('PageToolbar', () => {
|
||||
expect(html).not.toContain('data-slot="separator"');
|
||||
});
|
||||
|
||||
// On a phone the row stacks; the action slot spans it, so a caller can
|
||||
// stretch its main button. From sm the slot hugs its buttons.
|
||||
it('lets the action slot span the stacked row on a phone', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PageToolbar intro="x" action={<button type="button">Add</button>} />,
|
||||
);
|
||||
expect(html).toContain('<div class="w-full shrink-0 sm:w-auto">');
|
||||
});
|
||||
|
||||
it('renders notices between the row and the rule', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PageToolbar intro="x" action={<span />} divider>
|
||||
|
||||
@@ -37,7 +37,11 @@ export default function PageToolbar({
|
||||
) : search ? (
|
||||
<div className="w-full max-w-md">{search}</div>
|
||||
) : null}
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
{/* Spans the stacked row on a phone, so a caller can stretch its
|
||||
main button; hugs its buttons from sm. */}
|
||||
{action ? (
|
||||
<div className="w-full shrink-0 sm:w-auto">{action}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
{divider ? <Separator className="mb-8" /> : null}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkeletonLoader from './SkeletonLoader';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks', () => ({
|
||||
useMediaQuery: () => ({ isDesktop: true }),
|
||||
}));
|
||||
|
||||
describe('SkeletonLoader analysis', () => {
|
||||
const html = renderToStaticMarkup(<SkeletonLoader component="analysis" />);
|
||||
|
||||
it('renders chart-shaped Skeleton bars with no pulsing wrapper', () => {
|
||||
expect(html).toContain('flex h-full flex-col justify-end gap-3');
|
||||
expect(html).toContain('grid grid-cols-8 items-end gap-3');
|
||||
// Eight chart bars plus the x-axis bar, all Skeletons.
|
||||
expect(html.match(/data-slot="skeleton"/g)).toHaveLength(9);
|
||||
expect(html).not.toContain('bg-card');
|
||||
expect(html).not.toContain('rounded-3xl');
|
||||
expect(html).not.toContain('p-6');
|
||||
// Only the Skeleton bars pulse.
|
||||
expect(html.match(/animate-pulse/g)).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('fits the 245px chart box: the tallest bar is h-44', () => {
|
||||
expect(html).toContain('h-44');
|
||||
expect(html).not.toMatch(/\bh-(48|52|56|60|64|72|80|96)\b/);
|
||||
});
|
||||
|
||||
it('keeps the loading status for screen readers', () => {
|
||||
expect(html).toContain('role="status"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SkeletonLoader connectedState', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SkeletonLoader component="connectedState" />,
|
||||
);
|
||||
|
||||
it('is an outline Card row with default-surface Skeleton bars', () => {
|
||||
expect(html).toContain('data-slot="card"');
|
||||
expect(html).toContain('data-variant="outline"');
|
||||
expect(html).toContain('data-padding="sm"');
|
||||
expect(html).toContain('mb-4');
|
||||
expect(html).toContain('flex-row');
|
||||
expect(html).toContain('items-center');
|
||||
expect(html).toContain('justify-between');
|
||||
expect(html.match(/data-slot="skeleton"/g)).toHaveLength(3);
|
||||
expect(html.match(/data-surface="default"/g)).toHaveLength(3);
|
||||
expect(html).not.toContain('bg-muted-foreground/20');
|
||||
// The box does not pulse; only the three bars do.
|
||||
expect(html.match(/animate-pulse/g)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ interface SkeletonLoaderProps {
|
||||
| 'logs'
|
||||
| 'fileTable'
|
||||
| 'chatbot'
|
||||
| 'dropdown'
|
||||
| 'chunkCards'
|
||||
| 'sourceCards'
|
||||
| 'toolCards'
|
||||
@@ -86,16 +85,6 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const renderDropdown = () => (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-muted mb-2 h-4 w-24 rounded-sm"></div>
|
||||
<div className="bg-muted flex h-14 w-[360px] items-center justify-between rounded-3xl px-4">
|
||||
<div className="bg-muted-foreground/20 h-3 w-24 rounded-sm"></div>
|
||||
<div className="bg-muted-foreground/20 size-3 rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLogs = () => (
|
||||
<div className="flex w-full flex-col gap-px">
|
||||
{[...Array(8)].map((_, idx) => (
|
||||
@@ -155,34 +144,22 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
// Renders inside a chart panel's 245px chart box: the panel is the Card,
|
||||
// so only chart-shaped bars (tallest h-44 plus the x-axis, no wrapper).
|
||||
const renderAnalysis = () => (
|
||||
<>
|
||||
{[...Array(skeletonCount)].map((_, idx) => (
|
||||
<div key={idx} className="bg-card w-full animate-pulse rounded-3xl p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-muted h-4 w-1/3 rounded-sm"></div>
|
||||
<div className="grid grid-cols-6 items-end gap-2">
|
||||
<div className="bg-muted h-32 rounded-sm"></div>
|
||||
<div className="bg-muted h-24 rounded-sm"></div>
|
||||
<div className="bg-muted h-40 rounded-sm"></div>
|
||||
<div className="bg-muted h-28 rounded-sm"></div>
|
||||
<div className="bg-muted h-36 rounded-sm"></div>
|
||||
<div className="bg-muted h-20 rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-muted h-4 w-1/4 rounded-sm"></div>
|
||||
<div className="bg-muted h-32 rounded-sm"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-muted h-4 w-full rounded-sm"></div>
|
||||
<div className="bg-muted h-4 w-full rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
<div className="flex h-full flex-col justify-end gap-3">
|
||||
<div className="grid grid-cols-8 items-end gap-3">
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-40" />
|
||||
<Skeleton className="h-28" />
|
||||
<Skeleton className="h-36" />
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-44" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mirrors the chunk tile: a filled Card with text lines, then the token
|
||||
@@ -327,16 +304,20 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
// Stands in for ConnectorAuth's success Alert: a bordered row box, and
|
||||
// only the bars pulse.
|
||||
const renderConnectedState = () => (
|
||||
<div className="mb-4">
|
||||
<div className="bg-muted flex w-full animate-pulse items-center justify-between rounded-lg px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted-foreground/20 size-4 rounded"></div>
|
||||
<div className="bg-muted-foreground/20 h-4 w-32 rounded"></div>
|
||||
</div>
|
||||
<div className="bg-muted-foreground/20 h-4 w-16 rounded"></div>
|
||||
<Card
|
||||
variant="outline"
|
||||
padding="sm"
|
||||
className="mb-4 flex-row items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-4" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderFilesSection = () => (
|
||||
@@ -354,7 +335,6 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
const componentMap = {
|
||||
fileTable: renderTable,
|
||||
chatbot: renderChatbot,
|
||||
dropdown: renderDropdown,
|
||||
logs: renderLogs,
|
||||
default: renderDefault,
|
||||
analysis: renderAnalysis,
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function AttachFileButton({ onChange }: AttachFileButtonProps) {
|
||||
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">
|
||||
<Paperclip />
|
||||
<Paperclip className="size-3.5 sm:size-4" />
|
||||
<span className="text-foreground text-xs sm:text-sm">
|
||||
{t('conversation.attachments.attach')}
|
||||
</span>
|
||||
|
||||
@@ -44,7 +44,6 @@ export default function MicButton({
|
||||
shape="pill"
|
||||
onClick={onClick}
|
||||
aria-label={voiceButtonLabel}
|
||||
title={voiceButtonLabel}
|
||||
disabled={loading || recordingState === 'transcribing'}
|
||||
className="justify-start"
|
||||
>
|
||||
|
||||
@@ -53,14 +53,16 @@ export default function SourcesTrigger({
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="max-w-[130px] justify-start sm:max-w-[150px]"
|
||||
title={
|
||||
selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.map((doc) => doc.name).join(', ')
|
||||
: t('conversation.sources.title')
|
||||
}
|
||||
>
|
||||
<Database />
|
||||
<span className="text-foreground truncate overflow-hidden text-xs sm:text-sm">
|
||||
<Database className="size-3.5 sm:size-4" />
|
||||
<span
|
||||
className="text-foreground truncate overflow-hidden text-xs sm:text-sm"
|
||||
title={
|
||||
selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.map((doc) => doc.name).join(', ')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.length === 1
|
||||
? selectedDocs[0].name
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function ToolsTrigger({
|
||||
shape="pill"
|
||||
className="max-w-[130px] justify-start"
|
||||
>
|
||||
<Wrench />
|
||||
<Wrench className="size-3.5 sm:size-4" />
|
||||
<span className="text-foreground truncate overflow-hidden text-xs sm:text-sm">
|
||||
{t('settings.tools.label')}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import AttachFileButton from './AttachFileButton';
|
||||
import SourcesTrigger from './SourcesTrigger';
|
||||
import ToolsTrigger from './ToolsTrigger';
|
||||
|
||||
const iconClass = (html: string, icon: string): string => {
|
||||
const match = html.match(
|
||||
new RegExp(`<svg[^>]*class="([^"]*lucide-${icon}[^"]*)"`),
|
||||
);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
describe('composer controls', () => {
|
||||
it('sizes every control icon size-3.5 sm:size-4', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
[renderToStaticMarkup(<AttachFileButton onChange={noop} />), 'paperclip'],
|
||||
[
|
||||
renderToStaticMarkup(
|
||||
<ToolsTrigger
|
||||
open={false}
|
||||
onOpenChange={noop}
|
||||
items={[]}
|
||||
selectedIds={[]}
|
||||
onToggle={noop}
|
||||
loading={false}
|
||||
/>,
|
||||
),
|
||||
'wrench',
|
||||
],
|
||||
[
|
||||
renderToStaticMarkup(
|
||||
<SourcesTrigger
|
||||
open={false}
|
||||
onOpenChange={noop}
|
||||
items={[]}
|
||||
selectedIds={[]}
|
||||
onToggle={noop}
|
||||
selectedDocs={null}
|
||||
onUploadClick={noop}
|
||||
/>,
|
||||
),
|
||||
'database',
|
||||
],
|
||||
];
|
||||
for (const [html, icon] of cases) {
|
||||
const classes = iconClass(html, icon).split(' ');
|
||||
expect(classes, icon).toContain('size-3.5');
|
||||
expect(classes, icon).toContain('sm:size-4');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ const buttonVariants = cva(
|
||||
// (the base is shrink-0), keeping a neighbour's edit button in view.
|
||||
combobox:
|
||||
'min-w-0 shrink border border-input bg-card font-normal shadow-xs hover:bg-accent data-placeholder:text-muted-foreground',
|
||||
// Underline tabs (FilePicker's drives, the agent page sub-nav). Mark the
|
||||
// Underline route tabs (the workflow builder's sub-nav). Mark the
|
||||
// current tab with data-active; the 2px border is always there so the
|
||||
// row height doesn't move. The compound variant squares the corners.
|
||||
tab: 'border-b-2 border-transparent text-muted-foreground hover:text-foreground hover:border-border data-[active=true]:border-primary data-[active=true]:text-foreground',
|
||||
|
||||
@@ -50,6 +50,20 @@ describe('ActionMenu', () => {
|
||||
expect(trigger().querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
// A page header or toolbar: the trigger matches the icon buttons beside
|
||||
// 38px buttons on a plain surface, not a highlighted row.
|
||||
it('renders a 36px ghost-muted trigger with size="toolbar"', async () => {
|
||||
await render(
|
||||
<ActionMenu
|
||||
options={options()}
|
||||
triggerLabel="More actions"
|
||||
size="toolbar"
|
||||
/>,
|
||||
);
|
||||
expect(trigger().getAttribute('data-variant')).toBe('ghost-muted');
|
||||
expect(trigger().getAttribute('data-size')).toBe('icon');
|
||||
});
|
||||
|
||||
it('passes layout classes, disabled and a test id to the trigger', async () => {
|
||||
await render(
|
||||
<ActionMenu
|
||||
|
||||
@@ -263,9 +263,12 @@ const stopPropagation = (event: React.SyntheticEvent) =>
|
||||
* Clicks and keys on the trigger and the menu stop propagating, because the
|
||||
* menu usually sits inside a clickable card and a portal still bubbles
|
||||
* through the React tree. The trigger is `ghost-on-accent`: most hosts turn
|
||||
* bg-accent while the pointer is on them.
|
||||
* bg-accent while the pointer is on them. In a page header or toolbar
|
||||
* (`size="toolbar"`) it is a 36px `ghost-muted` icon button like the others
|
||||
* there, with its tooltip below.
|
||||
*
|
||||
* @param options The menu rows.
|
||||
* @param size `row` (a card, tile or row; the default) or `toolbar`.
|
||||
* @param triggerLabel The trigger's accessible name.
|
||||
* @param className Layout classes for the trigger (position, margin).
|
||||
* @param triggerTestId A `data-testid` for the trigger.
|
||||
@@ -279,6 +282,7 @@ function ActionMenu({
|
||||
disabled,
|
||||
className,
|
||||
triggerTestId,
|
||||
size = 'row',
|
||||
}: {
|
||||
options: MenuOption[];
|
||||
triggerLabel: string;
|
||||
@@ -288,7 +292,9 @@ function ActionMenu({
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
triggerTestId?: string;
|
||||
size?: 'row' | 'toolbar';
|
||||
}) {
|
||||
const toolbar = size === 'toolbar';
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<Tooltip>
|
||||
@@ -297,8 +303,8 @@ function ActionMenu({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
variant={toolbar ? 'ghost-muted' : 'ghost-on-accent'}
|
||||
size={toolbar ? 'icon' : 'icon-xs'}
|
||||
aria-label={triggerLabel}
|
||||
disabled={disabled}
|
||||
data-testid={triggerTestId}
|
||||
@@ -310,7 +316,9 @@ function ActionMenu({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<TooltipContent>{triggerLabel}</TooltipContent>
|
||||
<TooltipContent side={toolbar ? 'bottom' : undefined}>
|
||||
{triggerLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
|
||||
@@ -36,6 +36,26 @@ describe('Dropzone', () => {
|
||||
expect(html).toContain('data-size="compact"');
|
||||
});
|
||||
|
||||
it('draws the tile size as an 88px square with a short muted label', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Dropzone
|
||||
onDrop={vi.fn()}
|
||||
size="tile"
|
||||
title="Avatar"
|
||||
description="hidden"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-size="tile"');
|
||||
// 64px beside the Name field on a phone, 88px beside both fields from sm.
|
||||
expect(html).toContain('size-16');
|
||||
expect(html).toContain('sm:size-22');
|
||||
// The word shows from sm; on a phone the tile is icon-only.
|
||||
expect(html).toMatch(/class="[^"]*\bsr-only\b[^"]*sm:not-sr-only/);
|
||||
expect(html).not.toContain('w-full cursor-pointer');
|
||||
expect(html).toContain('Avatar');
|
||||
expect(html).not.toContain('hidden</span>');
|
||||
});
|
||||
|
||||
it('never uses raw palette colours', () => {
|
||||
const html = renderToStaticMarkup(<Dropzone onDrop={vi.fn()} />);
|
||||
expect(html).not.toMatch(/\b(bg|text|border)-(gray|red|green|blue)-\d+/);
|
||||
|
||||
@@ -19,6 +19,10 @@ const dropzoneVariants = cva(
|
||||
size: {
|
||||
default: 'px-6 py-10',
|
||||
compact: 'flex-row justify-start px-4 py-3 text-left',
|
||||
// An 88px square beside a form's fields (an agent's avatar): the icon
|
||||
// and a one-word label, or the picked image filling it.
|
||||
// 64px and icon-only on a phone (beside the Name field), 88px from sm.
|
||||
tile: 'size-16 shrink-0 gap-1 rounded-2xl p-2 sm:size-22',
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: 'default' },
|
||||
@@ -32,6 +36,7 @@ const dropzoneIconVariants = cva(
|
||||
size: {
|
||||
default: 'size-12 [&>svg]:size-6',
|
||||
compact: 'size-8 [&>svg]:size-4',
|
||||
tile: 'size-8 [&>svg]:size-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: 'default' },
|
||||
@@ -47,8 +52,11 @@ type DropzoneProps = {
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
disabled?: boolean;
|
||||
/** Compact fits inside forms and modals; default is the full-height target. */
|
||||
size?: 'default' | 'compact';
|
||||
/**
|
||||
* Compact fits inside forms and modals; default is the full-height target;
|
||||
* tile is an 88px square that shows only the icon and the title.
|
||||
*/
|
||||
size?: 'default' | 'compact' | 'tile';
|
||||
/** Main line. Defaults to the generic upload prompt. */
|
||||
title?: React.ReactNode;
|
||||
/** Secondary line for accepted types and limits, e.g. ".yaml, up to 5 MB". */
|
||||
@@ -111,24 +119,34 @@ function Dropzone({
|
||||
data-disabled={disabled || undefined}
|
||||
data-drag-active={isDragActive || undefined}
|
||||
data-drag-reject={isDragReject || undefined}
|
||||
className={dropzoneVariants({ size })}
|
||||
className={cn(dropzoneVariants({ size }))}
|
||||
>
|
||||
<input {...getInputProps({ id: field.id })} />
|
||||
{children ?? (
|
||||
<>
|
||||
<span className={dropzoneIconVariants({ size })}>
|
||||
{icon ?? <CloudUpload />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{description ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{children ??
|
||||
(size === 'tile' ? (
|
||||
<>
|
||||
<span className={dropzoneIconVariants({ size })}>
|
||||
{icon ?? <CloudUpload />}
|
||||
</span>
|
||||
<span className="text-muted-foreground sr-only text-xs sm:not-sr-only">
|
||||
{title}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={dropzoneIconVariants({ size })}>
|
||||
{icon ?? <CloudUpload />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{description ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
{error ? <p className="text-destructive text-xs">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -211,6 +211,9 @@ describe('Modal mobile sheet', () => {
|
||||
]),
|
||||
);
|
||||
expect(sheet.className).not.toContain('env(');
|
||||
// Every bottom sheet has the sheet elevation, like SheetContent.
|
||||
expect(classes).toContain('shadow-lg');
|
||||
expect(classes).not.toContain('shadow-modal');
|
||||
expect(sheet.firstElementChild!.getAttribute('data-slot')).toBe(
|
||||
'sheet-handle',
|
||||
);
|
||||
|
||||
@@ -127,12 +127,14 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(function Modal(
|
||||
// onClick when the user interacts inside the modal.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={cn(
|
||||
'bg-card text-foreground data-[state=open]:animate-in data-[state=closed]:animate-out shadow-modal fixed z-50 duration-200 outline-none',
|
||||
'bg-card text-foreground data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 duration-200 outline-none',
|
||||
isMobileSheet
|
||||
? // The shared bottom-sheet shape; pb-safe clears the iPhone
|
||||
// home indicator and keeps 1rem under the footer elsewhere.
|
||||
`${sheetBottomShape} pb-safe flex w-full flex-col gap-3 px-4`
|
||||
? // The shared bottom-sheet shape and the sheet elevation;
|
||||
// pb-safe clears the iPhone home indicator and keeps 1rem
|
||||
// under the footer elsewhere.
|
||||
`${sheetBottomShape} pb-safe flex w-full flex-col gap-3 px-4 shadow-lg`
|
||||
: cn(
|
||||
'shadow-modal',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 top-1/2 left-1/2 flex max-h-[85dvh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-2xl p-8',
|
||||
SIZE_CLASSES[size],
|
||||
className,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { count?: number }) =>
|
||||
opts?.count !== undefined ? `${key}:${opts.count}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MultiSelect } from './multi-select';
|
||||
|
||||
@@ -58,7 +65,7 @@ describe('MultiSelect', () => {
|
||||
// The hand-rolled chip's heavier tint is gone.
|
||||
expect(chip.className.split(' ')).not.toContain('bg-primary/20');
|
||||
});
|
||||
expect(host.textContent).toContain('+1 more');
|
||||
expect(host.textContent).toContain('components.multiSelect.more:1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -57,6 +58,7 @@ export function MultiSelect({
|
||||
modal = false,
|
||||
id,
|
||||
}: MultiSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const control = useFormFieldControl<{
|
||||
id?: string;
|
||||
@@ -146,7 +148,9 @@ export function MultiSelect({
|
||||
})}
|
||||
{selected.length > 2 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
+{selected.length - 2} more
|
||||
{t('components.multiSelect.more', {
|
||||
count: selected.length - 2,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -103,6 +103,41 @@ describe('SheetContent side="bottom"', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SheetContent side="right" size', () => {
|
||||
const widthClasses = async (size?: 'default' | 'detail' | 'wide') => {
|
||||
await render(
|
||||
<Sheet open>
|
||||
<SheetContent
|
||||
side="right"
|
||||
size={size}
|
||||
title="Drawer"
|
||||
aria-describedby={undefined}
|
||||
/>
|
||||
</Sheet>,
|
||||
);
|
||||
return content()
|
||||
.className.split(' ')
|
||||
.filter((c) => /^(sm:|md:|lg:)?(max-)?w-/.test(c));
|
||||
};
|
||||
|
||||
it('is a 384px companion drawer by default', async () => {
|
||||
expect(await widthClasses()).toEqual(['w-3/4', 'sm:max-w-sm']);
|
||||
});
|
||||
|
||||
it('is full width on a phone and 576px from sm at detail', async () => {
|
||||
expect(await widthClasses('detail')).toEqual(['w-full', 'sm:max-w-xl']);
|
||||
});
|
||||
|
||||
it('steps 600 / 700 / 800px at wide', async () => {
|
||||
expect(await widthClasses('wide')).toEqual([
|
||||
'w-full',
|
||||
'sm:max-w-[600px]',
|
||||
'md:max-w-[700px]',
|
||||
'lg:max-w-[800px]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SheetOverlay', () => {
|
||||
it('uses the blurred scrim every Modal uses', async () => {
|
||||
await render(
|
||||
|
||||
@@ -47,7 +47,7 @@ function SheetOverlay({
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone bottom-sheet shape: card fill, 16px top corners, capped by
|
||||
* The phone bottom-sheet shape: card fill, 18px top corners, capped by
|
||||
* `max-h-sheet` so the scrim above it stays tappable. Shared with Modal's `mobileVariant="sheet"` so every bottom
|
||||
* sheet looks the same; the caller adds its own bottom padding (`pb-safe` or
|
||||
* `pb-safe-0`).
|
||||
@@ -76,15 +76,34 @@ const sheetContentVariants = cva(
|
||||
variants: {
|
||||
side: {
|
||||
right:
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full border-l',
|
||||
left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
// A bottom sheet stacks its handle and parts flush; SheetHeader and
|
||||
// SheetFooter bring their own padding.
|
||||
bottom: `${sheetBottomShape} pb-safe-0 gap-0`,
|
||||
},
|
||||
// A right drawer's width by role (DESIGN.md "Modal, not Dialog"):
|
||||
// default, a companion list read beside the chat (384px); detail, one
|
||||
// record's fields (576px, full width on a phone); wide, a working
|
||||
// surface such as a trace waterfall or an agent preview (600 / 700 /
|
||||
// 800px).
|
||||
size: {
|
||||
default: '',
|
||||
detail: '',
|
||||
wide: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: { side: 'right' },
|
||||
compoundVariants: [
|
||||
{ side: 'right', size: 'default', class: 'w-3/4 sm:max-w-sm' },
|
||||
{ side: 'right', size: 'detail', class: 'w-full sm:max-w-xl' },
|
||||
{
|
||||
side: 'right',
|
||||
size: 'wide',
|
||||
class: 'w-full sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]',
|
||||
},
|
||||
],
|
||||
defaultVariants: { side: 'right', size: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -92,17 +111,23 @@ function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
size = 'default',
|
||||
handle = false,
|
||||
// A bottom sheet with a grab handle closes by its scrim, not an X (the
|
||||
// handle is only a cue; it doesn't drag).
|
||||
showCloseButton = !handle,
|
||||
closeLabel = 'Close',
|
||||
title,
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
/** Width of a right drawer: default (384px), detail (576px), wide (800px). */
|
||||
size?: 'default' | 'detail' | 'wide';
|
||||
showCloseButton?: boolean;
|
||||
/** Accessible name for the built-in X; pass a translated one. */
|
||||
closeLabel?: string;
|
||||
/** Draw the grab bar first (bottom sheets). */
|
||||
handle?: boolean;
|
||||
// Accessible name for the dialog. Radix warns when a Dialog has no Title;
|
||||
@@ -117,7 +142,7 @@ function SheetContent({
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(sheetContentVariants({ side }), className)}
|
||||
className={cn(sheetContentVariants({ side, size }), className)}
|
||||
{...props}
|
||||
{...focusReturn}
|
||||
>
|
||||
@@ -130,7 +155,7 @@ function SheetContent({
|
||||
<Button
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
aria-label="Close"
|
||||
aria-label={closeLabel}
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
<XIcon />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -90,6 +92,47 @@ describe('Table parts', () => {
|
||||
expect(clickable).toContain('cursor-pointer');
|
||||
});
|
||||
|
||||
it('makes a clickable row focusable and opens it with Enter or Space', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const onClick = vi.fn();
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TableRow data-testid="plain" />
|
||||
<TableRow data-testid="clickable" onClick={onClick}>
|
||||
<td>
|
||||
<button type="button">Inner</button>
|
||||
</td>
|
||||
</TableRow>
|
||||
</tbody>
|
||||
</table>,
|
||||
),
|
||||
);
|
||||
const plain = host.querySelector('[data-testid="plain"]')!;
|
||||
const row = host.querySelector<HTMLElement>('[data-testid="clickable"]')!;
|
||||
expect(plain.hasAttribute('tabindex')).toBe(false);
|
||||
expect(row.getAttribute('tabindex')).toBe('0');
|
||||
expect(row.className).toContain('focus-visible:ring-3');
|
||||
|
||||
const key = (target: Element, k: string) =>
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: k, bubbles: true }),
|
||||
);
|
||||
await act(async () => key(row, 'Enter'));
|
||||
await act(async () => key(row, ' '));
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
// A key on a control inside the row belongs to that control.
|
||||
await act(async () => key(row.querySelector('button')!, 'Enter'));
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('keeps the default minimum width on the table', () => {
|
||||
const table = render(<Table>{null}</Table>).querySelector('table')!;
|
||||
expect(table.className.split(' ')).toContain('min-w-[600px]');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, focusRing } from '@/lib/utils';
|
||||
|
||||
type TableProps = React.ComponentProps<'table'> & {
|
||||
/** A min-width class; the table scrolls inside TableContainer below it. */
|
||||
@@ -110,20 +110,37 @@ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A table row. With `onClick` the whole row is the target, so it also takes
|
||||
* focus and opens with Enter or Space for keyboard users.
|
||||
*/
|
||||
function TableRow({
|
||||
className,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: React.ComponentProps<'tr'>) {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTableRowElement>) => {
|
||||
onKeyDown?.(event);
|
||||
// Keys on a control inside the row belong to that control.
|
||||
if (event.defaultPrevented || event.target !== event.currentTarget) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.currentTarget.click();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'border-border border-b',
|
||||
onClick && 'hover:bg-accent cursor-pointer transition-colors',
|
||||
onClick &&
|
||||
`hover:bg-accent cursor-pointer transition-colors outline-none ${focusRing}`,
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? handleKeyDown : onKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { AlertCircle, Check, Info, TriangleAlert } from 'lucide-react';
|
||||
import { Check, CircleAlert, Info, TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -198,7 +198,7 @@ const STATUS_ICON = {
|
||||
pending: <Spinner size="sm" />,
|
||||
success: <Check strokeWidth={3} />,
|
||||
warning: <TriangleAlert strokeWidth={3} />,
|
||||
destructive: <AlertCircle strokeWidth={3} />,
|
||||
destructive: <CircleAlert strokeWidth={3} />,
|
||||
info: <Info strokeWidth={3} />,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Hover delay before a tooltip opens (Pavel, phase 7: 400ms, not shadcn's 0). */
|
||||
/** Hover delay before a tooltip opens: 400ms, not shadcn's 0 (DESIGN.md). */
|
||||
const TOOLTIP_DELAY_MS = 400;
|
||||
|
||||
/** True under a TooltipProvider, so a Tooltip knows not to add its own. */
|
||||
|
||||
@@ -147,7 +147,13 @@ describe('ConversationBubble', () => {
|
||||
'wrap-break-word',
|
||||
]),
|
||||
);
|
||||
const card = pre.parentElement!;
|
||||
// The scroll cap sits on an inner scroller inside the Card's padding,
|
||||
// so the scrollbar stays clear of the rounded corners.
|
||||
const scroller = pre.parentElement!;
|
||||
expect(scroller.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['scrollbar-overlay', 'overflow-y-auto']),
|
||||
);
|
||||
const card = scroller.parentElement!;
|
||||
expect(card.getAttribute('data-slot')).toBe('card');
|
||||
expect(card.getAttribute('data-variant')).toBe('subtle');
|
||||
expect(card.getAttribute('data-padding')).toBe('sm');
|
||||
|
||||
@@ -612,11 +612,7 @@ const ConversationBubble = forwardRef<
|
||||
)}
|
||||
{sources && (
|
||||
<Sheet open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
title={t('conversation.sources.title')}
|
||||
className="w-64 sm:w-80 sm:max-w-none"
|
||||
>
|
||||
<SheetContent side="right" title={t('conversation.sources.title')}>
|
||||
<div className="flex h-full flex-col items-center gap-2 px-6 py-4 text-center">
|
||||
<AllSources sources={sources} />
|
||||
</div>
|
||||
@@ -832,14 +828,12 @@ function ToolCallApprovalBar({
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{t('conversation.inlineSteps.arguments')}
|
||||
</p>
|
||||
<Card
|
||||
variant="subtle"
|
||||
padding="sm"
|
||||
className="mb-2 max-h-40 overflow-y-auto"
|
||||
>
|
||||
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
|
||||
{JSON.stringify(toolCall.arguments, null, 2)}
|
||||
</pre>
|
||||
<Card variant="subtle" padding="sm" className="mb-2">
|
||||
<div className="scrollbar-overlay max-h-40 overflow-y-auto">
|
||||
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
|
||||
{JSON.stringify(toolCall.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
<Input
|
||||
type="text"
|
||||
|
||||
@@ -17,6 +17,11 @@ import { markdownHeadings } from '@/lib/markdown';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import MermaidRenderer from '../components/MermaidRenderer';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '../components/ui/tooltip';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import classes from './ConversationBubble.module.css';
|
||||
import {
|
||||
@@ -218,33 +223,42 @@ export default function MarkdownAnswer({
|
||||
const num = href.replace('#cite-', '');
|
||||
const sourceIdx = parseInt(num, 10) - 1;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
const el = document.getElementById(
|
||||
`source-${sourceIdx}`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
el.classList.add('ring-3', 'ring-primary');
|
||||
setTimeout(
|
||||
() =>
|
||||
el.classList.remove('ring-3', 'ring-primary'),
|
||||
2000,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mx-0.5 h-5 min-w-5"
|
||||
title={t('conversation.jumpToSource', { num })}
|
||||
>
|
||||
{num}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
const el = document.getElementById(
|
||||
`source-${sourceIdx}`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
el.classList.add('ring-3', 'ring-primary');
|
||||
setTimeout(
|
||||
() =>
|
||||
el.classList.remove(
|
||||
'ring-3',
|
||||
'ring-primary',
|
||||
),
|
||||
2000,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mx-0.5 h-5 min-w-5"
|
||||
>
|
||||
{num}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('conversation.jumpToSource', { num })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -340,6 +354,7 @@ export default function MarkdownAnswer({
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="border-border relative overflow-x-auto rounded-lg border">
|
||||
{/* eslint-disable-next-line no-restricted-syntax -- the model's own markdown table, styled by the answer (DESIGN.md, Approved exceptions) */}
|
||||
<table className="text-foreground w-full text-left">
|
||||
{children}
|
||||
</table>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -1142,25 +1143,44 @@ export default function DesignSystem() {
|
||||
</Example>
|
||||
<Example
|
||||
title="Action menu"
|
||||
code="<ActionMenu options triggerLabel />"
|
||||
code='<ActionMenu options triggerLabel /> on a tile · size="toolbar" in a page header'
|
||||
>
|
||||
<div className="bg-muted hover:bg-accent relative h-24 w-48 rounded-2xl p-4 text-sm">
|
||||
<span className="font-semibold">Vendor Due Diligence</span>
|
||||
<ActionMenu
|
||||
triggerLabel="Agent actions"
|
||||
className="absolute top-3 right-3"
|
||||
options={[
|
||||
{ icon: Pencil, label: 'Edit', onClick: () => {} },
|
||||
{ icon: Copy, label: 'Duplicate', onClick: () => {} },
|
||||
{ icon: Pin, label: 'Pin agent', onClick: () => {} },
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
variant: 'destructive',
|
||||
onClick: () => {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="bg-muted hover:bg-accent relative h-24 w-48 rounded-2xl p-4 text-sm">
|
||||
<span className="font-semibold">Vendor Due Diligence</span>
|
||||
<ActionMenu
|
||||
triggerLabel="Agent actions"
|
||||
className="absolute top-3 right-3"
|
||||
options={[
|
||||
{ icon: Pencil, label: 'Edit', onClick: () => {} },
|
||||
{ icon: Copy, label: 'Duplicate', onClick: () => {} },
|
||||
{ icon: Pin, label: 'Pin agent', onClick: () => {} },
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
variant: 'destructive',
|
||||
onClick: () => {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="field" shape="pill">
|
||||
<Play />
|
||||
Preview
|
||||
</Button>
|
||||
<Button size="field" shape="pill">
|
||||
Save
|
||||
</Button>
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel="More actions"
|
||||
options={[
|
||||
{ label: 'Access details', onClick: () => {} },
|
||||
{ label: 'Share with team', onClick: () => {} },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Example>
|
||||
<Example title="States" code="disabled · asChild">
|
||||
@@ -2109,8 +2129,8 @@ export default function DesignSystem() {
|
||||
intro="Click-or-drag file target used by imports and uploads. Colours follow the drag state; pass accept and limits and describe them in the second line."
|
||||
>
|
||||
<Example
|
||||
title="Default and compact"
|
||||
code='<Dropzone accept={…} description="…" size="compact">'
|
||||
title="Default, compact and tile"
|
||||
code='<Dropzone accept={…} description="…" size="compact | tile">'
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Dropzone
|
||||
@@ -2141,6 +2161,17 @@ export default function DesignSystem() {
|
||||
title="With an error"
|
||||
error="Only .yaml or .yml files are supported"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Dropzone
|
||||
size="tile"
|
||||
onDrop={(files) => setDropped(files.map((f) => f.name))}
|
||||
title="Avatar"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
size="tile": the agent's avatar beside its
|
||||
name and description.
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Last drop:{' '}
|
||||
{dropped.length ? dropped.join(', ') : 'nothing yet'}
|
||||
@@ -2589,7 +2620,7 @@ export default function DesignSystem() {
|
||||
>
|
||||
<Example
|
||||
title="Modal and sheet"
|
||||
code='<Modal size="md" title description footer={<ModalActions …/>}> · <SheetContent side="right | left | top"> · <SheetContent side="bottom" handle>'
|
||||
code='<Modal size="md" title description footer={<ModalActions …/>}> · <SheetContent side="right" size="default | detail | wide"> · <SheetContent side="left | top"> · <SheetContent side="bottom" handle>'
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => setModalOpen(true)}>
|
||||
@@ -2627,10 +2658,39 @@ export default function DesignSystem() {
|
||||
<Button variant="outline">Open sheet</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sources</SheetTitle>
|
||||
<SheetDescription>
|
||||
size="default" (384px): a companion list read
|
||||
beside the chat.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Detail sheet</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" size="detail">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Run details</SheetTitle>
|
||||
<SheetDescription>
|
||||
Started 09:30, finished 09:31, 3 tools called.
|
||||
size="detail" (576px, full width on a phone):
|
||||
one record's fields.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Wide sheet</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" size="wide">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Trace</SheetTitle>
|
||||
<SheetDescription>
|
||||
size="wide" (600 / 700 / 800px): a working
|
||||
surface such as a trace or an agent preview.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment node
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import { Linter } from 'eslint';
|
||||
|
||||
import {
|
||||
everywhereSelectors,
|
||||
pageSelectors,
|
||||
} from '../../eslint/design-rules.js';
|
||||
|
||||
const linter = new Linter({ configType: 'flat' });
|
||||
|
||||
const lint = (code: string): number =>
|
||||
linter.verify(
|
||||
code,
|
||||
[
|
||||
{
|
||||
files: ['**/*.tsx'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...everywhereSelectors,
|
||||
...pageSelectors,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
'fixture.tsx',
|
||||
).length;
|
||||
|
||||
describe('design rules lint', () => {
|
||||
it.each([
|
||||
['break-all', `<p className="break-all" />`],
|
||||
['bare rounded', `<div className="bg-muted rounded px-2" />`],
|
||||
['bare rounded in cn()', `<div className={cn('p-2 rounded', a && 'x')} />`],
|
||||
['custom breakpoint', `<div className="min-[500px]:flex" />`],
|
||||
['media breakpoint', `<div className="[@media(hover:hover)]:block" />`],
|
||||
['off-scale z', `<div className="absolute z-30" />`],
|
||||
['arbitrary z', `<div className="z-[60]" />`],
|
||||
['focus ring-2', `<div className="focus-within:ring-2" />`],
|
||||
['lucide loader', `import { Loader2 } from 'lucide-react';`],
|
||||
['transition-all', `<div className="transition-all duration-200" />`],
|
||||
['hover scale', `<div className="hover:scale-105" />`],
|
||||
['bg-primary/10', `const tone = 'bg-primary/10 text-primary';`],
|
||||
['native checkbox', `<input type="checkbox" />`],
|
||||
['title on Button', `<Button title="Delete" />`],
|
||||
['raw table', `<table><tbody /></table>`],
|
||||
['link-styled a', `<a className="text-primary underline" href="#">x</a>`],
|
||||
['drawer width', `<SheetContent side="right" className="sm:max-w-xl" />`],
|
||||
['drawer w-', `<SheetContent className="w-80 p-0" />`],
|
||||
])('rejects %s', (_name, code) => {
|
||||
expect(lint(code)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'rounded scale steps',
|
||||
`<div className="rounded-md rounded-t-2xl rounded-full" />`,
|
||||
],
|
||||
[
|
||||
'layer z values',
|
||||
`<div className="z-10 lg:z-20 z-50 z-200 z-0 z-auto -z-10" />`,
|
||||
],
|
||||
['size and max-w', `<div className="size-4 max-w-[520px] min-h-dvh" />`],
|
||||
['selection ring-2', `<div className="ring-2 ring-primary" />`],
|
||||
['hover:scale-x underline wipe', `<span className="hover:scale-x-100" />`],
|
||||
['transition-colors', `<div className="transition-colors" />`],
|
||||
[
|
||||
'other lucide icons',
|
||||
`import { Loader as L } from './local'; import { Plus } from 'lucide-react';`,
|
||||
],
|
||||
['a without link styling', `<a className="flex items-center" href="#" />`],
|
||||
['IconButton label', `<IconButton label="Delete" />`],
|
||||
['secondary fill', `<span className="bg-secondary bg-primary/90" />`],
|
||||
[
|
||||
'drawer size and layout',
|
||||
`<SheetContent size="wide" className="min-w-0 p-0" />`,
|
||||
],
|
||||
])('allows %s', (_name, code) => {
|
||||
expect(lint(code)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1018,6 +1018,9 @@
|
||||
"start": "Chat starten",
|
||||
"name": "Name",
|
||||
"choose": "Dateien auswählen",
|
||||
"dropzoneText": "Zum Hochladen klicken oder per Drag & Drop ablegen",
|
||||
"dropzoneHint": "PDF, Word, Excel, PowerPoint, Markdown, HTML, Bilder, Audio, EPUB, JSON oder ZIP, jeweils bis zu 25 MB",
|
||||
"filesRejected": "Nicht hinzugefügt (größer als 25 MB oder kein unterstützter Dateityp): {{files}}",
|
||||
"info": "Bitte lade .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip hoch (max. 25 MB)",
|
||||
"uploadedFiles": "Hochgeladene Dateien",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -1568,15 +1571,16 @@
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"sections": {
|
||||
"meta": "Meta",
|
||||
"source": "Quelle",
|
||||
"prompt": "Prompt",
|
||||
"tools": "Werkzeuge",
|
||||
"agentType": "Agententyp",
|
||||
"models": "Modelle",
|
||||
"advanced": "Erweitert",
|
||||
"guardrails": "Guardrails",
|
||||
"preview": "Vorschau"
|
||||
"preview": "Vorschau",
|
||||
"basics": "Grundlagen",
|
||||
"knowledge": "Wissen und Verhalten",
|
||||
"model": "Modell"
|
||||
},
|
||||
"placeholders": {
|
||||
"agentName": "Agentenname",
|
||||
@@ -1621,7 +1625,11 @@
|
||||
"research": "Recherche"
|
||||
},
|
||||
"labels": {
|
||||
"defaultModel": "Standardmodell"
|
||||
"defaultModel": "Standardmodell",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"sources": "Quellen",
|
||||
"avatar": "Avatar"
|
||||
},
|
||||
"advanced": {
|
||||
"jsonSchema": "JSON-Antwortschema",
|
||||
@@ -1636,7 +1644,10 @@
|
||||
"systemPromptOverrideDescription": "Erlaubt v1-API-Aufrufern, den System-Prompt dieses Agenten zu ersetzen"
|
||||
},
|
||||
"preview": {
|
||||
"publishedPreview": "Veröffentlichte Agenten können hier in der Vorschau angezeigt werden"
|
||||
"savedVersion": "gespeicherte Version",
|
||||
"publishTitle": "Zum Testen veröffentlichen",
|
||||
"publishDescription": "Die Vorschau chattet mit dem veröffentlichten Agenten. Entwürfe bleiben privat, bis Sie sie veröffentlichen.",
|
||||
"unsavedChanges": "Sie haben ungespeicherte Änderungen. Die Vorschau nutzt die gespeicherte Version, bis Sie speichern."
|
||||
},
|
||||
"dangerZone": {
|
||||
"heading": "Gefahrenzone",
|
||||
@@ -1699,6 +1710,12 @@
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"byline": {
|
||||
"new": "Richten Sie den Agenten ein und veröffentlichen Sie ihn, um mit ihm zu chatten."
|
||||
},
|
||||
"status": {
|
||||
"published": "Veröffentlicht"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1709,7 +1726,6 @@
|
||||
},
|
||||
"schedules": {
|
||||
"title": "Agenten-Zeitpläne",
|
||||
"heading": "Zeitpläne",
|
||||
"newRecurring": "Neuer Zeitplan",
|
||||
"closeForm": "Formular schließen",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -1805,8 +1821,6 @@
|
||||
"output": "Ausgabe",
|
||||
"truncated": "(gekürzt)"
|
||||
},
|
||||
"timezoneMeta": "Zeitzone: {{timezone}}",
|
||||
"nextRunMeta": "nächster Lauf: {{time}}",
|
||||
"toolCard": {
|
||||
"cancelFailed": "Abbrechen fehlgeschlagen",
|
||||
"taskCancelled": "Geplante Aufgabe abgebrochen.",
|
||||
@@ -1849,7 +1863,28 @@
|
||||
"runLog": {
|
||||
"when": "Wann",
|
||||
"details": "Details",
|
||||
"empty": "Für diesen Zeitplan wurden noch keine Läufe aufgezeichnet."
|
||||
"empty": "Für diesen Zeitplan wurden noch keine Läufe aufgezeichnet.",
|
||||
"duration": "Dauer"
|
||||
},
|
||||
"actions": "Aktionen für den Zeitplan",
|
||||
"cancelTask": "Aufgabe abbrechen",
|
||||
"failureStreak_one": "{{count}} Fehlschlag in Folge",
|
||||
"failureStreak_other": "{{count}} Fehlschläge in Folge",
|
||||
"meta": {
|
||||
"nextRun": "Nächster Lauf {{time}}",
|
||||
"runsAt": "Läuft am {{time}}",
|
||||
"lastRun": "Letzter Lauf {{time}}"
|
||||
},
|
||||
"stats": {
|
||||
"active": "Aktive Zeitpläne",
|
||||
"nextRun": "Nächster Lauf",
|
||||
"runs": "Läufe, letzte {{days}} Tage",
|
||||
"tokens": "{{tokens}} Tokens",
|
||||
"failed": "Fehlgeschlagen, letzte {{days}} Tage",
|
||||
"latestFailure": "Zuletzt: {{status}}, {{date}}",
|
||||
"paused_one": "{{count}} pausiert",
|
||||
"paused_other": "{{count}} pausiert",
|
||||
"overdue": "Überfällig"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -2140,6 +2175,10 @@
|
||||
},
|
||||
"profile": {
|
||||
"avatarAlt": "Benutzeravatar"
|
||||
},
|
||||
"multiSelect": {
|
||||
"more_one": "+{{count}} weitere",
|
||||
"more_other": "+{{count}} weitere"
|
||||
}
|
||||
},
|
||||
"pageNotFound": {
|
||||
|
||||
@@ -1024,6 +1024,9 @@
|
||||
"start": "Start Chatting",
|
||||
"name": "Name",
|
||||
"choose": "Choose Files",
|
||||
"dropzoneText": "Click to upload or drag and drop",
|
||||
"dropzoneHint": "PDF, Word, Excel, PowerPoint, Markdown, HTML, images, audio, EPUB, JSON or ZIP, up to 25 MB each",
|
||||
"filesRejected": "Not added (over 25 MB or not a supported type): {{files}}",
|
||||
"info": "Please upload .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip limited to 25mb",
|
||||
"uploadedFiles": "Uploaded Files",
|
||||
"cancel": "Cancel",
|
||||
@@ -1585,15 +1588,16 @@
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"sections": {
|
||||
"meta": "Meta",
|
||||
"source": "Source",
|
||||
"prompt": "Prompt",
|
||||
"tools": "Tools",
|
||||
"agentType": "Agent type",
|
||||
"models": "Models",
|
||||
"advanced": "Advanced",
|
||||
"guardrails": "Guardrails",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"basics": "Basics",
|
||||
"knowledge": "Knowledge and behaviour",
|
||||
"model": "Model"
|
||||
},
|
||||
"placeholders": {
|
||||
"agentName": "Agent name",
|
||||
@@ -1640,7 +1644,9 @@
|
||||
"labels": {
|
||||
"defaultModel": "Default Model",
|
||||
"name": "Name",
|
||||
"description": "Description"
|
||||
"description": "Description",
|
||||
"sources": "Sources",
|
||||
"avatar": "Avatar"
|
||||
},
|
||||
"advanced": {
|
||||
"jsonSchema": "JSON response schema",
|
||||
@@ -1657,7 +1663,10 @@
|
||||
"requestLimit": "Request limit"
|
||||
},
|
||||
"preview": {
|
||||
"publishedPreview": "Published agents can be previewed here"
|
||||
"savedVersion": "saved version",
|
||||
"publishTitle": "Publish to preview",
|
||||
"publishDescription": "The preview chats with the published agent. Drafts stay private until you publish.",
|
||||
"unsavedChanges": "You have unsaved changes. The preview uses the saved version until you save."
|
||||
},
|
||||
"dangerZone": {
|
||||
"heading": "Danger zone",
|
||||
@@ -1720,6 +1729,12 @@
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"byline": {
|
||||
"new": "Set up the agent, then publish it to chat with it."
|
||||
},
|
||||
"status": {
|
||||
"published": "Published"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1730,7 +1745,6 @@
|
||||
},
|
||||
"schedules": {
|
||||
"title": "Agent Schedules",
|
||||
"heading": "Schedules",
|
||||
"newRecurring": "New schedule",
|
||||
"closeForm": "Close form",
|
||||
"edit": "Edit",
|
||||
@@ -1828,8 +1842,6 @@
|
||||
"output": "Output",
|
||||
"truncated": "(truncated)"
|
||||
},
|
||||
"timezoneMeta": "tz: {{timezone}}",
|
||||
"nextRunMeta": "next: {{time}}",
|
||||
"toolCard": {
|
||||
"cancelFailed": "Cancel failed",
|
||||
"taskCancelled": "Scheduled task cancelled.",
|
||||
@@ -1872,7 +1884,28 @@
|
||||
"runLog": {
|
||||
"when": "When",
|
||||
"details": "Details",
|
||||
"empty": "No runs recorded for this schedule yet."
|
||||
"empty": "No runs recorded for this schedule yet.",
|
||||
"duration": "Duration"
|
||||
},
|
||||
"actions": "Schedule actions",
|
||||
"cancelTask": "Cancel task",
|
||||
"failureStreak_one": "{{count}} failure in a row",
|
||||
"failureStreak_other": "{{count}} failures in a row",
|
||||
"meta": {
|
||||
"nextRun": "Next run {{time}}",
|
||||
"runsAt": "Runs on {{time}}",
|
||||
"lastRun": "Last run {{time}}"
|
||||
},
|
||||
"stats": {
|
||||
"active": "Active schedules",
|
||||
"nextRun": "Next run",
|
||||
"runs": "Runs, last {{days}} days",
|
||||
"tokens": "{{tokens}} tokens",
|
||||
"failed": "Failed, last {{days}} days",
|
||||
"latestFailure": "Latest: {{status}}, {{date}}",
|
||||
"paused_one": "{{count}} paused",
|
||||
"paused_other": "{{count}} paused",
|
||||
"overdue": "Overdue"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -2176,6 +2209,10 @@
|
||||
},
|
||||
"profile": {
|
||||
"avatarAlt": "User avatar"
|
||||
},
|
||||
"multiSelect": {
|
||||
"more_one": "+{{count}} more",
|
||||
"more_other": "+{{count}} more"
|
||||
}
|
||||
},
|
||||
"pageNotFound": {
|
||||
|
||||
@@ -1018,6 +1018,9 @@
|
||||
"start": "Comenzar a chatear",
|
||||
"name": "Nombre",
|
||||
"choose": "Seleccionar Archivos",
|
||||
"dropzoneText": "Haz clic para subir o arrastra y suelta",
|
||||
"dropzoneHint": "PDF, Word, Excel, PowerPoint, Markdown, HTML, imágenes, audio, EPUB, JSON o ZIP, hasta 25 MB cada uno",
|
||||
"filesRejected": "No se añadieron (más de 25 MB o tipo no admitido): {{files}}",
|
||||
"info": "Por favor, sube archivos .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip limitados a 25MB",
|
||||
"uploadedFiles": "Archivos Subidos",
|
||||
"cancel": "Cancelar",
|
||||
@@ -1568,15 +1571,16 @@
|
||||
"moreActions": "Más acciones"
|
||||
},
|
||||
"sections": {
|
||||
"meta": "Meta",
|
||||
"source": "Fuente",
|
||||
"prompt": "Prompt",
|
||||
"tools": "Herramientas",
|
||||
"agentType": "Tipo de agente",
|
||||
"models": "Modelos",
|
||||
"advanced": "Avanzado",
|
||||
"guardrails": "Guardrails",
|
||||
"preview": "Vista previa"
|
||||
"preview": "Vista previa",
|
||||
"basics": "Básico",
|
||||
"knowledge": "Conocimiento y comportamiento",
|
||||
"model": "Modelo"
|
||||
},
|
||||
"placeholders": {
|
||||
"agentName": "Nombre del agente",
|
||||
@@ -1621,7 +1625,11 @@
|
||||
"research": "Investigación"
|
||||
},
|
||||
"labels": {
|
||||
"defaultModel": "Modelo Predeterminado"
|
||||
"defaultModel": "Modelo Predeterminado",
|
||||
"name": "Nombre",
|
||||
"description": "Descripción",
|
||||
"sources": "Fuentes",
|
||||
"avatar": "Avatar"
|
||||
},
|
||||
"advanced": {
|
||||
"jsonSchema": "Esquema de respuesta JSON",
|
||||
@@ -1636,7 +1644,10 @@
|
||||
"systemPromptOverrideDescription": "Permitir que los llamadores de la API v1 reemplacen el prompt del sistema de este agente"
|
||||
},
|
||||
"preview": {
|
||||
"publishedPreview": "Los agentes publicados se pueden previsualizar aquí"
|
||||
"savedVersion": "versión guardada",
|
||||
"publishTitle": "Publica para probarlo",
|
||||
"publishDescription": "La vista previa chatea con el agente publicado. Los borradores siguen siendo privados hasta que los publiques.",
|
||||
"unsavedChanges": "Tienes cambios sin guardar. La vista previa usa la versión guardada hasta que guardes."
|
||||
},
|
||||
"dangerZone": {
|
||||
"heading": "Zona de peligro",
|
||||
@@ -1699,6 +1710,12 @@
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"byline": {
|
||||
"new": "Configura el agente y publícalo para chatear con él."
|
||||
},
|
||||
"status": {
|
||||
"published": "Publicado"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1709,7 +1726,6 @@
|
||||
},
|
||||
"schedules": {
|
||||
"title": "Programaciones del Agente",
|
||||
"heading": "Programaciones",
|
||||
"newRecurring": "Nueva programación",
|
||||
"closeForm": "Cerrar formulario",
|
||||
"edit": "Editar",
|
||||
@@ -1805,8 +1821,6 @@
|
||||
"output": "Salida",
|
||||
"truncated": "(truncada)"
|
||||
},
|
||||
"timezoneMeta": "zona horaria: {{timezone}}",
|
||||
"nextRunMeta": "próxima: {{time}}",
|
||||
"toolCard": {
|
||||
"cancelFailed": "No se pudo cancelar",
|
||||
"taskCancelled": "Tarea programada cancelada.",
|
||||
@@ -1849,7 +1863,28 @@
|
||||
"runLog": {
|
||||
"when": "Cuándo",
|
||||
"details": "Detalles",
|
||||
"empty": "Aún no hay ejecuciones registradas para esta programación."
|
||||
"empty": "Aún no hay ejecuciones registradas para esta programación.",
|
||||
"duration": "Duración"
|
||||
},
|
||||
"actions": "Acciones de la programación",
|
||||
"cancelTask": "Cancelar tarea",
|
||||
"failureStreak_one": "{{count}} fallo seguido",
|
||||
"failureStreak_other": "{{count}} fallos seguidos",
|
||||
"meta": {
|
||||
"nextRun": "Próxima ejecución {{time}}",
|
||||
"runsAt": "Se ejecuta el {{time}}",
|
||||
"lastRun": "Última ejecución {{time}}"
|
||||
},
|
||||
"stats": {
|
||||
"active": "Programaciones activas",
|
||||
"nextRun": "Próxima ejecución",
|
||||
"runs": "Ejecuciones, últimos {{days}} días",
|
||||
"tokens": "{{tokens}} tokens",
|
||||
"failed": "Fallidas, últimos {{days}} días",
|
||||
"latestFailure": "Última: {{status}}, {{date}}",
|
||||
"paused_one": "{{count}} en pausa",
|
||||
"paused_other": "{{count}} en pausa",
|
||||
"overdue": "Atrasada"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -2140,6 +2175,10 @@
|
||||
},
|
||||
"profile": {
|
||||
"avatarAlt": "Avatar del usuario"
|
||||
},
|
||||
"multiSelect": {
|
||||
"more_one": "+{{count}} más",
|
||||
"more_other": "+{{count}} más"
|
||||
}
|
||||
},
|
||||
"pageNotFound": {
|
||||
|
||||
@@ -1017,6 +1017,9 @@
|
||||
"start": "チャットを開始する",
|
||||
"name": "名前",
|
||||
"choose": "ファイルを選択",
|
||||
"dropzoneText": "クリックしてアップロード、またはドラッグ&ドロップ",
|
||||
"dropzoneHint": "PDF、Word、Excel、PowerPoint、Markdown、HTML、画像、音声、EPUB、JSON、ZIP(各 25 MB まで)",
|
||||
"filesRejected": "追加されませんでした(25 MB を超えているか、未対応の形式です): {{files}}",
|
||||
"info": "25MBまでの.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zipファイルをアップロードしてください",
|
||||
"uploadedFiles": "アップロードされたファイル",
|
||||
"cancel": "キャンセル",
|
||||
@@ -1564,15 +1567,16 @@
|
||||
"moreActions": "その他のアクション"
|
||||
},
|
||||
"sections": {
|
||||
"meta": "メタ",
|
||||
"source": "ソース",
|
||||
"prompt": "プロンプト",
|
||||
"tools": "ツール",
|
||||
"agentType": "エージェントタイプ",
|
||||
"models": "モデル",
|
||||
"advanced": "詳細設定",
|
||||
"guardrails": "Guardrails",
|
||||
"preview": "プレビュー"
|
||||
"preview": "プレビュー",
|
||||
"basics": "基本情報",
|
||||
"knowledge": "ナレッジと動作",
|
||||
"model": "モデル"
|
||||
},
|
||||
"placeholders": {
|
||||
"agentName": "エージェント名",
|
||||
@@ -1617,7 +1621,11 @@
|
||||
"research": "リサーチ"
|
||||
},
|
||||
"labels": {
|
||||
"defaultModel": "デフォルトモデル"
|
||||
"defaultModel": "デフォルトモデル",
|
||||
"name": "名前",
|
||||
"description": "説明",
|
||||
"sources": "ソース",
|
||||
"avatar": "アバター"
|
||||
},
|
||||
"advanced": {
|
||||
"jsonSchema": "JSON応答スキーマ",
|
||||
@@ -1632,7 +1640,10 @@
|
||||
"systemPromptOverrideDescription": "v1 API呼び出し元がこのエージェントのシステムプロンプトを置き換えることを許可します"
|
||||
},
|
||||
"preview": {
|
||||
"publishedPreview": "公開されたエージェントはここでプレビューできます"
|
||||
"savedVersion": "保存済みバージョン",
|
||||
"publishTitle": "公開してプレビュー",
|
||||
"publishDescription": "プレビューは公開済みのエージェントとチャットします。下書きは公開するまで非公開のままです。",
|
||||
"unsavedChanges": "保存されていない変更があります。保存するまで、プレビューは保存済みのバージョンを使います。"
|
||||
},
|
||||
"dangerZone": {
|
||||
"heading": "危険ゾーン",
|
||||
@@ -1694,6 +1705,12 @@
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"byline": {
|
||||
"new": "エージェントを設定し、公開するとチャットできます。"
|
||||
},
|
||||
"status": {
|
||||
"published": "公開済み"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1704,7 +1721,6 @@
|
||||
},
|
||||
"schedules": {
|
||||
"title": "エージェントスケジュール",
|
||||
"heading": "スケジュール",
|
||||
"newRecurring": "新しいスケジュール",
|
||||
"closeForm": "フォームを閉じる",
|
||||
"edit": "編集",
|
||||
@@ -1800,8 +1816,6 @@
|
||||
"output": "出力",
|
||||
"truncated": "(切り詰め)"
|
||||
},
|
||||
"timezoneMeta": "タイムゾーン: {{timezone}}",
|
||||
"nextRunMeta": "次回: {{time}}",
|
||||
"toolCard": {
|
||||
"cancelFailed": "キャンセルに失敗しました",
|
||||
"taskCancelled": "予約タスクをキャンセルしました。",
|
||||
@@ -1844,7 +1858,28 @@
|
||||
"runLog": {
|
||||
"when": "日時",
|
||||
"details": "詳細",
|
||||
"empty": "このスケジュールの実行記録はまだありません。"
|
||||
"empty": "このスケジュールの実行記録はまだありません。",
|
||||
"duration": "所要時間"
|
||||
},
|
||||
"actions": "スケジュールの操作",
|
||||
"cancelTask": "タスクをキャンセル",
|
||||
"failureStreak_one": "{{count}}回連続で失敗",
|
||||
"failureStreak_other": "{{count}}回連続で失敗",
|
||||
"meta": {
|
||||
"nextRun": "次回実行: {{time}}",
|
||||
"runsAt": "実行日時: {{time}}",
|
||||
"lastRun": "前回実行: {{time}}"
|
||||
},
|
||||
"stats": {
|
||||
"active": "有効なスケジュール",
|
||||
"nextRun": "次回実行",
|
||||
"runs": "実行回数(過去{{days}}日)",
|
||||
"tokens": "{{tokens}} トークン",
|
||||
"failed": "失敗(過去{{days}}日)",
|
||||
"latestFailure": "最新: {{status}}、{{date}}",
|
||||
"paused_one": "{{count}}件一時停止中",
|
||||
"paused_other": "{{count}}件一時停止中",
|
||||
"overdue": "期限超過"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -2134,6 +2169,10 @@
|
||||
},
|
||||
"profile": {
|
||||
"avatarAlt": "ユーザーのアバター"
|
||||
},
|
||||
"multiSelect": {
|
||||
"more_one": "他{{count}}件",
|
||||
"more_other": "他{{count}}件"
|
||||
}
|
||||
},
|
||||
"pageNotFound": {
|
||||
|
||||
@@ -1058,6 +1058,9 @@
|
||||
"start": "Начать чат",
|
||||
"name": "Имя",
|
||||
"choose": "Выбрать файлы",
|
||||
"dropzoneText": "Нажмите, чтобы загрузить, или перетащите файлы",
|
||||
"dropzoneHint": "PDF, Word, Excel, PowerPoint, Markdown, HTML, изображения, аудио, EPUB, JSON или ZIP, до 25 МБ каждый",
|
||||
"filesRejected": "Не добавлены (больше 25 МБ или неподдерживаемый формат): {{files}}",
|
||||
"info": "Пожалуйста, загрузите файлы .pdf, .txt, .rst, .csv, .xlsx, .xlsm, .xlsb, .xls, .ods, .docx, .docm, .doc, .odt, .rtf, .md, .html, .xhtml, .png, .jpg, .jpeg, .epub, .json, .pptx, .pptm, .ppt, .pps, .ppsx, .ppsm, .pot, .odp, .zip размером до 25 МБ",
|
||||
"uploadedFiles": "Загруженные файлы",
|
||||
"cancel": "Отмена",
|
||||
@@ -1614,15 +1617,16 @@
|
||||
"moreActions": "Другие действия"
|
||||
},
|
||||
"sections": {
|
||||
"meta": "Мета",
|
||||
"source": "Источник",
|
||||
"prompt": "Промпт",
|
||||
"tools": "Инструменты",
|
||||
"agentType": "Тип агента",
|
||||
"models": "Модели",
|
||||
"advanced": "Расширенные",
|
||||
"guardrails": "Guardrails",
|
||||
"preview": "Предпросмотр"
|
||||
"preview": "Предпросмотр",
|
||||
"basics": "Основное",
|
||||
"knowledge": "Знания и поведение",
|
||||
"model": "Модель"
|
||||
},
|
||||
"placeholders": {
|
||||
"agentName": "Имя агента",
|
||||
@@ -1667,7 +1671,11 @@
|
||||
"research": "Исследование"
|
||||
},
|
||||
"labels": {
|
||||
"defaultModel": "Модель по умолчанию"
|
||||
"defaultModel": "Модель по умолчанию",
|
||||
"name": "Имя",
|
||||
"description": "Описание",
|
||||
"sources": "Источники",
|
||||
"avatar": "Аватар"
|
||||
},
|
||||
"advanced": {
|
||||
"jsonSchema": "Схема ответа JSON",
|
||||
@@ -1682,7 +1690,10 @@
|
||||
"systemPromptOverrideDescription": "Разрешить вызовам API v1 заменять системный промпт этого агента"
|
||||
},
|
||||
"preview": {
|
||||
"publishedPreview": "Опубликованные агенты можно просмотреть здесь"
|
||||
"savedVersion": "сохранённая версия",
|
||||
"publishTitle": "Опубликуйте, чтобы проверить",
|
||||
"publishDescription": "Предпросмотр общается с опубликованным агентом. Черновики остаются приватными, пока вы их не опубликуете.",
|
||||
"unsavedChanges": "Есть несохранённые изменения. Пока вы не сохраните, предпросмотр использует сохранённую версию."
|
||||
},
|
||||
"dangerZone": {
|
||||
"heading": "Опасная зона",
|
||||
@@ -1747,6 +1758,12 @@
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"byline": {
|
||||
"new": "Настройте агента и опубликуйте его, чтобы с ним общаться."
|
||||
},
|
||||
"status": {
|
||||
"published": "Опубликован"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1757,7 +1774,6 @@
|
||||
},
|
||||
"schedules": {
|
||||
"title": "Расписания агента",
|
||||
"heading": "Расписания",
|
||||
"newRecurring": "Новое расписание",
|
||||
"closeForm": "Закрыть форму",
|
||||
"edit": "Редактировать",
|
||||
@@ -1853,8 +1869,6 @@
|
||||
"output": "Результат",
|
||||
"truncated": "(обрезано)"
|
||||
},
|
||||
"timezoneMeta": "часовой пояс: {{timezone}}",
|
||||
"nextRunMeta": "следующий: {{time}}",
|
||||
"toolCard": {
|
||||
"cancelFailed": "Не удалось отменить",
|
||||
"taskCancelled": "Запланированная задача отменена.",
|
||||
@@ -1899,7 +1913,32 @@
|
||||
"runLog": {
|
||||
"when": "Когда",
|
||||
"details": "Подробнее",
|
||||
"empty": "Для этого расписания пока нет запусков."
|
||||
"empty": "Для этого расписания пока нет запусков.",
|
||||
"duration": "Длительность"
|
||||
},
|
||||
"actions": "Действия с расписанием",
|
||||
"cancelTask": "Отменить задачу",
|
||||
"failureStreak_one": "{{count}} сбой подряд",
|
||||
"failureStreak_few": "{{count}} сбоя подряд",
|
||||
"failureStreak_many": "{{count}} сбоев подряд",
|
||||
"failureStreak_other": "{{count}} сбоя подряд",
|
||||
"meta": {
|
||||
"nextRun": "Следующий запуск {{time}}",
|
||||
"runsAt": "Запуск {{time}}",
|
||||
"lastRun": "Последний запуск {{time}}"
|
||||
},
|
||||
"stats": {
|
||||
"active": "Активные расписания",
|
||||
"nextRun": "Следующий запуск",
|
||||
"runs": "Запуски за {{days}} дней",
|
||||
"tokens": "{{tokens}} токенов",
|
||||
"failed": "Сбои за {{days}} дней",
|
||||
"latestFailure": "Последний: {{status}}, {{date}}",
|
||||
"paused_one": "{{count}} приостановлено",
|
||||
"paused_few": "{{count}} приостановлено",
|
||||
"paused_many": "{{count}} приостановлено",
|
||||
"paused_other": "{{count}} приостановлено",
|
||||
"overdue": "Просрочено"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -2198,6 +2237,12 @@
|
||||
},
|
||||
"profile": {
|
||||
"avatarAlt": "Аватар пользователя"
|
||||
},
|
||||
"multiSelect": {
|
||||
"more_one": "+ещё {{count}}",
|
||||
"more_few": "+ещё {{count}}",
|
||||
"more_many": "+ещё {{count}}",
|
||||
"more_other": "+ещё {{count}}"
|
||||
}
|
||||
},
|
||||
"pageNotFound": {
|
||||
|
||||
@@ -1017,6 +1017,9 @@
|
||||
"start": "開始對話",
|
||||
"name": "名稱",
|
||||
"choose": "選擇檔案",
|
||||
"dropzoneText": "點擊上傳或拖放檔案",
|
||||
"dropzoneHint": "PDF、Word、Excel、PowerPoint、Markdown、HTML、圖片、音訊、EPUB、JSON 或 ZIP,每個最大 25 MB",
|
||||
"filesRejected": "未新增(超過 25 MB 或不支援的類型):{{files}}",
|
||||
"info": "請上傳限制為25MB的.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zip檔案",
|
||||
"uploadedFiles": "已上傳檔案",
|
||||
"cancel": "取消",
|
||||
@@ -1564,15 +1567,16 @@
|
||||
"moreActions": "更多操作"
|
||||
},
|
||||
"sections": {
|
||||
"meta": "中繼資料",
|
||||
"source": "來源",
|
||||
"prompt": "提示詞",
|
||||
"tools": "工具",
|
||||
"agentType": "代理類型",
|
||||
"models": "模型",
|
||||
"advanced": "進階",
|
||||
"guardrails": "Guardrails",
|
||||
"preview": "預覽"
|
||||
"preview": "預覽",
|
||||
"basics": "基本資訊",
|
||||
"knowledge": "知識與行為",
|
||||
"model": "模型"
|
||||
},
|
||||
"placeholders": {
|
||||
"agentName": "代理名稱",
|
||||
@@ -1617,7 +1621,11 @@
|
||||
"research": "研究"
|
||||
},
|
||||
"labels": {
|
||||
"defaultModel": "預設模型"
|
||||
"defaultModel": "預設模型",
|
||||
"name": "名稱",
|
||||
"description": "描述",
|
||||
"sources": "來源",
|
||||
"avatar": "頭像"
|
||||
},
|
||||
"advanced": {
|
||||
"jsonSchema": "JSON回應架構",
|
||||
@@ -1632,7 +1640,10 @@
|
||||
"systemPromptOverrideDescription": "允許 v1 API 呼叫者替換此代理的系統提示詞"
|
||||
},
|
||||
"preview": {
|
||||
"publishedPreview": "已發佈的代理可以在此處預覽"
|
||||
"savedVersion": "已儲存版本",
|
||||
"publishTitle": "發佈後即可預覽",
|
||||
"publishDescription": "預覽會與已發佈的代理對話。草稿在發佈前會保持私密。",
|
||||
"unsavedChanges": "你有未儲存的變更。儲存之前,預覽會使用已儲存的版本。"
|
||||
},
|
||||
"dangerZone": {
|
||||
"heading": "危險區域",
|
||||
@@ -1694,6 +1705,12 @@
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"byline": {
|
||||
"new": "設定好代理後發佈,即可與它對話。"
|
||||
},
|
||||
"status": {
|
||||
"published": "已發佈"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1704,7 +1721,6 @@
|
||||
},
|
||||
"schedules": {
|
||||
"title": "代理排程",
|
||||
"heading": "排程",
|
||||
"newRecurring": "新增排程",
|
||||
"closeForm": "關閉表單",
|
||||
"edit": "編輯",
|
||||
@@ -1800,8 +1816,6 @@
|
||||
"output": "輸出",
|
||||
"truncated": "(已截斷)"
|
||||
},
|
||||
"timezoneMeta": "時區:{{timezone}}",
|
||||
"nextRunMeta": "下次:{{time}}",
|
||||
"toolCard": {
|
||||
"cancelFailed": "取消失敗",
|
||||
"taskCancelled": "已取消排程工作。",
|
||||
@@ -1844,7 +1858,28 @@
|
||||
"runLog": {
|
||||
"when": "時間",
|
||||
"details": "詳細資料",
|
||||
"empty": "此排程尚無執行紀錄。"
|
||||
"empty": "此排程尚無執行紀錄。",
|
||||
"duration": "時長"
|
||||
},
|
||||
"actions": "排程操作",
|
||||
"cancelTask": "取消任務",
|
||||
"failureStreak_one": "連續失敗 {{count}} 次",
|
||||
"failureStreak_other": "連續失敗 {{count}} 次",
|
||||
"meta": {
|
||||
"nextRun": "下次執行:{{time}}",
|
||||
"runsAt": "執行時間:{{time}}",
|
||||
"lastRun": "上次執行:{{time}}"
|
||||
},
|
||||
"stats": {
|
||||
"active": "啟用中的排程",
|
||||
"nextRun": "下次執行",
|
||||
"runs": "執行次數(近 {{days}} 天)",
|
||||
"tokens": "{{tokens}} 個權杖",
|
||||
"failed": "失敗(近 {{days}} 天)",
|
||||
"latestFailure": "最近:{{status}},{{date}}",
|
||||
"paused_one": "{{count}} 個已暫停",
|
||||
"paused_other": "{{count}} 個已暫停",
|
||||
"overdue": "已逾期"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -2134,6 +2169,10 @@
|
||||
},
|
||||
"profile": {
|
||||
"avatarAlt": "使用者頭像"
|
||||
},
|
||||
"multiSelect": {
|
||||
"more_one": "另外 {{count}} 個",
|
||||
"more_other": "另外 {{count}} 個"
|
||||
}
|
||||
},
|
||||
"pageNotFound": {
|
||||
|
||||
@@ -1017,6 +1017,9 @@
|
||||
"start": "开始聊天",
|
||||
"name": "名称",
|
||||
"choose": "选择文件",
|
||||
"dropzoneText": "点击上传或拖放文件",
|
||||
"dropzoneHint": "PDF、Word、Excel、PowerPoint、Markdown、HTML、图片、音频、EPUB、JSON 或 ZIP,每个最大 25 MB",
|
||||
"filesRejected": "未添加(超过 25 MB 或不支持的类型):{{files}}",
|
||||
"info": "请上传限制为25MB的.pdf、.txt、.rst、.csv、.xlsx、.xlsm、.xlsb、.xls、.ods、.docx、.docm、.doc、.odt、.rtf、.md、.html、.xhtml、.png、.jpg、.jpeg、.epub、.json、.pptx、.pptm、.ppt、.pps、.ppsx、.ppsm、.pot、.odp、.zip文件",
|
||||
"uploadedFiles": "已上传文件",
|
||||
"cancel": "取消",
|
||||
@@ -1564,15 +1567,16 @@
|
||||
"moreActions": "更多操作"
|
||||
},
|
||||
"sections": {
|
||||
"meta": "元数据",
|
||||
"source": "来源",
|
||||
"prompt": "提示词",
|
||||
"tools": "工具",
|
||||
"agentType": "代理类型",
|
||||
"models": "模型",
|
||||
"advanced": "高级",
|
||||
"guardrails": "Guardrails",
|
||||
"preview": "预览"
|
||||
"preview": "预览",
|
||||
"basics": "基本信息",
|
||||
"knowledge": "知识与行为",
|
||||
"model": "模型"
|
||||
},
|
||||
"placeholders": {
|
||||
"agentName": "代理名称",
|
||||
@@ -1617,7 +1621,11 @@
|
||||
"research": "研究"
|
||||
},
|
||||
"labels": {
|
||||
"defaultModel": "默认模型"
|
||||
"defaultModel": "默认模型",
|
||||
"name": "名称",
|
||||
"description": "描述",
|
||||
"sources": "来源",
|
||||
"avatar": "头像"
|
||||
},
|
||||
"advanced": {
|
||||
"jsonSchema": "JSON响应架构",
|
||||
@@ -1632,7 +1640,10 @@
|
||||
"systemPromptOverrideDescription": "允许 v1 API 调用者替换此代理的系统提示词"
|
||||
},
|
||||
"preview": {
|
||||
"publishedPreview": "已发布的代理可以在此处预览"
|
||||
"savedVersion": "已保存版本",
|
||||
"publishTitle": "发布后即可预览",
|
||||
"publishDescription": "预览会与已发布的智能体对话。草稿在发布前保持私密。",
|
||||
"unsavedChanges": "你有未保存的更改。保存之前,预览使用已保存的版本。"
|
||||
},
|
||||
"dangerZone": {
|
||||
"heading": "危险区域",
|
||||
@@ -1694,6 +1705,12 @@
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"byline": {
|
||||
"new": "设置好智能体后发布,即可与它对话。"
|
||||
},
|
||||
"status": {
|
||||
"published": "已发布"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1704,7 +1721,6 @@
|
||||
},
|
||||
"schedules": {
|
||||
"title": "代理计划",
|
||||
"heading": "计划",
|
||||
"newRecurring": "新建计划",
|
||||
"closeForm": "关闭表单",
|
||||
"edit": "编辑",
|
||||
@@ -1800,8 +1816,6 @@
|
||||
"output": "输出",
|
||||
"truncated": "(已截断)"
|
||||
},
|
||||
"timezoneMeta": "时区:{{timezone}}",
|
||||
"nextRunMeta": "下次:{{time}}",
|
||||
"toolCard": {
|
||||
"cancelFailed": "取消失败",
|
||||
"taskCancelled": "已取消计划任务。",
|
||||
@@ -1844,7 +1858,28 @@
|
||||
"runLog": {
|
||||
"when": "时间",
|
||||
"details": "详情",
|
||||
"empty": "此计划尚无运行记录。"
|
||||
"empty": "此计划尚无运行记录。",
|
||||
"duration": "时长"
|
||||
},
|
||||
"actions": "计划操作",
|
||||
"cancelTask": "取消任务",
|
||||
"failureStreak_one": "连续失败 {{count}} 次",
|
||||
"failureStreak_other": "连续失败 {{count}} 次",
|
||||
"meta": {
|
||||
"nextRun": "下次运行:{{time}}",
|
||||
"runsAt": "运行时间:{{time}}",
|
||||
"lastRun": "上次运行:{{time}}"
|
||||
},
|
||||
"stats": {
|
||||
"active": "活跃的计划",
|
||||
"nextRun": "下次运行",
|
||||
"runs": "运行次数(近 {{days}} 天)",
|
||||
"tokens": "{{tokens}} 个令牌",
|
||||
"failed": "失败(近 {{days}} 天)",
|
||||
"latestFailure": "最近:{{status}},{{date}}",
|
||||
"paused_one": "{{count}} 个已暂停",
|
||||
"paused_other": "{{count}} 个已暂停",
|
||||
"overdue": "已逾期"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -2134,6 +2169,10 @@
|
||||
},
|
||||
"profile": {
|
||||
"avatarAlt": "用户头像"
|
||||
},
|
||||
"multiSelect": {
|
||||
"more_one": "另外 {{count}} 个",
|
||||
"more_other": "另外 {{count}} 个"
|
||||
}
|
||||
},
|
||||
"pageNotFound": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
@@ -59,22 +60,39 @@ export function SectionBackLink({
|
||||
export default function SectionPageHeader({
|
||||
section,
|
||||
item,
|
||||
title,
|
||||
titleAction,
|
||||
className,
|
||||
}: {
|
||||
section: Section;
|
||||
item: SectionItem | null;
|
||||
/** Replaces the resolved title. */
|
||||
title?: React.ReactNode;
|
||||
/** A control at the end of the title row. */
|
||||
titleAction?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const heading = (
|
||||
<h1 className="text-foreground text-2xl font-bold">
|
||||
{title ??
|
||||
(item && section.pageTitle !== 'section'
|
||||
? t(item.labelKey)
|
||||
: (section.title ?? t(section.titleKey)))}
|
||||
</h1>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<SectionBackLink section={section} />
|
||||
<h1 className="text-foreground text-2xl font-bold">
|
||||
{item && section.pageTitle !== 'section'
|
||||
? t(item.labelKey)
|
||||
: (section.title ?? t(section.titleKey))}
|
||||
</h1>
|
||||
{titleAction ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{heading}
|
||||
{titleAction}
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,11 +102,25 @@ export default function SectionPageHeader({
|
||||
* from resolving its own section, and keeps the heading identical across
|
||||
* settings, admin and agents.
|
||||
*/
|
||||
export function CurrentSectionHeader({ className }: { className?: string }) {
|
||||
export function CurrentSectionHeader({
|
||||
title,
|
||||
titleAction,
|
||||
className,
|
||||
}: {
|
||||
title?: React.ReactNode;
|
||||
titleAction?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { section, item } = useSectionContext();
|
||||
|
||||
if (!section) return null;
|
||||
return (
|
||||
<SectionPageHeader section={section} item={item} className={className} />
|
||||
<SectionPageHeader
|
||||
section={section}
|
||||
item={item}
|
||||
title={title}
|
||||
titleAction={titleAction}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,10 @@ import { useSectionContext } from './useSectionContext';
|
||||
*
|
||||
* Sections whose destinations are separate pages (settings, admin) use
|
||||
* `SectionIndexPage` instead. This is for sections whose destinations are
|
||||
* views of the page you are already on — the agent list's filters, an
|
||||
* agent's own pages — where bouncing out to a menu to switch would be worse
|
||||
* than a row of pills.
|
||||
* views of the page you are already on — the agent list's filters — where
|
||||
* bouncing out to a menu to switch would be worse than a row of pills. An
|
||||
* agent's own pages don't use it: the sidebar and the agent tile's menu
|
||||
* already move between them.
|
||||
*/
|
||||
export default function SectionPills({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-redux', () => ({ useSelector: () => null }));
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock('@/hooks', () => ({ useMediaQuery: () => ({ isMobile: false }) }));
|
||||
|
||||
import SectionShell from './SectionShell';
|
||||
|
||||
describe('SectionShell', () => {
|
||||
it('draws a title override in place of the section title', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={['/agents/manage/new']}>
|
||||
<SectionShell title="New agent">
|
||||
<p>body</p>
|
||||
</SectionShell>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(html).toMatch(/<h1[^>]*>New agent<\/h1>/);
|
||||
expect(html).toContain('body');
|
||||
});
|
||||
|
||||
// A page-level menu (the agent Overview's ⋯) sits at the title row's end.
|
||||
it('puts a title action at the end of the title row', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={['/agents/manage/new']}>
|
||||
<SectionShell
|
||||
title="Overview"
|
||||
titleAction={<button type="button">menu</button>}
|
||||
>
|
||||
<p>body</p>
|
||||
</SectionShell>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<div class="flex items-center justify-between gap-3"><h1[^>]*>Overview<\/h1><button type="button">menu<\/button><\/div>/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { CurrentSectionHeader } from './SectionPageHeader';
|
||||
import SectionPills from './SectionPills';
|
||||
|
||||
const WIDTH_CLASSES = {
|
||||
default: 'max-w-6xl',
|
||||
@@ -14,19 +13,23 @@ const WIDTH_CLASSES = {
|
||||
|
||||
/**
|
||||
* The frame of every section page (settings, admin, agents, teams): the
|
||||
* scroll container, the centred column, the section title and, with `pills`,
|
||||
* the phone/tablet destination pills. Content starts 32px under the title.
|
||||
* scroll container, the centred column and the section title. Content starts
|
||||
* 32px under the title.
|
||||
*/
|
||||
export default function SectionShell({
|
||||
width = 'default',
|
||||
pills = false,
|
||||
header = true,
|
||||
title,
|
||||
titleAction,
|
||||
children,
|
||||
}: {
|
||||
width?: keyof typeof WIDTH_CLASSES;
|
||||
pills?: boolean;
|
||||
/** False for the phone section index, which draws its own title. */
|
||||
header?: boolean;
|
||||
/** Replaces the section's own title (the new-agent form, which has no nav item). */
|
||||
title?: ReactNode;
|
||||
/** A page-level control at the title row's end (the agent Overview's ⋯). */
|
||||
titleAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -34,8 +37,7 @@ export default function SectionShell({
|
||||
<div className={cn('mx-auto w-full', WIDTH_CLASSES[width])}>
|
||||
{header ? (
|
||||
<>
|
||||
<CurrentSectionHeader />
|
||||
{pills ? <SectionPills className="mt-4" /> : null}
|
||||
<CurrentSectionHeader title={title} titleAction={titleAction} />
|
||||
<div className="mt-8">{children}</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -124,6 +124,27 @@ describe('Prompts', () => {
|
||||
).toBe('Prompt');
|
||||
});
|
||||
|
||||
it('labels the picker with a floating FormField label with titleAs="field"', () => {
|
||||
renderPrompts({
|
||||
titleAs: 'field',
|
||||
title: 'Prompt',
|
||||
labelSurface: 'background',
|
||||
showAddButton: false,
|
||||
});
|
||||
expect(container.querySelector('[data-slot="section-header"]')).toBeNull();
|
||||
const label = container.querySelector<HTMLLabelElement>(
|
||||
'[data-slot="form-field-label"]',
|
||||
)!;
|
||||
expect(label.textContent).toBe('Prompt');
|
||||
expect(label.className).toContain('bg-background');
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[role="combobox"]',
|
||||
)!;
|
||||
expect(label.htmlFor).toBe(trigger.id);
|
||||
expect(trigger.hasAttribute('aria-label')).toBe(false);
|
||||
expect(trigger.className).toContain('w-full');
|
||||
});
|
||||
|
||||
it('makes Add a neutral outline pill sized to the field row', () => {
|
||||
renderPrompts();
|
||||
const add = Array.from(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommandList,
|
||||
} from '../components/ui/command';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { FormField } from '../components/ui/form-field';
|
||||
import { IconButton } from '../components/ui/icon-button';
|
||||
import {
|
||||
Popover,
|
||||
@@ -35,9 +36,13 @@ type ExtendedPromptProps = PromptProps & {
|
||||
title?: string;
|
||||
/**
|
||||
* `row` (Settings → General): a SettingRow whose label names the picker.
|
||||
* `heading` (the agent form): the title is a section heading above it.
|
||||
* `heading`: the title is a section heading above it.
|
||||
* `field` (the agent form): the title is the picker's floating FormField
|
||||
* label, like the fields around it.
|
||||
*/
|
||||
titleAs?: 'row' | 'heading';
|
||||
titleAs?: 'row' | 'heading' | 'field';
|
||||
/** The surface behind a `field` picker, for its label's notch. */
|
||||
labelSurface?: 'card' | 'background' | 'muted';
|
||||
/** The row's muted description, for `titleAs="row"`. */
|
||||
description?: string;
|
||||
dropdownProps?: PromptsDropdownProps;
|
||||
@@ -54,6 +59,7 @@ export default function Prompts({
|
||||
description,
|
||||
dropdownProps = {},
|
||||
showAddButton = true,
|
||||
labelSurface = 'card',
|
||||
}: ExtendedPromptProps) {
|
||||
const token = useSelector(selectToken);
|
||||
const { t } = useTranslation();
|
||||
@@ -448,6 +454,21 @@ export default function Prompts({
|
||||
{addButton}
|
||||
</div>
|
||||
</SettingRow>
|
||||
) : titleAs === 'field' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<FormField
|
||||
id={pickerId}
|
||||
label={titleText}
|
||||
labelSurface={labelSurface}
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
{picker}
|
||||
</FormField>
|
||||
{editButton}
|
||||
</div>
|
||||
{addButton}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SectionHeader as="h2" title={titleText} />
|
||||
|
||||
@@ -336,7 +336,7 @@ export default function RemoteDeviceConfig({ tool, handleGoBack }: Props) {
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label={t('settings.devices.deviceIdLabel')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="text-foreground bg-muted max-w-full truncate rounded px-2 py-0.5 font-mono text-xs">
|
||||
<code className="text-foreground bg-muted max-w-full truncate rounded-md px-2 py-0.5 font-mono text-xs">
|
||||
{deviceId || '-'}
|
||||
</code>
|
||||
{deviceId && <CopyButton textToCopy={deviceId} />}
|
||||
|
||||
@@ -598,7 +598,6 @@ export default function Sources({
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
title={t('settings.sources.addSource')}
|
||||
onClick={() => {
|
||||
setIsOnboarding(false);
|
||||
setModalState('ACTIVE');
|
||||
|
||||
@@ -351,7 +351,7 @@ describe('ToolConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the API property tables from the ui/table parts with 50px delete columns', async () => {
|
||||
it('renders the API property tables from the ui/table parts with 40px delete columns', async () => {
|
||||
await render(apiTool);
|
||||
await act(async () => {
|
||||
(
|
||||
@@ -373,7 +373,7 @@ describe('ToolConfig', () => {
|
||||
.querySelector('table button[data-variant="ghost-destructive"]')
|
||||
?.closest('td');
|
||||
expect(deleteCell?.dataset.slot).toBe('table-cell');
|
||||
expect(deleteCell?.style.getPropertyValue('--cell-width')).toBe('50px');
|
||||
expect(deleteCell?.style.getPropertyValue('--cell-width')).toBe('40px');
|
||||
expect(deleteCell?.className).toContain('text-center');
|
||||
expect(deleteCell?.className).not.toMatch(/p-0|!/);
|
||||
});
|
||||
|
||||
@@ -1548,7 +1548,7 @@ function APIActionTable({
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell width="50px" align="center">
|
||||
<TableCell width="40px" align="center">
|
||||
<IconButton
|
||||
label={t('convTile.delete')}
|
||||
icon={Trash2}
|
||||
@@ -1613,7 +1613,7 @@ function APIActionTable({
|
||||
{t('settings.tools.cancel')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell width="50px" align="center"></TableCell>
|
||||
<TableCell width="40px" align="center"></TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow>
|
||||
@@ -1628,7 +1628,7 @@ function APIActionTable({
|
||||
{t('settings.tools.addNew')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell width="50px" align="center"></TableCell>
|
||||
<TableCell width="40px" align="center"></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
@@ -1715,7 +1715,7 @@ function APIActionTable({
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell width="50px" align="center">
|
||||
<TableCell width="40px" align="center">
|
||||
<IconButton
|
||||
label={t('convTile.delete')}
|
||||
icon={Trash2}
|
||||
@@ -1764,7 +1764,7 @@ function APIActionTable({
|
||||
{t('settings.tools.cancel')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell width="50px" align="center"></TableCell>
|
||||
<TableCell width="40px" align="center"></TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow>
|
||||
@@ -1779,7 +1779,7 @@ function APIActionTable({
|
||||
{t('settings.tools.addNew')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell width="50px" align="center"></TableCell>
|
||||
<TableCell width="40px" align="center"></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
@@ -1797,7 +1797,7 @@ function APIActionTable({
|
||||
<TableHeader>{t('settings.tools.name')}</TableHeader>
|
||||
<TableHeader>{t('settings.tools.value')}</TableHeader>
|
||||
<TableHeader>{t('settings.tools.description')}</TableHeader>
|
||||
<TableHeader width="50px" align="center"></TableHeader>
|
||||
<TableHeader width="40px" align="center"></TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{renderHeadersTable()}</TableBody>
|
||||
@@ -1819,7 +1819,7 @@ function APIActionTable({
|
||||
<TableHeader>{t('settings.tools.filledByLLM')}</TableHeader>
|
||||
<TableHeader>{t('settings.tools.description')}</TableHeader>
|
||||
<TableHeader>{t('settings.tools.value')}</TableHeader>
|
||||
<TableHeader width="50px" align="center"></TableHeader>
|
||||
<TableHeader width="40px" align="center"></TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{renderPropertiesTable('query_params')}</TableBody>
|
||||
@@ -1837,7 +1837,7 @@ function APIActionTable({
|
||||
<TableHeader>{t('settings.tools.filledByLLM')}</TableHeader>
|
||||
<TableHeader>{t('settings.tools.description')}</TableHeader>
|
||||
<TableHeader>{t('settings.tools.value')}</TableHeader>
|
||||
<TableHeader width="50px" align="center"></TableHeader>
|
||||
<TableHeader width="40px" align="center"></TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{renderPropertiesTable('body')}</TableBody>
|
||||
|
||||
@@ -84,7 +84,8 @@ export default function TraceSheet({
|
||||
side="right"
|
||||
title={t('settings.logs.trace.title')}
|
||||
aria-describedby={undefined}
|
||||
className="w-full overflow-y-auto sm:max-w-3xl"
|
||||
size="wide"
|
||||
className="overflow-y-auto"
|
||||
>
|
||||
<div className="flex flex-col gap-6 px-4 pt-4 pb-6">
|
||||
{loading && <LoadingState fill="block" />}
|
||||
|
||||
@@ -99,3 +99,131 @@ describe('Upload source-type tiles', () => {
|
||||
expect(train!.className).toContain('bg-primary');
|
||||
});
|
||||
});
|
||||
|
||||
function makeFile(name: string, type: string, bytes = 10): File {
|
||||
return new File([new Uint8Array(bytes)], name, { type });
|
||||
}
|
||||
|
||||
describe('Upload local file step', () => {
|
||||
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 openLocalFile = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Upload
|
||||
receivedFile={[]}
|
||||
setModalState={vi.fn()}
|
||||
isOnboarding={false}
|
||||
renderTab={null}
|
||||
close={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const tile = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-slot="option-card"]',
|
||||
),
|
||||
).find((el) =>
|
||||
el.textContent?.includes('modals.uploadDoc.ingestors.local_file.label'),
|
||||
)!;
|
||||
await act(async () => tile.click());
|
||||
};
|
||||
|
||||
const pick = async (files: File[]) => {
|
||||
const input = document.body.querySelector(
|
||||
'[data-slot="dropzone"] input[type="file"]',
|
||||
);
|
||||
if (!input) throw new Error('no dropzone 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));
|
||||
});
|
||||
};
|
||||
|
||||
it('renders the file field as a default-size Dropzone', async () => {
|
||||
await openLocalFile();
|
||||
const zone = document.body.querySelector('[data-slot="dropzone"]');
|
||||
expect(zone).not.toBeNull();
|
||||
expect(zone!.getAttribute('data-size')).toBe('default');
|
||||
expect(zone!.textContent).toContain('modals.uploadDoc.dropzoneText');
|
||||
expect(zone!.textContent).toContain('modals.uploadDoc.dropzoneHint');
|
||||
expect(document.body.textContent).not.toContain('modals.uploadDoc.choose');
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'modals.uploadDoc.selectedFiles',
|
||||
);
|
||||
// No list until something is picked.
|
||||
expect(document.body.querySelector('[data-slot="list-rows"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists picked files as rows with their size and fills the name', async () => {
|
||||
await openLocalFile();
|
||||
await pick([
|
||||
makeFile('rates.pdf', 'application/pdf', 2048),
|
||||
makeFile('notes.md', 'text/x-markdown', 10),
|
||||
]);
|
||||
const rows = Array.from(
|
||||
document.body.querySelectorAll('[data-slot="list-row"]'),
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('rates.pdf');
|
||||
expect(rows[0].textContent).toContain('2 KB');
|
||||
expect(rows[1].textContent).toContain('notes.md');
|
||||
const card = rows[0].closest('[data-slot="card"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.getAttribute('data-variant')).toBe('outline');
|
||||
const textInputs = Array.from(
|
||||
document.body.querySelectorAll<HTMLInputElement>('input[type="text"]'),
|
||||
);
|
||||
expect(textInputs.some((el) => el.value === 'rates.pdf')).toBe(true);
|
||||
const train = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('button'),
|
||||
).find((b) => b.textContent === 'modals.uploadDoc.train');
|
||||
expect(train!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces the selection on a new drop', async () => {
|
||||
await openLocalFile();
|
||||
await pick([makeFile('a.pdf', 'application/pdf')]);
|
||||
await pick([makeFile('b.pdf', 'application/pdf')]);
|
||||
const rows = document.body.querySelectorAll('[data-slot="list-row"]');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].textContent).toContain('b.pdf');
|
||||
});
|
||||
|
||||
it('reports files that are too large or of an unsupported type', async () => {
|
||||
await openLocalFile();
|
||||
const big = makeFile('huge.pdf', 'application/pdf');
|
||||
Object.defineProperty(big, 'size', { value: 26_000_000 });
|
||||
await pick([
|
||||
big,
|
||||
makeFile('setup.exe', 'application/x-msdownload'),
|
||||
makeFile('ok.pdf', 'application/pdf'),
|
||||
]);
|
||||
const rows = document.body.querySelectorAll('[data-slot="list-row"]');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].textContent).toContain('ok.pdf');
|
||||
const zone = document.body.querySelector('[data-slot="dropzone"]')!;
|
||||
expect(zone.parentElement!.querySelector('p')?.textContent).toBe(
|
||||
'modals.uploadDoc.filesRejected',
|
||||
);
|
||||
// A clean drop clears the message.
|
||||
await pick([makeFile('next.pdf', 'application/pdf')]);
|
||||
expect(zone.parentElement!.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { ChevronLeft, FileText } from 'lucide-react';
|
||||
import { envVar } from '@/env';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import type { FileRejection } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
|
||||
@@ -28,6 +28,10 @@ import { Textarea } from '../components/ui/textarea';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { Separator } from '../components/ui/separator';
|
||||
import { OptionCard } from '../components/ui/option-card';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Dropzone } from '../components/ui/dropzone';
|
||||
import { ListRow, ListRows } from '../components/ui/list-row';
|
||||
import { formatBytes } from '../components/artifactViewUtils';
|
||||
import { ActiveState, Doc } from '../models/misc';
|
||||
|
||||
import { getDocs } from '../preferences/preferenceApi';
|
||||
@@ -57,6 +61,9 @@ import RetrievalOptions, {
|
||||
type RetrievalOptionsValue,
|
||||
} from '../settings/components/RetrievalOptions';
|
||||
|
||||
/** Per-file limit for local uploads (25 MB), enforced by the dropzone. */
|
||||
const MAX_UPLOAD_BYTES = 25000000;
|
||||
|
||||
function Upload({
|
||||
receivedFile = [],
|
||||
setModalState,
|
||||
@@ -84,6 +91,9 @@ function Upload({
|
||||
const selectedDocs = useSelector(selectSelectedDocs);
|
||||
|
||||
const [files, setfiles] = useState<File[]>(receivedFile);
|
||||
// Names of the files the last drop turned away (over the size limit or of
|
||||
// an unaccepted type), shown under the dropzone.
|
||||
const [rejectedFiles, setRejectedFiles] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<boolean>(true);
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [retrievalOptions, setRetrievalOptions] =
|
||||
@@ -293,34 +303,45 @@ function Upload({
|
||||
);
|
||||
case 'local_file_picker':
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<div className="mb-3" {...getRootProps()}>
|
||||
<span className="text-primary dark:text-muted-foreground border-border inline-block rounded-3xl border bg-transparent px-4 py-2 font-medium hover:cursor-pointer">
|
||||
<input type="button" {...getInputProps()} />
|
||||
{t('modals.uploadDoc.choose')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 max-w-full">
|
||||
<p className="text-foreground mb-3.5 text-sm font-medium">
|
||||
{t('modals.uploadDoc.selectedFiles')}
|
||||
</p>
|
||||
<div className="max-w-full overflow-hidden">
|
||||
{files.map((file) => (
|
||||
<p
|
||||
key={file.name}
|
||||
className="text-muted-foreground truncate"
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</p>
|
||||
))}
|
||||
{files.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('modals.uploadDoc.noFilesSelected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div key={field.name} className="flex flex-col gap-3">
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
multiple
|
||||
accept={FILE_UPLOAD_ACCEPT}
|
||||
maxSize={MAX_UPLOAD_BYTES}
|
||||
title={t('modals.uploadDoc.dropzoneText')}
|
||||
description={t('modals.uploadDoc.dropzoneHint')}
|
||||
error={
|
||||
rejectedFiles.length > 0
|
||||
? t('modals.uploadDoc.filesRejected', {
|
||||
files: rejectedFiles.join(', '),
|
||||
// React escapes the text; i18next must not do it twice.
|
||||
interpolation: { escapeValue: false },
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<Card variant="outline" padding="none">
|
||||
<ListRows>
|
||||
{files.map((file) => (
|
||||
<ListRow
|
||||
key={`${file.name}-${file.size}-${file.lastModified}`}
|
||||
leading={
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</span>
|
||||
}
|
||||
title={<span title={file.name}>{file.name}</span>}
|
||||
description={formatBytes(file.size)}
|
||||
/>
|
||||
))}
|
||||
</ListRows>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'remote_file_picker':
|
||||
@@ -417,6 +438,7 @@ function Upload({
|
||||
const resetUploaderState = useCallback(() => {
|
||||
setIngestor({ type: null, name: '', config: {} });
|
||||
setfiles([]);
|
||||
setRejectedFiles([]);
|
||||
setSelectedFiles([]);
|
||||
setSelectedFolders([]);
|
||||
setShowAdvancedOptions(false);
|
||||
@@ -580,8 +602,9 @@ function Upload({
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
(acceptedFiles: File[], rejections: FileRejection[] = []) => {
|
||||
setfiles(acceptedFiles);
|
||||
setRejectedFiles(rejections.map((rejection) => rejection.file.name));
|
||||
const pickedName = acceptedFiles[0]?.name;
|
||||
if (!nameTouched && pickedName) {
|
||||
setIngestor((prev) => ({ ...prev, name: pickedName }));
|
||||
@@ -601,8 +624,6 @@ function Upload({
|
||||
[ingestor.type, nameTouched],
|
||||
);
|
||||
|
||||
const doNothing = () => undefined;
|
||||
|
||||
const uploadFile = (clientTaskId: string) => {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
@@ -902,16 +923,6 @@ function Upload({
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
multiple: true,
|
||||
onDragEnter: doNothing,
|
||||
onDragOver: doNothing,
|
||||
onDragLeave: doNothing,
|
||||
maxSize: 25000000,
|
||||
accept: FILE_UPLOAD_ACCEPT,
|
||||
});
|
||||
|
||||
const isUploadDisabled = (): boolean => {
|
||||
if (!activeTab) return true;
|
||||
|
||||
@@ -1008,6 +1019,7 @@ function Upload({
|
||||
config: {},
|
||||
});
|
||||
setfiles([]);
|
||||
setRejectedFiles([]);
|
||||
setNameTouched(false);
|
||||
return;
|
||||
}
|
||||
@@ -1023,6 +1035,7 @@ function Upload({
|
||||
// Clear files if switching away from local_file
|
||||
if (type !== 'local_file') {
|
||||
setfiles([]);
|
||||
setRejectedFiles([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -52,6 +52,23 @@ describe('formatRelative', () => {
|
||||
expect(formatRelative('garbage', { now: NOW })).toBeNull();
|
||||
});
|
||||
|
||||
it('words a future time as "in …" when asked (the next scheduled run)', async () => {
|
||||
const { formatRelative } = await import('./dateTimeUtils');
|
||||
const ahead = (ms: number) => new Date(NOW + ms).toISOString();
|
||||
const opts = { now: NOW, locale: 'en', future: true };
|
||||
expect(formatRelative(ahead(18 * 3_600_000), opts)).toBe('in 18 hours');
|
||||
// Clock skew under a minute still reads "now".
|
||||
expect(formatRelative(ahead(20_000), opts)).toBe('now');
|
||||
});
|
||||
|
||||
it('clamps a future time to "now" by default (server clock skew)', async () => {
|
||||
const { formatRelative } = await import('./dateTimeUtils');
|
||||
const ahead = (ms: number) => new Date(NOW + ms).toISOString();
|
||||
expect(formatRelative(ahead(3 * 60_000), { now: NOW, locale: 'en' })).toBe(
|
||||
'now',
|
||||
);
|
||||
});
|
||||
|
||||
it('words the gap with Intl in the given language', async () => {
|
||||
const { formatRelative } = await import('./dateTimeUtils');
|
||||
expect(formatRelative(ago(20_000), { now: NOW, locale: 'en' })).toBe('now');
|
||||
|
||||
@@ -92,26 +92,36 @@ const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* "3 minutes ago", "yesterday", "now" in the UI language.
|
||||
* "3 minutes ago", "yesterday", "now", "in 18 hours" in the UI language.
|
||||
*
|
||||
* Args:
|
||||
* value: an ISO timestamp.
|
||||
* options.now: the reference time (tests).
|
||||
* options.locale: an app language code; defaults to the current one.
|
||||
* options.dateAfterDays: past this many days, show the date instead.
|
||||
* options.future: word future times as "in …"; otherwise they read "now"
|
||||
* (a past-event field under server clock skew).
|
||||
*
|
||||
* Returns:
|
||||
* The phrase, or null when the value is empty or unparseable.
|
||||
*/
|
||||
export function formatRelative(
|
||||
value: string | null | undefined,
|
||||
options: { now?: number; locale?: string; dateAfterDays?: number } = {},
|
||||
options: {
|
||||
now?: number;
|
||||
locale?: string;
|
||||
dateAfterDays?: number;
|
||||
future?: boolean;
|
||||
} = {},
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
const then = Date.parse(value);
|
||||
if (Number.isNaN(then)) return null;
|
||||
const { now = Date.now(), locale, dateAfterDays } = options;
|
||||
const diffMs = Math.max(0, now - then);
|
||||
const { now = Date.now(), locale, dateAfterDays, future = false } = options;
|
||||
// Past times read "… ago"; future ones "in …" only when the caller expects
|
||||
// them (a schedule's next run), else they clamp to "now".
|
||||
const sign = then > now ? 1 : -1;
|
||||
const diffMs = future ? Math.abs(now - then) : Math.max(0, now - then);
|
||||
if (dateAfterDays !== undefined && diffMs > dateAfterDays * 86_400_000) {
|
||||
return formatDateOnly(value);
|
||||
}
|
||||
@@ -119,7 +129,8 @@ export function formatRelative(
|
||||
numeric: 'auto',
|
||||
});
|
||||
for (const [unit, ms] of RELATIVE_UNITS) {
|
||||
if (diffMs >= ms) return formatter.format(-Math.round(diffMs / ms), unit);
|
||||
if (diffMs >= ms)
|
||||
return formatter.format(sign * Math.round(diffMs / ms), unit);
|
||||
}
|
||||
return formatter.format(0, 'second');
|
||||
}
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user