Merge pull request #2842 from arc53/Agent-menu-UI

Agent menu UI
This commit is contained in:
Pavel authored and GitHub committed 2026-09-28 17:20:26 +02:00
commit ac7bbd811f
223 files changed
+23053 -5733

No files matched your search

+648 -213
View File
File diff suppressed because it is too large. Load diff
+59 -61
View File
@@ -7,22 +7,53 @@ import unusedImports from 'eslint-plugin-unused-imports';
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 [
{
ignores: [
'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',
@@ -84,35 +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".',
},
...baseSyntaxRules,
...everywhereSelectors,
...pageSelectors,
],
// Design-system rules (@shadcn/lint). Tokens, variants and the
// approved exceptions are documented in DESIGN.md.
@@ -205,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}}.',
},
},
{
@@ -248,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'],
@@ -267,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/*'],
@@ -334,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],
},
},
{
+4
View File
@@ -0,0 +1,4 @@
export declare const cardSurfaceSelectors: {
selector: string;
message: string;
}[];
+39
View File
@@ -0,0 +1,39 @@
// Card surface rule (DESIGN.md "Card surfaces"): a tile is `filled`, and
// nothing on a tile repeats its muted fill, or it disappears into it. The
// neutral Badge and the muted Avatar are `bg-muted-foreground/15`, a tint
// that shows on muted, so they are fine; a literal `bg-muted` is not. These
// are `no-restricted-syntax` entries; they see children written in the same
// JSX tree as the `<Card variant="filled">`, not ones a child component
// renders.
// `variant="filled"` or `variant={'filled'}`.
const FILLED_VARIANT =
'JSXAttribute[name.name="variant"]:matches([value.value="filled"], [value.expression.value="filled"])';
const FILLED_CARD = `JSXElement:has(> JSXOpeningElement[name.name="Card"]:has(> ${FILLED_VARIANT}))`;
// `bg-muted` or `bg-muted/NN`, with any variant prefix, but not
// `bg-muted-foreground`.
const BG_MUTED = '/(^|[\\s:])bg-muted([^-a-z]|$)/';
/** @type {{ selector: string, message: string }[]} */
export const cardSurfaceSelectors = [
{
selector: `${FILLED_CARD} JSXElement > JSXOpeningElement[name.name="Card"] > ${FILLED_VARIANT}`,
message:
'A filled Card inside a filled Card is a fill on a fill. A well inside a tile needs a panel around it, or none. See DESIGN.md "Card surfaces".',
},
...[
`${FILLED_CARD} JSXElement JSXAttribute[name.name="className"] Literal[value=${BG_MUTED}]`,
`${FILLED_CARD} JSXElement JSXAttribute[name.name="className"] TemplateElement[value.raw=${BG_MUTED}]`,
].map((selector) => ({
selector,
message:
'bg-muted inside a filled tile is a fill on a fill and disappears. Drop the fill (use a Separator or border-border for structure). See DESIGN.md "Card surfaces".',
})),
{
selector: `${FILLED_CARD} JSXOpeningElement[name.name="Skeleton"]:not(:has(JSXAttribute[name.name="surface"]))`,
message:
'Skeleton bars on a filled tile need surface="muted", or they vanish into the fill. See DESIGN.md "Skeleton".',
},
];
+3
View File
@@ -0,0 +1,3 @@
type Selector = { selector: string; message: string };
export declare const everywhereSelectors: Selector[];
export declare const pageSelectors: Selector[];
+92
View File
@@ -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".',
})),
];
+10 -6
View File
@@ -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}`);
+5 -2
View File
@@ -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)}
/>
)}
+73
View File
@@ -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();
});
});
+9 -19
View File
@@ -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
@@ -53,4 +53,52 @@ describe('AgentPageHeader sub-nav', () => {
expect(tabs[0].tagName).toBe('A');
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
});
it('makes the current crumb a button with the avatar and a chevron that opens the details', () => {
const onNameClick = vi.fn();
act(() => {
root.render(
<MemoryRouter>
<AgentPageHeader
agentId="a1"
agentName="Helpdesk Triage"
currentPage="overview"
onNameClick={onNameClick}
status={<span data-testid="status">Published</span>}
/>
</MemoryRouter>,
);
});
const crumb = container.querySelector(
'button[aria-haspopup="dialog"]',
) as HTMLButtonElement;
expect(crumb).not.toBeNull();
expect(crumb.getAttribute('data-variant')).toBe('ghost');
expect(crumb.getAttribute('data-size')).toBe('sm');
expect(crumb.querySelector('img')).not.toBeNull();
expect(crumb.querySelector('.lucide-chevron-down')).not.toBeNull();
expect(crumb.textContent).toContain('Helpdesk Triage');
expect(container.querySelector('[data-testid="status"]')).not.toBeNull();
act(() => crumb.click());
expect(onNameClick).toHaveBeenCalledTimes(1);
});
it('hides the tabs for a workflow that has no id yet', () => {
act(() => {
root.render(
<MemoryRouter>
<AgentPageHeader
agentName="New workflow"
currentPage="overview"
onNameClick={vi.fn()}
/>
</MemoryRouter>,
);
});
expect(
container.querySelector(
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
),
).toBeNull();
});
});
+115 -64
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { ChevronDown } from 'lucide-react';
import { type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@@ -10,6 +11,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -36,13 +38,26 @@ type AgentPageHeaderProps = {
* (e.g. the workflow builder's fixed toolbar) to avoid a double rule.
*/
inline?: boolean;
/** The agent's avatar URL; the robot is drawn when it's empty. */
agentImage?: string;
/**
* Makes the current crumb a button (avatar, name, chevron) that opens the
* agent's details. Only with `currentPage="overview"`.
*/
onNameClick?: () => void;
/** A status Badge placed after the crumbs. */
status?: ReactNode;
};
/**
* 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>`), an optional status Badge, and underline tab links
* to the agent's Overview, Logs and Schedules (hidden until the agent has an
* id). The builder is full-screen with no sidebar, so it needs its own way
* between them. With `onNameClick` the current crumb is the agent's avatar,
* name and a chevron in a `ghost sm` Button that opens the details, like the
* phone top bar's chat title. Section pages use
* `components/AgentPageToolbar` and the sidebar instead.
*/
export default function AgentPageHeader({
agentId,
@@ -51,6 +66,9 @@ export default function AgentPageHeader({
currentPage,
className,
inline = false,
agentImage,
onNameClick,
status,
}: AgentPageHeaderProps) {
const { t } = useTranslation();
@@ -85,83 +103,116 @@ export default function AgentPageHeader({
return (
<div
className={cn(
'flex flex-col gap-3 md:flex-row md:items-baseline md:gap-6',
// The builder only renders from lg, so one row.
'flex min-w-0 items-center gap-6',
className,
)}
>
<Breadcrumb className="shrink-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={AGENTS_MANAGE_ROOT}>
{t('agents.pageHeader.crumbs.agents')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
{currentPage === 'overview' ? (
<BreadcrumbPage title={displayName} className="w-[16ch]">
{displayName}
</BreadcrumbPage>
) : (
<div className="flex min-w-0 items-center gap-2">
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={editPath} className="max-w-[40ch] truncate">
{displayName}
<Link to={AGENTS_MANAGE_ROOT}>
{t('agents.pageHeader.crumbs.agents')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
{currentPage === 'overview' && onNameClick ? (
<Button
type="button"
variant="ghost"
size="sm"
aria-haspopup="dialog"
onClick={onNameClick}
// Button is shrink-0; shrink lets a long name truncate.
className="min-w-0 shrink"
>
<Avatar
src={agentImage}
alt=""
shape="circle"
className="shrink-0 overflow-hidden"
imgClassName="size-5 object-contain"
/>
{/* The list's muted colour would reach the name; the current crumb
is foreground, like BreadcrumbPage. */}
<span
className="text-foreground max-w-[24ch] truncate"
title={displayName}
>
{displayName}
</span>
<ChevronDown className="text-muted-foreground" aria-hidden />
</Button>
) : currentPage === 'overview' ? (
<BreadcrumbPage title={displayName} className="w-[16ch]">
{displayName}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={editPath} className="max-w-[40ch] truncate">
{displayName}
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{currentPage !== 'overview' && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{currentTabLabel}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbItem>
{currentPage !== 'overview' && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{currentTabLabel}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</BreadcrumbList>
</Breadcrumb>
{status}
</div>
<nav
aria-label={t('agents.pageHeader.subnavAriaLabel')}
className={cn(
'flex items-center gap-6',
// 1px baseline rule under the whole row; the active tab's 2px
// primary underline sits on top of it for the GitHub-style look.
!inline && 'border-border border-b',
)}
>
{tabs.map((tab) => {
const isActive = tab.id === currentPage;
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
if (isActive) {
{agentId && (
<nav
aria-label={t('agents.pageHeader.subnavAriaLabel')}
className={cn(
'flex items-center gap-6',
// 1px baseline rule under the whole row; the active tab's 2px
// primary underline sits on top of it for the GitHub-style look.
!inline && 'border-border border-b',
)}
>
{tabs.map((tab) => {
const isActive = tab.id === currentPage;
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
if (isActive) {
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
data-active
className="-mb-px"
>
<span aria-current="page">{tab.label}</span>
</Button>
);
}
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
data-active
className="-mb-px"
>
<span aria-current="page">{tab.label}</span>
<Link to={tab.href}>{tab.label}</Link>
</Button>
);
}
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
className="-mb-px"
>
<Link to={tab.href}>{tab.label}</Link>
</Button>
);
})}
</nav>
})}
</nav>
)}
</div>
);
}
+11 -11
View File
@@ -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
+227 -29
View File
@@ -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');
@@ -221,7 +390,7 @@ describe('NewAgent form', () => {
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
expect(toggle.querySelector('h2')).toBeNull();
expect(toggle.parentElement!.tagName).toBe('H2');
const panel = toggle.closest('.bg-card')!;
const panel = toggle.closest('[data-slot="card"]')!;
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
@@ -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');
});
@@ -327,12 +500,37 @@ describe('NewAgent form', () => {
expect(valid.querySelector('svg.lucide-circle-check')).not.toBeNull();
});
it('shows the preview placeholder illustration as decorative images', async () => {
// Card surfaces: the form is a place, so each section is a subtle panel
// straight on the page, with no muted panel around the form.
it('draws each form section as a subtle panel on the page', 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');
const titles = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="section-header"]'),
);
expect(titles.length).toBeGreaterThan(0);
for (const title of titles) {
const panel = title.closest<HTMLElement>('[data-slot="card"]')!;
expect(panel.dataset.variant).toBe(
title.closest('[data-tone="destructive"]')
? panel.dataset.variant
: 'subtle',
);
}
const advanced = buttonByText('agents.form.sections.advanced');
const panel = advanced.closest<HTMLElement>('[data-slot="card"]')!;
expect(panel.dataset.variant).toBe('subtle');
expect(panel.dataset.padding).toBe('lg');
expect(container.querySelector('.bg-muted.rounded-2xl')).toBeNull();
});
it('notches floating labels on the page background', async () => {
await render();
const labels = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="form-field-label"]'),
);
expect(labels.length).toBeGreaterThan(0);
for (const label of labels) {
expect(label.className).toContain('bg-background');
}
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,22 @@
import { renderToStaticMarkup } from 'react-dom/server';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import SharedAgentCard from './SharedAgentCard';
describe('SharedAgentCard', () => {
// Card surfaces: a summary of one agent is a thing, so it is a filled tile.
it('is a filled tile', () => {
const html = renderToStaticMarkup(
<SharedAgentCard
agent={
{ id: 'a1', name: 'Carrier FAQ', description: 'Answers' } as never
}
/>,
);
expect(html).toContain('data-slot="card"');
expect(html).toContain('data-variant="filled"');
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ export default function SharedAgentCard({
Object.keys(agent.shared_metadata).length > 0;
return (
<Card
variant="subtle"
variant="filled"
padding="lg"
className="w-full max-w-[720px] sm:w-fit sm:min-w-[480px]"
>
@@ -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')}
</>
);
}
@@ -0,0 +1,120 @@
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';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('AgentPreviewSheet', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async () => {
await act(async () => {
root.render(
<AgentPreviewSheet
open
onOpenChange={() => undefined}
title="Preview"
description="Carrier onboarding FAQ"
actions={<span data-testid="action">Running</span>}
>
<div data-testid="body">Chat</div>
</AgentPreviewSheet>,
);
});
};
it('is a right Sheet at the workflow preview widths', async () => {
await render();
const content = document.querySelector<HTMLElement>(
'[data-slot="sheet-content"]',
)!;
expect(content.dataset.side).toBe('right');
expect(content.className).toContain('lg:max-w-[800px]');
expect(content.className).toContain('p-0');
});
it('titles the drawer with SheetTitle and SheetDescription', async () => {
await render();
expect(
document.querySelector('[data-slot="sheet-title"]')?.textContent,
).toBe('Preview');
expect(
document.querySelector('[data-slot="sheet-description"]')?.textContent,
).toBe('Carrier onboarding FAQ');
});
it('keeps header actions clear of the close button', async () => {
await render();
const action = document.querySelector('[data-testid="action"]')!;
const header = action.parentElement!;
// The X sits at top-2 right-2 (32px wide); pr-12 keeps 48px free.
expect(header.className).toContain('pr-12');
expect(
header.contains(document.querySelector('[data-slot="sheet-title"]')),
).toBe(true);
});
// DESIGN maps "running" to info, as schedule runs do (was primary text).
it('shows a running run as an info badge in the header', async () => {
await act(async () => {
root.render(
<AgentPreviewSheet
open
onOpenChange={() => undefined}
title="Preview"
running
>
<div />
</AgentPreviewSheet>,
);
});
const badge = document.querySelector<HTMLElement>('[data-slot="badge"]')!;
expect(badge.dataset.variant).toBe('info');
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"]')!;
expect(body.textContent).toBe('Chat');
expect(document.querySelector('[data-slot="separator"]')).not.toBeNull();
const content = document.querySelector('[data-slot="sheet-content"]')!;
expect(content.querySelector('.bg-card')).toBeNull();
});
});
@@ -0,0 +1,93 @@
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';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { setPreviewOpen } from '../workflow/workflowPreviewSlice';
type AgentPreviewSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The drawer's title, e.g. "Preview". */
title: string;
/** One muted line under the title: the agent's name. */
description?: string;
/** Shows an info "Running" badge while the preview is answering. */
running?: boolean;
/** Actions at the header's right end (New chat). */
actions?: ReactNode;
/** The preview itself; fills the rest of the drawer. */
children: ReactNode;
};
/**
* The drawer an agent is previewed in, for workflow and classic agents alike:
* a right Sheet with a title row, a rule, then the preview filling the rest.
*
* Args:
* open: Whether the drawer is open.
* onOpenChange: Called with the next open state (the X, Escape, the scrim).
* title: The drawer's title.
* description: A muted line under the title.
* running: Whether the preview is answering; shows a Running badge.
* actions: Nodes at the right end of the header, clear of the close button.
* children: The preview body.
*/
export default function AgentPreviewSheet({
open,
onOpenChange,
title,
description,
running = false,
actions,
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" 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">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<SheetTitle>{title}</SheetTitle>
{description && (
<SheetDescription className="truncate" title={description}>
{description}
</SheetDescription>
)}
</div>
{running && (
<Badge variant="info">
{t('agents.schedules.status.running')}
</Badge>
)}
{actions}
</div>
<Separator />
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -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')}
@@ -179,18 +179,44 @@ describe('GuardrailsSection', () => {
expect(active.parentElement).toBe(heading.parentElement);
expect(heading.parentElement?.firstElementChild).toBe(heading);
// A place, not a thing: the section is a subtle panel on the page, and
// Card draws the ring for the section-toggle inside it.
const panel = q('guardrails-section')!;
expect(panel.contains(toggle)).toBe(true);
expect(panel.dataset.slot).toBe('card');
expect(panel.dataset.variant).toBe('subtle');
expect(panel.dataset.padding).toBe('lg');
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
expect(panel.className).toContain('bg-card');
await act(async () => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
});
// 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');
@@ -343,6 +369,7 @@ describe('GuardrailsSection', () => {
await render();
const card = q('guardrail-check-pii');
expect(card?.dataset.slot).toBe('card');
expect(card?.dataset.variant).toBe('subtle');
expect(card?.dataset.padding).toBe('sm');
});
@@ -216,8 +216,10 @@ export default function GuardrailsSection({
const instanceDisabled = catalog !== null && catalog.enabled === false;
return (
<div
className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset"
<Card
variant="subtle"
padding="lg"
className="gap-5"
data-testid="guardrails-section"
>
<div className="flex flex-wrap items-center gap-2">
@@ -265,7 +267,7 @@ export default function GuardrailsSection({
</div>
{expanded && (
<div className="mt-3 pb-3">
<div className="flex flex-col gap-5">
{loadError && (
<EmptyState
tone="destructive"
@@ -286,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')}
@@ -310,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', {
@@ -321,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}
@@ -337,45 +330,42 @@ export default function GuardrailsSection({
{config.enabled && (
<>
<div className="mt-5">
<FormField
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>
@@ -426,7 +416,7 @@ export default function GuardrailsSection({
</div>
<FormField
className="mt-6"
labelSurface="background"
label={t('agents.form.guardrails.blockMessage')}
hint={t('agents.form.guardrails.blockMessageDescription')}
disabled={disabled}
@@ -442,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 +446,7 @@ export default function GuardrailsSection({
</SettingRow>
<FormField
className="mt-5"
labelSurface="background"
label={t('agents.form.guardrails.timeout')}
disabled={disabled}
>
@@ -476,7 +465,7 @@ export default function GuardrailsSection({
)}
</div>
)}
</div>
</Card>
);
}
@@ -597,7 +586,11 @@ function CheckCard({
);
return (
<Card padding="sm" data-testid={`guardrail-check-${info.name}`}>
<Card
variant="subtle"
padding="sm"
data-testid={`guardrail-check-${info.name}`}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<SectionHeader as="h4" size="xs" title={info.label} />
@@ -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]);
});
});
+51 -37
View File
@@ -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',
);
});
});
+193 -213
View File
@@ -4,26 +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,
@@ -38,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() {
@@ -65,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;
@@ -151,218 +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) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron, t)} ·{' '}
{t('agents.schedules.timezoneMeta', {
...NO_ESCAPE,
timezone: schedule.timezone,
})}{' '}
·{' '}
{t('agents.schedules.nextRunMeta', {
...NO_ESCAPE,
time: formatTimestamp(schedule.next_run_at),
})}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
>
{t('agents.schedules.runNow')}
</Button>
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.delete')}
</Button>
</div>
</div>
<Button
type="button"
variant="link"
size="xs"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-0.5 -ml-2"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="sm"
title={`${t('agents.schedules.oneTime')} (${oneTime.length})`}
<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) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.cancel')}
</Button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<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;
+13
View File
@@ -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[];
@@ -0,0 +1,89 @@
import { Maximize, Minus, Plus, Redo2, Undo2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Panel, useReactFlow, useStore } from 'reactflow';
import { IconButton } from '@/components/ui/icon-button';
import { Separator } from '@/components/ui/separator';
interface CanvasControlsProps {
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
}
const ZOOM_DURATION_MS = 200;
/**
* The canvas's one control strip, bottom left: undo and redo, then zoom out,
* the zoom level, zoom in and fit view. Replaces React Flow's own
* `<Controls />`, whose buttons ignore the theme. Render inside `<ReactFlow>`.
*/
export default function CanvasControls({
onUndo,
onRedo,
canUndo,
canRedo,
}: CanvasControlsProps) {
const { t } = useTranslation();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const zoom = useStore((state) => state.transform[2]);
return (
<Panel
position="bottom-left"
className="bg-card border-border flex items-center gap-0.5 rounded-full border p-1"
>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onUndo}
disabled={!canUndo}
label={t('agents.workflow.undo')}
hint={t('agents.workflow.undoHint')}
icon={Undo2}
/>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onRedo}
disabled={!canRedo}
label={t('agents.workflow.redo')}
hint={t('agents.workflow.redoHint')}
icon={Redo2}
/>
<Separator orientation="vertical" className="mx-1 h-5" />
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() => zoomOut({ duration: ZOOM_DURATION_MS })}
label={t('agents.workflow.zoomOut')}
icon={Minus}
/>
<span className="text-foreground w-11 text-center text-xs tabular-nums">
{Math.round(zoom * 100)}%
</span>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() => zoomIn({ duration: ZOOM_DURATION_MS })}
label={t('agents.workflow.zoomIn')}
icon={Plus}
/>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() =>
fitView({ padding: 0.2, maxZoom: 0.8, duration: ZOOM_DURATION_MS })
}
label={t('agents.workflow.fitView')}
icon={Maximize}
/>
</Panel>
);
}
@@ -0,0 +1,83 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import NodePalette from './NodePalette';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('NodePalette', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (onAdd = vi.fn(), onDragStart = vi.fn()) => {
act(() => {
root.render(<NodePalette onAdd={onAdd} onDragStart={onDragStart} />);
});
return { onAdd, onDragStart };
};
const pill = (labelKey: string) =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes(labelKey),
) as HTMLButtonElement;
it('renders each node type as a draggable button', () => {
render();
const buttons = container.querySelectorAll('button');
expect(buttons).toHaveLength(6);
buttons.forEach((button) => {
expect(button.getAttribute('type')).toBe('button');
expect(button.getAttribute('draggable')).toBe('true');
});
});
it('adds a node of the pill type on click', () => {
const { onAdd } = render();
act(() => pill('agents.workflow.nodes.condition').click());
expect(onAdd).toHaveBeenCalledWith('condition');
act(() => pill('agents.workflow.builder.aiAgent').click());
expect(onAdd).toHaveBeenLastCalledWith('agent');
});
it('starts a drag with the pill type', () => {
const { onDragStart } = render();
const button = pill('agents.workflow.nodes.code');
act(() => {
button.dispatchEvent(new Event('dragstart', { bubbles: true }));
});
expect(onDragStart).toHaveBeenCalledWith(expect.anything(), 'code');
});
it('shows the shared tone on the icon square, with no hover swap', () => {
render();
const square = pill('agents.workflow.nodes.end').querySelector(
'span',
) as HTMLElement;
expect(square.className).toContain('bg-success/10');
// Radius by role: a tinted icon square at size-8 is rounded-md.
expect(square.className).toContain('rounded-md');
expect(container.innerHTML).not.toContain('group-hover:');
});
it('explains click-to-add under the groups', () => {
render();
expect(container.textContent).toContain(
'agents.workflow.builder.paletteHint',
);
});
});
@@ -0,0 +1,123 @@
import { type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { SectionHeader } from '@/components/ui/section-header';
import { cn, focusRing } from '@/lib/utils';
import { NODE_META, nodeToneClass, type WorkflowNodeType } from './nodeTones';
interface PaletteEntry {
type: WorkflowNodeType;
group: 'core' | 'logic';
hintKey?: string;
}
const PALETTE: PaletteEntry[] = [
{ type: 'agent', group: 'core' },
{ type: 'end', group: 'core' },
{ type: 'note', group: 'core' },
{
type: 'state',
group: 'logic',
hintKey: 'agents.workflow.builder.setStateHint',
},
{
type: 'condition',
group: 'logic',
hintKey: 'agents.workflow.builder.conditionHint',
},
{ type: 'code', group: 'logic', hintKey: 'agents.workflow.builder.codeHint' },
];
const GROUPS: { group: PaletteEntry['group']; titleKey: string }[] = [
{ group: 'core', titleKey: 'agents.workflow.builder.coreNodes' },
{ group: 'logic', titleKey: 'agents.workflow.builder.logicNodes' },
];
interface NodePaletteItemProps {
entry: PaletteEntry;
onAdd: (nodeType: WorkflowNodeType) => void;
onDragStart: (e: DragEvent, nodeType: string) => void;
}
/**
* A palette pill: drag it onto the canvas, or click (or press Enter) to add
* the node without dragging.
*
* Args:
* entry: The palette entry to render.
* onAdd: Adds a node of the entry's type beside the selection.
* onDragStart: Starts dragging a node of the entry's type onto the canvas.
*/
function NodePaletteItem({ entry, onAdd, onDragStart }: NodePaletteItemProps) {
const { t } = useTranslation();
const { icon: Icon, labelKey } = NODE_META[entry.type];
const label = (
<span className="text-foreground text-sm font-medium">{t(labelKey)}</span>
);
return (
<button
type="button"
draggable
onDragStart={(e) => onDragStart(e, entry.type)}
onClick={() => onAdd(entry.type)}
className={cn(
'border-border bg-card hover:bg-accent flex cursor-grab items-center gap-3 rounded-full border px-4 py-2.5 text-left transition-colors outline-none',
focusRing,
)}
>
<span
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md',
nodeToneClass(entry.type),
)}
>
<Icon className="size-4.5" aria-hidden="true" />
</span>
{entry.hintKey ? (
<span className="flex flex-col">
{label}
<span className="text-muted-foreground text-xs">
{t(entry.hintKey)}
</span>
</span>
) : (
label
)}
</button>
);
}
interface NodePaletteProps {
/** Adds a node of the given type without dragging (click or Enter). */
onAdd: (nodeType: WorkflowNodeType) => void;
/** Starts dragging a node of the given type onto the canvas. */
onDragStart: (e: DragEvent, nodeType: string) => void;
}
/** The builder's left rail of node types, grouped Core and Logic. */
export default function NodePalette({ onAdd, onDragStart }: NodePaletteProps) {
const { t } = useTranslation();
return (
<div className="border-border bg-background flex w-64 shrink-0 flex-col gap-6 overflow-y-auto border-r p-4">
{GROUPS.map(({ group, titleKey }) => (
<div key={group} className="flex flex-col gap-3">
<SectionHeader as="h3" size="sm" title={t(titleKey)} />
<div className="flex flex-col gap-2">
{PALETTE.filter((entry) => entry.group === group).map((entry) => (
<NodePaletteItem
key={entry.type}
entry={entry}
onAdd={onAdd}
onDragStart={onDragStart}
/>
))}
</div>
</div>
))}
<p className="text-muted-foreground text-xs">
{t('agents.workflow.builder.paletteHint')}
</p>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -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');
@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react';
/**
* Model display names by model id, provided by the builder so the canvas
* nodes (rendered by React Flow, out of reach of props) can name a model.
*/
export const WorkflowModelsContext = createContext<Record<string, string>>({});
/**
* A model's display name.
*
* Args:
* modelId: The model id stored on the node.
*
* Returns:
* The display name, the id itself when the model is unknown, or undefined
* when no id is set.
*/
export function useModelDisplayName(
modelId: string | undefined,
): string | undefined {
const names = useContext(WorkflowModelsContext);
if (!modelId) return undefined;
return names[modelId] || modelId;
}
@@ -0,0 +1,134 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useDispatch: () => vi.fn(),
}));
vi.mock('./WorkflowRunArtifacts', () => ({ default: () => null }));
import { ExecutionDetails, RunArtifactsSection } from './WorkflowPreview';
import type { WorkflowNode } from '../types/workflow';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const nodes = [
{
id: 'n1',
type: 'agent',
title: 'Summarise',
position: { x: 0, y: 0 },
data: {},
},
] as unknown as WorkflowNode[];
const steps = [
{
nodeId: 'n1',
nodeType: 'agent',
nodeTitle: 'Summarise',
status: 'completed',
output: 'Three carriers expire this month.',
stateDelta: { region: 'EU', query: 'ignored' },
},
] as never;
describe('WorkflowPreview execution sections', () => {
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 renderDetails = async (isOpen = true) => {
await act(async () => {
root.render(
<ExecutionDetails
steps={steps}
nodes={nodes}
isOpen={isOpen}
onToggle={() => undefined}
/>,
);
});
};
// The answer's step-row recipe (AnswerFlow): ghost sm at ml-3.5, a muted
// icon, muted text, and a chevron that turns while open.
it('draws the Execution details toggle as an answer step row', async () => {
await renderDetails();
const toggle = container.querySelector<HTMLButtonElement>('button')!;
expect(toggle.dataset.variant).toBe('ghost');
expect(toggle.dataset.size).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(toggle.className).toContain('ml-3.5');
const icons = toggle.querySelectorAll('svg');
expect(icons[0].getAttribute('class')).toContain('text-muted-foreground');
expect(icons[icons.length - 1].getAttribute('class')).toContain(
'rotate-180',
);
expect(toggle.querySelector('p')).toBeNull();
});
it('draws each step as a subtle panel with its output in a filled well', async () => {
await renderDetails();
const step = container.querySelector<HTMLElement>(
'[data-slot="card"][data-variant="subtle"]',
)!;
expect(step.dataset.padding).toBe('sm');
const well = step.querySelector<HTMLElement>(
'[data-slot="card"][data-variant="filled"]',
)!;
expect(well.dataset.padding).toBe('sm');
expect(well.textContent).toContain('Three carriers expire this month.');
// No hand-rolled muted boxes: a muted box in a muted box disappears.
expect(
container.querySelector(
'div.bg-muted:not([data-slot="card"]), span.bg-muted',
),
).toBeNull();
});
it('shows state changes as mono neutral badges, without query', async () => {
await renderDetails();
const badges = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="badge"]'),
);
expect(badges).toHaveLength(1);
expect(badges[0].dataset.variant).toBe('neutral');
expect(badges[0].className).toContain('font-mono');
expect(badges[0].textContent).toContain('region');
expect(badges[0].textContent).toContain('EU');
});
it('draws the Artifacts toggle as the same step row', async () => {
await act(async () => {
root.render(
<RunArtifactsSection
workflowRunId="run-1"
isOpen={false}
onToggle={() => undefined}
/>,
);
});
const toggle = container.querySelector<HTMLButtonElement>('button')!;
expect(toggle.dataset.variant).toBe('ghost');
expect(toggle.dataset.size).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.className).toContain('ml-3.5');
expect(toggle.querySelector('p')).toBeNull();
});
});
+194 -218
View File
@@ -11,7 +11,6 @@ import {
Flag,
GitBranch,
type LucideIcon,
MessageSquare,
Play,
StickyNote,
Workflow,
@@ -21,9 +20,11 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { SectionHeader } from '@/components/ui/section-header';
import { SheetTitle } from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
MessageScroller,
@@ -50,7 +51,6 @@ import {
resendQuery,
resetWorkflowPreview,
selectActiveNodeId,
setPreviewOpen,
selectWorkflowExecutionSteps,
selectWorkflowPreviewQueries,
selectWorkflowPreviewStatus,
@@ -94,7 +94,64 @@ const NODE_COLORS: Record<string, string> = {
code: 'text-info',
};
function ExecutionDetails({
/**
* A step row under an answer, like Reasoning in chat (AnswerFlow): a ghost
* button whose muted icon sits on the answer's ml-6 column, then the panel it
* opens in place on that column.
*/
function StepDisclosure({
icon: Icon,
label,
count,
isOpen,
onToggle,
children,
}: {
icon: LucideIcon;
label: string;
count?: string;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="my-2 flex w-full flex-col">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
aria-expanded={isOpen}
// ml-3.5 plus size sm's own has-[>svg]:px-2.5 puts the icon on the
// answer's ml-6 text column.
className="ml-3.5 w-fit max-w-full justify-start"
>
<Icon className="text-muted-foreground" aria-hidden />
<span className="text-muted-foreground min-w-0 truncate">{label}</span>
{count && (
<span className="text-muted-foreground/70 font-normal">{count}</span>
)}
<ChevronDown
aria-hidden
className={cn(
'text-muted-foreground shrink-0 transition-transform duration-200',
isOpen && 'rotate-180',
)}
/>
</Button>
<div
className={cn(
'mr-5 ml-6 grid transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
</div>
);
}
export function ExecutionDetails({
steps,
nodes,
isOpen,
@@ -122,153 +179,121 @@ function ExecutionDetails({
};
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<Workflow className="text-muted-foreground size-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="-ml-2.5"
>
<p className="text-base font-semibold">
{t('agents.workflow.preview.executionDetails')}
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
{t('agents.workflow.preview.stepCount', {
count: completedSteps.length,
})}
</span>
</p>
<ChevronDown
className={cn(
'transition-transform duration-200',
isOpen && 'rotate-180',
)}
/>
</Button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="flex flex-col gap-2 pr-2">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
const stateVars = step.stateDelta
? Object.entries(step.stateDelta).filter(
([key]) => !['query', 'chat_history'].includes(key),
)
: [];
<StepDisclosure
icon={Workflow}
label={t('agents.workflow.preview.executionDetails')}
count={t('agents.workflow.preview.stepCount', {
count: completedSteps.length,
})}
isOpen={isOpen}
onToggle={onToggle}
>
<div className="flex flex-col gap-2 pt-1">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
const stateVars = step.stateDelta
? Object.entries(step.stateDelta).filter(
([key]) => !['query', 'chat_history'].includes(key),
)
: [];
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
};
const hasOutput =
step.output !== undefined &&
step.output !== null &&
formatValue(step.output) !== '';
const formattedOutput = hasOutput ? formatValue(step.output) : '';
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
};
const hasOutput =
step.output !== undefined &&
step.output !== null &&
formatValue(step.output) !== '';
const formattedOutput = hasOutput ? formatValue(step.output) : '';
return (
<div
key={step.nodeId}
ref={(el) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
className="bg-muted rounded-xl p-3"
>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<div
className={cn(
'shrink-0',
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
>
<StepIcon className="size-3" />
</div>
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CircleCheck className="text-success size-4" />
)}
{step.status === 'failed' && (
<CircleX className="text-destructive size-4" />
)}
</div>
</div>
{(hasOutput || step.error || stateVars.length > 0) && (
<div className="mt-3 flex flex-col gap-2 text-sm">
{hasOutput && (
<div className="bg-muted rounded-lg p-2">
<span className="text-muted-foreground font-medium">
{t('agents.workflow.preview.outputLabel')}{' '}
</span>
<span className="text-foreground wrap-break-word whitespace-pre-wrap">
{truncateText(formattedOutput, 300)}
</span>
</div>
)}
{step.error && (
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">
{t('agents.workflow.preview.errorLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
</AlertDescription>
</Alert>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
{stateVars.map(([key, value]) => (
<span
key={key}
className="bg-muted inline-flex items-center rounded-lg px-2 py-1 text-xs"
>
<span className="text-muted-foreground max-w-[100px] truncate font-medium">
{key}:
</span>
<span
className="text-foreground ml-1 max-w-[200px] truncate"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
</span>
</span>
))}
</div>
)}
</div>
// A step is a place you read inside: a subtle panel on the
// drawer's background, with its output in a filled well.
return (
<Card
key={step.nodeId}
ref={(el: HTMLDivElement | null) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
variant="subtle"
padding="sm"
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<StepIcon
aria-hidden
className={cn(
'size-3 shrink-0',
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
/>
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CircleCheck className="text-success size-4" />
)}
{step.status === 'failed' && (
<CircleX className="text-destructive size-4" />
)}
</div>
);
})}
</div>
</div>
</div>
{hasOutput && (
<Card variant="filled" padding="sm">
<p className="wrap-break-word whitespace-pre-wrap">
<span className="text-muted-foreground font-medium">
{t('agents.workflow.preview.outputLabel')}{' '}
</span>
<span className="text-foreground">
{truncateText(formattedOutput, 300)}
</span>
</p>
</Card>
)}
{step.error && (
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">
{t('agents.workflow.preview.errorLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
</AlertDescription>
</Alert>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
{stateVars.map(([key, value]) => (
// eslint-disable-next-line shadcn/no-restyle -- state keys and values are serialised by the app, so the chip is set in mono
<Badge key={key} variant="neutral" className="font-mono">
<span className="max-w-[100px] truncate">{key}:</span>
<span
className="text-foreground max-w-[200px] truncate"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
</span>
</Badge>
))}
</div>
)}
</Card>
);
})}
</div>
</div>
</StepDisclosure>
);
}
function RunArtifactsSection({
export function RunArtifactsSection({
workflowRunId,
isOpen,
onToggle,
@@ -281,47 +306,21 @@ function RunArtifactsSection({
}) {
const { t } = useTranslation();
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<FileBox className="text-muted-foreground size-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="-ml-2.5"
>
<p className="text-base font-semibold">
{t('agents.workflow.preview.artifacts')}
</p>
<ChevronDown
className={cn(
'transition-transform duration-200',
isOpen && 'rotate-180',
)}
<StepDisclosure
icon={FileBox}
label={t('agents.workflow.preview.artifacts')}
isOpen={isOpen}
onToggle={onToggle}
>
<div className="max-h-[480px] overflow-y-auto pt-1">
{isOpen && (
<WorkflowRunArtifacts
workflowRunId={workflowRunId}
inProgress={runInProgress}
/>
</Button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="max-h-[480px] overflow-y-auto pr-2">
{isOpen && (
<WorkflowRunArtifacts
workflowRunId={workflowRunId}
inProgress={runInProgress}
/>
)}
</div>
</div>
</div>
</div>
</StepDisclosure>
);
}
@@ -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]);
@@ -616,28 +613,7 @@ export default function WorkflowPreview({
queries.length > 0 ? queries[queries.length - 1].executionSteps || [] : [];
return (
<div className="bg-card flex h-full flex-col">
<div className="border-border flex h-[77px] items-center justify-between border-b px-6">
<div className="flex items-center gap-3">
<div className="bg-muted text-muted-foreground flex items-center justify-center rounded-full p-3">
<Play className="size-4" />
</div>
<div>
<SheetTitle>{t('agents.form.sections.preview')}</SheetTitle>
<p className="text-muted-foreground max-w-md truncate text-xs">
{workflowData.name}
{workflowData.description && ` - ${workflowData.description}`}
</p>
</div>
</div>
{status === 'loading' && (
<span className="text-primary flex items-center gap-1 text-xs">
<Spinner size="xs" />
{t('agents.schedules.status.running')}
</span>
)}
</div>
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1">
<div className="border-border flex w-64 shrink-0 flex-col border-r">
<div className="flex items-center justify-between px-4 py-3">
@@ -662,13 +638,12 @@ export default function WorkflowPreview({
<div className="flex min-w-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
{queries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4">
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-foreground text-xl font-semibold">
{t('agents.workflow.preview.emptyTitle')}
</p>
<div className="flex h-full items-center justify-center px-4">
<EmptyState
size="sm"
illustration="none"
title={t('agents.workflow.preview.emptyTitle')}
/>
</div>
) : (
<MessageScrollerProvider autoScroll>
@@ -788,11 +763,12 @@ export default function WorkflowPreview({
</MessageScrollerProvider>
)}
</div>
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
<div className="flex w-full flex-col gap-2 px-4 pt-2 pb-4">
{sendBlockedMessage && (
<p className="text-destructive text-xs" role="alert">
{t(sendBlockedMessage)}
</p>
<Alert variant="destructive">
<CircleAlert />
<AlertDescription>{t(sendBlockedMessage)}</AlertDescription>
</Alert>
)}
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
@@ -66,7 +66,7 @@ export default function NodeDocumentsControl({
return (
<FormField label={label} hint={helpText} id={fieldId} float={false}>
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
<div className="border-border bg-card rounded-xl border p-1">
<div className="bg-muted rounded-full p-1">
<ToggleGroup
id={fieldId}
type="single"
@@ -121,7 +121,7 @@ export default function NodeDocumentsControl({
onClick={addRef}
className="shrink-0"
>
<Plus className="size-3.5" />
<Plus />
{t('agents.form.buttons.add')}
</Button>
</div>
@@ -337,6 +337,8 @@ interface PromptTextAreaProps {
placeholder?: string;
rows?: number;
label?: string;
/** Surface behind the field, for the floating label's notch. */
labelSurface?: 'card' | 'background' | 'muted';
}
export default function PromptTextArea({
@@ -348,6 +350,7 @@ export default function PromptTextArea({
placeholder,
rows = 4,
label,
labelSurface = 'card',
}: PromptTextAreaProps) {
const { t } = useTranslation();
const textareaId = useId();
@@ -442,7 +445,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 relative rounded-xl border transition-shadow focus-within:ring-3">
<div
ref={overlayRef}
aria-hidden
@@ -525,7 +528,7 @@ export default function PromptTextArea({
}}
>
{label ? (
<FormField label={label} id={textareaId}>
<FormField label={label} id={textareaId} labelSurface={labelSurface}>
{field}
</FormField>
) : (
@@ -0,0 +1,113 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import WorkflowDetailsSheet from './WorkflowDetailsSheet';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const details = {
name: 'Helpdesk Triage',
description: 'Routes internal requests',
allowPromptOverride: false,
};
describe('WorkflowDetailsSheet', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (props: { errors?: string[]; saving?: boolean } = {}) => {
const handlers = { onOpenChange: vi.fn(), onSave: vi.fn() };
act(() => {
root.render(
<WorkflowDetailsSheet
open
details={details}
currentImage=""
saving={props.saving ?? false}
errors={props.errors ?? []}
{...handlers}
/>,
);
});
return handlers;
};
const button = (name: string) =>
Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent === name,
) as HTMLButtonElement;
const nameInput = () =>
document.querySelector('input[type="text"]') as HTMLInputElement;
const type = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
act(() => {
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
};
it('opens with the workflow details filled in', () => {
render();
expect(document.body.textContent).toContain(
'agents.workflow.builder.detailsTitle',
);
expect(nameInput().value).toBe('Helpdesk Triage');
expect(document.querySelector('textarea')?.value).toBe(
'Routes internal requests',
);
expect(document.querySelector('[role="switch"]')).not.toBeNull();
});
it('keeps Save disabled until something changes, then saves the edits', () => {
const { onSave } = render();
const save = button('agents.form.buttons.save');
expect(save.disabled).toBe(true);
type(nameInput(), 'Helpdesk Router');
expect(save.disabled).toBe(false);
act(() => save.click());
expect(onSave).toHaveBeenCalledWith({
name: 'Helpdesk Router',
description: 'Routes internal requests',
allowPromptOverride: false,
imageFile: null,
});
});
it('closes on Cancel without saving', () => {
const { onOpenChange, onSave } = render();
type(nameInput(), 'Something else');
act(() => button('agents.form.buttons.cancel').click());
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onSave).not.toHaveBeenCalled();
});
it('lists save errors in an alert inside the sheet', () => {
render({ errors: ['Workflow must have an end node'] });
const alert = document.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('agents.workflow.builder.unableSave');
expect(alert?.textContent).toContain('Workflow must have an end node');
});
});
@@ -0,0 +1,226 @@
import { CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { SectionHeader } from '@/components/ui/section-header';
import { Separator } from '@/components/ui/separator';
import { SettingRow } from '@/components/ui/setting-row';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { FileUpload } from '../../../components/FileUpload';
export interface WorkflowDetailsValues {
name: string;
description: string;
allowPromptOverride: boolean;
}
export interface WorkflowDetailsSave extends WorkflowDetailsValues {
/** A newly picked avatar, or null to keep the current one. */
imageFile: File | null;
}
interface WorkflowDetailsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The builder's current values; the sheet edits a copy of them. */
details: WorkflowDetailsValues;
/** The saved avatar's URL, shown in the tile until a new one is picked. */
currentImage: string;
saving: boolean;
/** Errors from the last save started here, shown at the top of the body. */
errors: string[];
onSave: (values: WorkflowDetailsSave) => void;
}
/**
* The workflow's name, description, avatar and prompt-override setting, in a
* right drawer laid out like the classic agent's Basics on a phone.
*
* The sheet edits a copy: Cancel, the X, Escape and the scrim drop the edits,
* and Save hands them to the builder, which saves the workflow.
*
* Args:
* open: Whether the sheet is open.
* onOpenChange: Opens or closes the sheet.
* details: The values to start from each time it opens.
* currentImage: The saved avatar's URL.
* saving: Whether a save started here is running.
* errors: Errors from that save.
* onSave: Receives the edited values.
*/
export default function WorkflowDetailsSheet({
open,
onOpenChange,
details,
currentImage,
saving,
errors,
onSave,
}: WorkflowDetailsSheetProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<WorkflowDetailsValues>(details);
const [imageFile, setImageFile] = useState<File | null>(null);
// Start from the builder's values every time the sheet opens.
useEffect(() => {
if (!open) return;
setDraft(details);
setImageFile(null);
// Only on open: later changes to `details` come from this sheet's own save.
}, [open]);
const dirty =
imageFile !== null ||
draft.name !== details.name ||
draft.description !== details.description ||
draft.allowPromptOverride !== details.allowPromptOverride;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
size="default"
className="p-0"
closeLabel={t('agents.close')}
>
<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 flex-col gap-1 px-6 pt-6 pr-12 pb-4">
<SheetTitle>{t('agents.workflow.builder.detailsTitle')}</SheetTitle>
<SheetDescription>
{t('agents.workflow.builder.detailsDescription')}
</SheetDescription>
</div>
<Separator />
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-6">
{errors.length > 0 && (
<Alert variant="destructive">
<CircleX />
<AlertTitle>
{t('agents.workflow.builder.unableSave')}
</AlertTitle>
<AlertDescription>
<ul className="flex list-inside list-disc flex-col gap-1 wrap-break-word">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<Card variant="subtle" padding="lg" className="gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.basics')}
/>
{/* The phone layout of classic Basics: the avatar beside Name,
Description across the row. */}
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-5">
<FileUpload
showPreview
size="tile"
tileSize="fixed"
currentImage={currentImage || undefined}
onUpload={(files) => setImageFile(files[0] ?? null)}
onRemove={() => setImageFile(null)}
uploadText={t('agents.form.labels.avatar')}
/>
<FormField
labelSurface="background"
label={t('agents.form.labels.name')}
>
<Input
shape="pill"
type="text"
value={draft.name}
placeholder={t(
'agents.workflow.builder.workflowNamePlaceholder',
)}
onChange={(e) =>
setDraft({ ...draft, name: e.target.value })
}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.form.labels.description')}
className="col-span-2"
>
<Textarea
size="lg"
className="h-32"
placeholder={t(
'agents.workflow.builder.workflowDescriptionPlaceholder',
)}
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.target.value })
}
/>
</FormField>
</div>
</Card>
<Card variant="subtle" padding="lg" className="gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.advanced')}
/>
<SettingRow
label={t('agents.form.advanced.systemPromptOverride')}
description={t(
'agents.form.advanced.systemPromptOverrideDescription',
)}
htmlFor="workflow-system-prompt-override"
>
<Switch
id="workflow-system-prompt-override"
checked={draft.allowPromptOverride}
onCheckedChange={(checked) =>
setDraft({ ...draft, allowPromptOverride: checked })
}
/>
</SettingRow>
</Card>
</div>
<Separator />
<div className="flex justify-end gap-3 px-6 py-4">
<Button
type="button"
variant="ghost"
size="lg"
shape="pill"
onClick={() => onOpenChange(false)}
>
{t('agents.form.buttons.cancel')}
</Button>
<Button
type="button"
size="lg"
shape="pill"
disabled={!dirty}
loading={saving}
onClick={() => onSave({ ...draft, imageFile })}
>
{t('agents.form.buttons.save')}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { NODE_TONES, nodeToneClass, TONE_CLASSES } from './nodeTones';
describe('nodeTones', () => {
it('maps every node type to one tone', () => {
expect(NODE_TONES).toEqual({
start: 'success',
agent: 'primary',
end: 'success',
note: 'warning',
state: 'info',
condition: 'warning',
code: 'info',
});
});
it('never paints End red', () => {
expect(nodeToneClass('end')).not.toMatch(/destructive/);
expect(nodeToneClass('end')).toBe('bg-success/10 text-success');
});
it('uses the brand soft fill for primary, not bg-primary/10', () => {
expect(TONE_CLASSES.primary).toBe('bg-secondary text-secondary-foreground');
expect(nodeToneClass('agent')).toBe(TONE_CLASSES.primary);
});
it('gives status tones their /10 fill and text', () => {
expect(TONE_CLASSES.info).toBe('bg-info/10 text-info');
expect(TONE_CLASSES.warning).toBe('bg-warning/10 text-warning');
});
it('falls back to a neutral tint for an unknown type', () => {
expect(nodeToneClass('mystery')).toBe(
'bg-muted-foreground/15 text-muted-foreground',
);
});
});
+71
View File
@@ -0,0 +1,71 @@
import {
Bot,
CodeXml,
Database,
Flag,
GitBranch,
Play,
StickyNote,
type LucideIcon,
} from 'lucide-react';
/** The node types the builder draws. */
export type WorkflowNodeType =
'start' | 'agent' | 'end' | 'note' | 'state' | 'condition' | 'code';
/** A node's colour role: the brand tint or one status tint. */
export type NodeTone = 'primary' | 'success' | 'warning' | 'info';
/**
* One colour per node type, read by the palette, every canvas node and the
* settings panel's header. Start and End share `success` as the two ends of
* the flow; `destructive` is kept for errors, so End is never red.
*/
export const NODE_TONES: Record<WorkflowNodeType, NodeTone> = {
start: 'success',
agent: 'primary',
end: 'success',
note: 'warning',
state: 'info',
condition: 'warning',
code: 'info',
};
// Whole class strings per tone so Tailwind sees them. Brand is the soft
// `secondary` fill (never bg-primary/10); status tones are the /10 fill.
export const TONE_CLASSES: Record<NodeTone, string> = {
primary: 'bg-secondary text-secondary-foreground',
success: 'bg-success/10 text-success',
warning: 'bg-warning/10 text-warning',
info: 'bg-info/10 text-info',
};
const NEUTRAL_TONE_CLASS = 'bg-muted-foreground/15 text-muted-foreground';
/**
* The icon-square classes for a node type.
*
* Args:
* type: The node's type.
*
* Returns:
* The tone's fill and text classes, or a neutral tint for an unknown type.
*/
export function nodeToneClass(type: string | undefined): string {
const tone = NODE_TONES[type as WorkflowNodeType];
return tone ? TONE_CLASSES[tone] : NEUTRAL_TONE_CLASS;
}
/** Each node type's icon and its name's locale key. */
export const NODE_META: Record<
WorkflowNodeType,
{ icon: LucideIcon; labelKey: string }
> = {
start: { icon: Play, labelKey: 'agents.workflow.nodes.start' },
agent: { icon: Bot, labelKey: 'agents.workflow.builder.aiAgent' },
end: { icon: Flag, labelKey: 'agents.workflow.nodes.end' },
note: { icon: StickyNote, labelKey: 'agents.workflow.nodes.note' },
state: { icon: Database, labelKey: 'agents.workflow.nodes.setState' },
condition: { icon: GitBranch, labelKey: 'agents.workflow.nodes.condition' },
code: { icon: CodeXml, labelKey: 'agents.workflow.nodes.code' },
};
+14 -34
View File
@@ -3,11 +3,14 @@ import { Handle, Position } from 'reactflow';
import { cn } from '@/lib/utils';
import { nodeToneClass, type WorkflowNodeType } from '../nodeTones';
interface BaseNodeProps {
title: string;
children?: ReactNode;
selected?: boolean;
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition' | 'code';
/** The node type; picks the icon square's tone from the shared map. */
type: WorkflowNodeType;
icon?: ReactNode;
handles?: {
source?: boolean;
@@ -15,47 +18,25 @@ interface BaseNodeProps {
};
}
/**
* The pill every canvas node but Note and If / Else is drawn as: a tinted
* icon, the title, a muted meta block and the handles. Selected is the focus
* ring look (`border-primary ring-3 ring-ring/50`), with no scale, so the
* node stays put under the pointer.
*/
export const BaseNode: React.FC<BaseNodeProps> = ({
title,
children,
selected,
type = 'default',
type,
icon,
handles = { source: true, target: true },
}) => {
let bgColor = 'bg-card';
let borderColor = 'border-border';
let iconBg = 'bg-muted';
let iconColor = 'text-muted-foreground';
if (selected) {
borderColor = 'border-primary ring-2 ring-primary';
}
if (type === 'start') {
iconBg = 'bg-success/10';
iconColor = 'text-success';
} else if (type === 'end') {
iconBg = 'bg-destructive/10';
iconColor = 'text-destructive';
} else if (type === 'state') {
iconBg = 'bg-muted';
iconColor = 'text-muted-foreground';
} else if (type === 'condition') {
iconBg = 'bg-warning/10';
iconColor = 'text-warning';
} else if (type === 'code') {
iconBg = 'bg-info/10';
iconColor = 'text-info';
}
return (
<div
className={cn(
'rounded-full border shadow-md transition hover:shadow-lg',
bgColor,
borderColor,
selected && 'scale-105',
'bg-card rounded-full border shadow-md transition hover:shadow-lg',
selected ? 'border-primary ring-ring/50 ring-3' : 'border-border',
'max-w-[250px] min-w-[180px]',
)}
>
@@ -72,8 +53,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
iconBg,
iconColor,
nodeToneClass(type),
)}
>
{icon}
@@ -5,9 +5,7 @@ import { NodeProps } from 'reactflow';
import { CodeNodeConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
import OutputVariableLine from './OutputVariableLine';
type CodeNodeData = {
title?: string;
@@ -46,18 +44,7 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
</div>
)}
{config.output_variable && (
<div
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
<OutputVariableLine variable={config.output_variable} />
)}
</div>
</BaseNode>
@@ -6,6 +6,7 @@ import { Handle, NodeProps, Position } from 'reactflow';
import { cn } from '@/lib/utils';
import { ConditionCase } from '../../types/workflow';
import { nodeToneClass } from '../nodeTones';
type ConditionNodeData = {
label?: string;
@@ -44,7 +45,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
className={cn(
'bg-card relative rounded-2xl border shadow-md transition',
selected
? 'border-primary ring-primary scale-105 ring-2'
? 'border-primary ring-ring/50 ring-3'
: 'border-border hover:shadow-lg',
)}
style={{ minWidth: 180, maxWidth: 220, height }}
@@ -57,7 +58,12 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
/>
<div className="flex items-center gap-3 px-3 py-2">
<div className="bg-warning/10 text-warning flex size-9 shrink-0 items-center justify-center rounded-full">
<div
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full',
nodeToneClass('condition'),
)}
>
<GitBranch className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-2">
@@ -69,8 +75,8 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
</div>
<div className="text-muted-foreground text-xs">
{data.config?.mode === 'advanced'
? t('agents.workflow.nodes.modeAdvanced')
: t('agents.workflow.nodes.modeSimple')}
? t('agents.workflow.builder.modeAdvanced')
: t('agents.workflow.builder.modeSimple')}
</div>
</div>
</div>
@@ -0,0 +1,25 @@
import { ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
/**
* A node's output variable on the canvas: an arrow, then the name in mono,
* because it is what the next node reads.
*
* Args:
* variable: The output variable's name.
*/
export default function OutputVariableLine({ variable }: { variable: string }) {
const { t } = useTranslation();
return (
<div
className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs"
title={t('agents.workflow.nodes.output', { ...NO_ESCAPE, variable })}
>
<ArrowRight className="size-3 shrink-0" aria-hidden="true" />
<span className="truncate font-mono">{variable}</span>
</div>
);
}
+20 -31
View File
@@ -4,14 +4,14 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { nodeToneClass } from '../nodeTones';
import { useModelDisplayName } from '../WorkflowModelsContext';
import { BaseNode } from './BaseNode';
import OutputVariableLine from './OutputVariableLine';
import CodeNode from './CodeNode';
import ConditionNode from './ConditionNode';
import SetStateNode from './SetStateNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
export const StartNode = memo(function StartNode({
selected,
}: {
@@ -73,6 +73,12 @@ export const AgentNode = memo(function AgentNode({
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.agent');
const config = data.config || {};
const modelName = useModelDisplayName(config.model_id);
const agentType =
config.agent_type === 'research' || config.agent_type === 'classic'
? t(`agents.form.agentTypes.${config.agent_type}`)
: config.agent_type;
const meta = [agentType, modelName].filter(Boolean).join(' · ');
return (
<BaseNode
title={title}
@@ -81,35 +87,13 @@ export const AgentNode = memo(function AgentNode({
icon={<Bot className="size-4" />}
>
<div className="flex flex-col gap-1">
{config.agent_type && (
<div
className="text-muted-foreground truncate text-xs"
title={config.agent_type}
>
{config.agent_type}
</div>
)}
{config.model_id && (
<div
className="text-primary truncate text-xs"
title={config.model_id}
>
{config.model_id}
{meta && (
<div className="text-muted-foreground truncate text-xs" title={meta}>
{meta}
</div>
)}
{config.output_variable && (
<div
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
<OutputVariableLine variable={config.output_variable} />
)}
</div>
</BaseNode>
@@ -138,12 +122,17 @@ export const NoteNode = memo(function NoteNode({
// over it as a flat gradient, so the canvas grid doesn't show through.
'bg-card from-warning/10 to-warning/10 max-w-[250px] rounded-3xl border bg-linear-to-b px-5 py-3 shadow-md transition',
selected
? 'border-warning ring-warning scale-105 ring-2'
? 'border-warning ring-warning/50 ring-3'
: 'border-warning/50 hover:shadow-lg',
)}
>
<div className="flex items-start gap-3">
<div className="bg-warning/15 text-warning flex size-10 shrink-0 items-center justify-center rounded-full">
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
nodeToneClass('note'),
)}
>
<StickyNote className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
@@ -0,0 +1,138 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import AgentPanel from './AgentPanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const makeNode = (config: Record<string, unknown> = {}): Node => ({
id: 'agent_1',
type: 'agent',
position: { x: 0, y: 0 },
data: {
title: 'Classify',
config: {
agent_type: 'classic',
system_prompt: '',
prompt_template: '',
stream_to_user: true,
sources: [],
tools: [],
...config,
},
},
});
describe('AgentPanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (node: Node, jsonSchemaText = '') => {
const onUpdate = vi.fn();
act(() => {
root.render(
<AgentPanel
node={node}
onUpdate={onUpdate}
nodes={[node]}
edges={[]}
availableModels={[]}
availableTools={[]}
sourceOptions={[]}
documentOptions={[]}
jsonSchemaText={jsonSchemaText}
jsonSchemaError={null}
modelSupportsStructuredOutput
onJsonSchemaChange={vi.fn()}
/>,
);
});
return { onUpdate };
};
const advancedToggle = () =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.advancedSettings'),
) as HTMLButtonElement;
it('groups the fields under Model, Prompt, Knowledge and Output', () => {
render(makeNode());
const headings = Array.from(container.querySelectorAll('h3')).map(
(h) => h.textContent,
);
expect(headings).toEqual([
'agents.form.sections.model',
'agents.form.sections.prompt',
'agents.workflow.builder.knowledge',
'agents.workflow.builder.output',
]);
});
it('stacks Agent type and Model at full width in the narrow panel', () => {
render(makeNode());
const modelSection = container.querySelector('section')!;
expect(modelSection.querySelector('.grid')).toBeNull();
const triggers = modelSection.querySelectorAll('[role="combobox"]');
expect(triggers).toHaveLength(2);
triggers.forEach((trigger) =>
expect(trigger.className).toContain('w-full'),
);
});
it('streams through a Switch with a description, not a Checkbox', () => {
const { onUpdate } = render(makeNode());
expect(container.querySelector('[role="checkbox"]')).toBeNull();
const toggle = container.querySelector(
'[role="switch"]',
) as HTMLButtonElement;
expect(toggle.getAttribute('aria-checked')).toBe('true');
expect(container.textContent).toContain(
'agents.workflow.builder.streamToUserDescription',
);
act(() => toggle.click());
expect(onUpdate).toHaveBeenCalledWith({
config: expect.objectContaining({ stream_to_user: false }),
});
});
it('keeps advanced settings closed when none is set', () => {
render(makeNode());
const toggle = advancedToggle();
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(container.textContent).not.toContain(
'agents.workflow.builder.filePassing',
);
act(() => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(container.textContent).toContain(
'agents.workflow.builder.filePassing',
);
});
it('opens advanced settings when one of them is set', () => {
render(makeNode({ file_passing: 'native' }));
expect(advancedToggle().getAttribute('aria-expanded')).toBe('true');
});
it('opens advanced settings when a schema is present', () => {
render(makeNode({ json_schema: { type: 'object' } }), '{"type":"object"}');
expect(advancedToggle().getAttribute('aria-expanded')).toBe('true');
});
});
@@ -0,0 +1,403 @@
import { ChevronRight } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type Edge, type Node } from 'reactflow';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import { SectionHeader } from '@/components/ui/section-header';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SettingRow, SettingRows } from '@/components/ui/setting-row';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { getToolDisplayName } from '../../../utils/toolUtils';
import NodeDocumentsControl from '../components/NodeDocumentsControl';
import PromptTextArea from '../components/PromptTextArea';
import {
DEFAULT_FILE_PASSING,
FILE_PASSING_OPTIONS,
FilePassing,
normalizeFilePassing,
} from '../documentConfig';
import { NO_ESCAPE, schemaErrorText, type UserTool } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
import type { Model } from '../../../models/types';
interface AgentPanelProps extends NodePanelBodyProps {
nodes: Node[];
edges: Edge[];
availableModels: Model[];
availableTools: UserTool[];
sourceOptions: { value: string; label: string }[];
/** Upstream file variables the agent can read. */
documentOptions: { value: string; label: string }[];
/** The structured-output schema as typed. */
jsonSchemaText: string;
/** The schema's validation fragment, or null. */
jsonSchemaError: string | null;
/** False when the picked model can't return structured output. */
modelSupportsStructuredOutput: boolean;
onJsonSchemaChange: (text: string) => void;
}
const SCHEMA_PLACEHOLDER = `{
"type": "object",
"properties": {
"summary": { "type": "string" }
},
"required": ["summary"]
}`;
/**
* Whether any advanced setting (documents, file passing, structured output)
* differs from its default, so the disclosure opens on its own.
*
* Args:
* config: The agent node's config.
* jsonSchemaText: The schema as typed.
* jsonSchemaError: The schema's validation fragment, or null.
*
* Returns:
* True when the advanced settings should start open.
*/
export function hasAdvancedAgentSettings(
config: Record<string, unknown> | undefined,
jsonSchemaText: string,
jsonSchemaError: string | null,
): boolean {
const inputDocuments = config?.input_documents;
return (
(Array.isArray(inputDocuments) && inputDocuments.length > 0) ||
normalizeFilePassing(config?.file_passing) !== DEFAULT_FILE_PASSING ||
(config?.json_schema !== undefined && config?.json_schema !== null) ||
jsonSchemaText.trim() !== '' ||
Boolean(jsonSchemaError)
);
}
/**
* Settings for an AI Agent node, grouped Model, Prompt, Knowledge and Output
* like classic Overview, with documents, file passing and structured output
* under an Advanced settings disclosure.
*/
export default function AgentPanel({
node,
onUpdate,
nodes,
edges,
availableModels,
availableTools,
sourceOptions,
documentOptions,
jsonSchemaText,
jsonSchemaError,
modelSupportsStructuredOutput,
onJsonSchemaChange,
}: AgentPanelProps) {
const { t } = useTranslation();
const streamId = useId();
const config = node.data.config || {};
const [advancedOpen, setAdvancedOpen] = useState(() =>
hasAdvancedAgentSettings(config, jsonSchemaText, jsonSchemaError),
);
const updateConfig = (patch: Record<string, unknown>) =>
onUpdate({ config: { ...(node.data.config || {}), ...patch } });
const builtinModels = availableModels.filter((m) => m.source !== 'user');
const userModels = availableModels.filter((m) => m.source === 'user');
const schemaInvalid = jsonSchemaText.trim() !== '' && jsonSchemaError;
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.model')}
/>
<div className="flex flex-col gap-5">
<FormField
label={t('agents.workflow.builder.agentType')}
labelSurface="background"
>
<Select
value={config.agent_type || 'classic'}
onValueChange={(value) => updateConfig({ agent_type: value })}
>
<SelectTrigger size="field" className="w-full">
<SelectValue
placeholder={t(
'agents.workflow.builder.agentTypePlaceholder',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="classic">
{t('agents.form.agentTypes.classic')}
</SelectItem>
<SelectItem value="research">
{t('agents.form.agentTypes.research')}
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.model')}
labelSurface="background"
>
<Select
value={config.model_id || ''}
onValueChange={(value) =>
updateConfig({
model_id: value,
llm_name:
availableModels.find((m) => m.id === value)?.provider || '',
})
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue
placeholder={t('agents.workflow.builder.modelPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{builtinModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t('settings.customModels.modelsGroup.builtin')}
</SelectLabel>
{builtinModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.display_name} · {model.provider}
</SelectItem>
))}
</SelectGroup>
)}
{userModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t('settings.customModels.modelsGroup.user')}
</SelectLabel>
{userModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.display_name} · {model.provider}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</FormField>
</div>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.prompt')}
/>
<FormField
label={t('agents.workflow.builder.systemPrompt')}
labelSurface="background"
>
<Textarea
value={config.system_prompt ?? ''}
onChange={(e) => updateConfig({ system_prompt: e.target.value })}
rows={3}
placeholder={t('agents.workflow.builder.systemPromptPlaceholder')}
/>
</FormField>
<PromptTextArea
label={t('agents.workflow.builder.promptTemplate')}
labelSurface="background"
value={config.prompt_template || ''}
onChange={(val) => updateConfig({ prompt_template: val })}
nodes={nodes}
edges={edges}
selectedNodeId={node.id}
placeholder={t('agents.workflow.builder.promptTemplatePlaceholder', {
...NO_ESCAPE,
example: '{{ agent.variable }}',
})}
/>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.workflow.builder.knowledge')}
/>
<FormField
label={t('agents.workflow.builder.sources')}
labelSurface="background"
>
<MultiSelect
options={sourceOptions}
selected={config.sources || []}
onChange={(newSources) => updateConfig({ sources: newSources })}
placeholder={t('agents.form.placeholders.selectSources')}
searchPlaceholder={t('agents.form.sourcePopup.searchPlaceholder')}
emptyText={t('agents.form.sourcePopup.noOptionsMessage')}
/>
</FormField>
<FormField
label={t('agents.form.sections.tools')}
labelSurface="background"
>
<MultiSelect
options={availableTools.map((tool) => ({
value: tool.id,
label: getToolDisplayName(tool),
}))}
selected={config.tools || []}
onChange={(newTools) => updateConfig({ tools: newTools })}
placeholder={t('agents.form.placeholders.selectTools')}
searchPlaceholder={t('agents.form.toolsPopup.searchPlaceholder')}
emptyText={t('agents.form.toolsPopup.noOptionsMessage')}
/>
</FormField>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.workflow.builder.output')}
/>
<FormField
label={t('agents.workflow.builder.outputVariable')}
labelSurface="background"
>
<Input
type="text"
value={config.output_variable || ''}
onChange={(e) => updateConfig({ output_variable: e.target.value })}
placeholder={t('agents.workflow.builder.outputVariablePlaceholder')}
/>
</FormField>
<SettingRows>
<SettingRow
label={t('agents.workflow.builder.streamToUser')}
description={t('agents.workflow.builder.streamToUserDescription')}
htmlFor={streamId}
>
<Switch
id={streamId}
checked={config.stream_to_user ?? true}
onCheckedChange={(checked) =>
updateConfig({ stream_to_user: checked })
}
/>
</SettingRow>
</SettingRows>
</section>
<div className="flex flex-col gap-5">
<Button
type="button"
variant="link"
size="sm"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((open) => !open)}
className="-ml-3 w-fit justify-start"
>
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform duration-200',
advancedOpen && 'rotate-90',
)}
/>
{t('agents.workflow.builder.advancedSettings')}
</Button>
{advancedOpen && (
<div className="flex flex-col gap-5">
<NodeDocumentsControl
key={node.id}
value={config.input_documents ?? []}
onChange={(nextInputDocuments) =>
updateConfig({ input_documents: nextInputDocuments })
}
options={documentOptions}
label={t('agents.workflow.builder.documents')}
helpText={t('agents.workflow.builder.documentsHint')}
/>
<FormField
label={t('agents.workflow.builder.filePassing')}
hint={t('agents.workflow.builder.filePassingHint')}
labelSurface="background"
>
<Select
value={normalizeFilePassing(config.file_passing)}
onValueChange={(value) =>
updateConfig({ file_passing: value as FilePassing })
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILE_PASSING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(`agents.workflow.filePassing.${option.value}`, {
defaultValue: option.label,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.structuredOutput')}
labelSurface="background"
hint={
[
!modelSupportsStructuredOutput
? t('agents.workflow.builder.modelNoStructuredOutput')
: null,
jsonSchemaText.trim() !== '' && !jsonSchemaError
? t('agents.workflow.builder.validSchema')
: null,
]
.filter(Boolean)
.join(' ') || undefined
}
error={
schemaInvalid
? t('agents.workflow.builder.invalidSchema', {
...NO_ESCAPE,
error: schemaErrorText(t, jsonSchemaError),
})
: undefined
}
>
<Textarea
value={jsonSchemaText}
onChange={(e) => onJsonSchemaChange(e.target.value)}
className="font-mono"
rows={4}
placeholder={SCHEMA_PLACEHOLDER}
/>
</FormField>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,152 @@
import { useTranslation } from 'react-i18next';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import NodeDocumentsControl from '../components/NodeDocumentsControl';
import { NO_ESCAPE, schemaErrorText } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
interface CodePanelProps extends NodePanelBodyProps {
/** Upstream file variables the code can take as inputs. */
documentOptions: { value: string; label: string }[];
/** The structured-output schema as typed. */
jsonSchemaText: string;
/** The schema's validation fragment, or null. */
jsonSchemaError: string | null;
onJsonSchemaChange: (text: string) => void;
}
/** Settings for a Code node: code, inputs, output and schema. */
export default function CodePanel({
node: selectedNode,
onUpdate: handleUpdateNodeData,
documentOptions: selectedCodeDocumentOptions,
jsonSchemaText: selectedCodeJsonSchemaText,
jsonSchemaError: selectedCodeJsonSchemaError,
onJsonSchemaChange: handleCodeJsonSchemaChange,
}: CodePanelProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.codeIntro')}
</p>
<FormField
labelSurface="background"
label={t('agents.workflow.nodes.code')}
>
<Textarea
value={selectedNode.data.config?.code ?? ''}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
code: e.target.value,
},
})
}
className="font-mono"
rows={10}
spellCheck={false}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'print("hello world")',
})}
/>
</FormField>
<NodeDocumentsControl
key={selectedNode.id}
value={selectedNode.data.config?.inputs ?? []}
onChange={(nextInputs) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
inputs: nextInputs,
},
})
}
options={selectedCodeDocumentOptions}
label={t('agents.workflow.builder.inputFiles')}
helpText={t('agents.workflow.builder.inputFilesHint')}
/>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.outputVariable')}
>
<Input
type="text"
value={selectedNode.data.config?.output_variable || ''}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
output_variable: e.target.value,
},
})
}
placeholder={t('agents.workflow.builder.outputVariablePlaceholder')}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.timeout')}
>
<Input
type="number"
min={1}
value={selectedNode.data.config?.timeout ?? ''}
onChange={(e) => {
const raw = e.target.value;
const parsed =
raw.trim() === '' ? undefined : Number.parseInt(raw, 10);
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
timeout:
parsed !== undefined && Number.isFinite(parsed)
? parsed
: undefined,
},
});
}}
placeholder={t('agents.workflow.builder.optional')}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.structuredOutput')}
hint={
selectedCodeJsonSchemaText.trim() !== '' &&
!selectedCodeJsonSchemaError
? t('agents.workflow.builder.validSchema')
: undefined
}
error={
selectedCodeJsonSchemaText.trim() !== '' &&
selectedCodeJsonSchemaError
? t('agents.workflow.builder.invalidSchema', {
...NO_ESCAPE,
error: schemaErrorText(t, selectedCodeJsonSchemaError),
})
: undefined
}
>
<Textarea
value={selectedCodeJsonSchemaText}
onChange={(e) => handleCodeJsonSchemaChange(e.target.value)}
className="font-mono"
rows={6}
placeholder={`{
"type": "object",
"properties": {
"result": { "type": "string" }
},
"required": ["result"]
}`}
/>
</FormField>
</div>
);
}
@@ -0,0 +1,107 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
import ConditionPanel from './ConditionPanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const node: Node = {
id: 'condition_1',
type: 'condition',
position: { x: 0, y: 0 },
data: {
title: 'Route',
config: {
mode: 'simple',
cases: [
{
name: 'People Ops',
expression: 'category.contains("people")',
sourceHandle: 'case_0',
},
],
},
},
};
describe('ConditionPanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = () => {
const onUpdate = vi.fn();
act(() => {
root.render(
<ConditionPanel
node={node}
onUpdate={onUpdate}
onRemoveBranch={vi.fn()}
/>,
);
});
return { onUpdate };
};
it('picks the mode with a ToggleGroup in the muted track', () => {
const { onUpdate } = render();
const group = container.querySelector('[role="radiogroup"]')!;
expect(group.parentElement!.className).toContain('bg-muted');
const advanced = Array.from(group.querySelectorAll('button')).find(
(b) => b.textContent === 'agents.workflow.builder.modeAdvanced',
)!;
act(() => advanced.click());
expect(onUpdate).toHaveBeenCalledWith({
config: expect.objectContaining({ mode: 'advanced' }),
});
});
it('labels the branch name, variable, operator and value fields', () => {
render();
const labels = Array.from(
container.querySelectorAll('[data-slot="form-field-label"]'),
).map((l) => l.textContent);
expect(labels).toEqual([
'agents.workflow.builder.branchName',
'agents.workflow.builder.variable',
'agents.workflow.builder.operator',
'agents.workflow.builder.value',
]);
});
it('wires the operator label to its select, with no aria-label', () => {
render();
const trigger = container.querySelector('[role="combobox"]')!;
expect(trigger.hasAttribute('aria-label')).toBe(false);
const label = container.querySelector(`label[for="${trigger.id}"]`);
expect(label?.textContent).toBe('agents.workflow.builder.operator');
});
it('adds a condition with an outline pill button', () => {
const { onUpdate } = render();
const add = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.addCondition'),
)!;
expect(add.getAttribute('data-variant')).toBe('outline');
act(() => add.click());
const cases = onUpdate.mock.calls[0][0].config.cases;
expect(cases).toHaveLength(2);
expect(cases[1].sourceHandle).toBe('case_1');
});
});
@@ -0,0 +1,291 @@
import { Plus, Trash2 } from 'lucide-react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SectionHeader } from '@/components/ui/section-header';
import { Textarea } from '@/components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { ConditionCase } from '../../types/workflow';
import { buildSimpleCel, parseSimpleCel } from '../simpleCel';
import {
getNextConditionHandle,
normalizeConditionCases,
} from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
interface ConditionPanelProps extends NodePanelBodyProps {
/** Drop the edges leaving the node from a removed case's handle. */
onRemoveBranch: (sourceHandle: string) => void;
}
type ConditionMode = 'simple' | 'advanced';
/**
* Settings for an If / Else node: Simple or Advanced mode, then one row box
* per case (If, Else if, …) and Add condition.
*/
export default function ConditionPanel({
node,
onUpdate,
onRemoveBranch,
}: ConditionPanelProps) {
const { t } = useTranslation();
const config = node.data.config || {};
const mode: ConditionMode =
config.mode === 'advanced' ? 'advanced' : 'simple';
const cases: ConditionCase[] = config.cases || [];
const updateConfig = (patch: Record<string, unknown>) =>
onUpdate({ config: { ...(node.data.config || {}), ...patch } });
const updateCase = (idx: number, patch: Partial<ConditionCase>) => {
const next = [...cases];
next[idx] = { ...next[idx], ...patch };
updateConfig({ cases: next });
};
const removeCase = (idx: number) => {
const next = normalizeConditionCases([...cases]);
const removedHandle = next[idx]?.sourceHandle;
next.splice(idx, 1);
updateConfig({ cases: next });
if (removedHandle) onRemoveBranch(removedHandle);
};
const addCase = () => {
const next = normalizeConditionCases([...cases]);
next.push({
name: '',
expression: '',
sourceHandle: getNextConditionHandle(next),
});
updateConfig({ cases: next });
};
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.conditionIntro')}
</p>
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
<div className="bg-muted rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
value={mode}
onValueChange={(next) => next && updateConfig({ mode: next })}
aria-label={t('agents.workflow.builder.conditionMode')}
className="flex-nowrap"
>
<ToggleGroupItem value="simple" className="flex-1">
{t('agents.workflow.builder.modeSimple')}
</ToggleGroupItem>
<ToggleGroupItem value="advanced" className="flex-1">
{t('agents.workflow.builder.modeAdvanced')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{cases.map((c, idx) => {
const parsed = parseSimpleCel(c.expression);
return (
<Card
key={c.sourceHandle}
variant="subtle"
padding="sm"
className="gap-4"
>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="bg-warning size-2 shrink-0 rounded-full"
/>
<SectionHeader
as="h4"
size="xs"
className="flex-1"
title={
idx === 0
? t('agents.workflow.nodes.if')
: t('agents.workflow.nodes.elseIf')
}
/>
{cases.length > 1 && (
<IconButton
variant="ghost-destructive"
size="icon-xs"
label={t('agents.workflow.removeCondition', {
index: idx + 1,
})}
icon={Trash2}
onClick={() => removeCase(idx)}
/>
)}
</div>
<FormField
label={t('agents.workflow.builder.branchName')}
labelSurface="background"
>
<Input
type="text"
value={c.name || ''}
onChange={(e) => updateCase(idx, { name: e.target.value })}
placeholder={t('agents.workflow.builder.caseNamePlaceholder')}
/>
</FormField>
{mode === 'simple' ? (
<>
<FormField
label={t('agents.workflow.builder.variable')}
labelSurface="background"
>
<Input
type="text"
className="font-mono"
value={parsed.variable}
onChange={(e) =>
updateCase(idx, {
expression: buildSimpleCel(
e.target.value,
parsed.operator,
parsed.value,
),
})
}
placeholder={t(
'agents.workflow.builder.variablePlaceholder',
)}
/>
</FormField>
<div className="flex gap-2">
<FormField
label={t('agents.workflow.builder.operator')}
labelSurface="background"
className="w-32 shrink-0"
>
<Select
value={parsed.operator}
onValueChange={(op) =>
updateCase(idx, {
expression: buildSimpleCel(
parsed.variable,
op,
parsed.value,
),
})
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="==">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="contains">
{t('agents.workflow.builder.opContains')}
</SelectItem>
<SelectItem value="startsWith">
{t('agents.workflow.builder.opStartsWith')}
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.value')}
labelSurface="background"
className="min-w-0 flex-1"
>
<Input
type="text"
value={parsed.value}
onChange={(e) =>
updateCase(idx, {
expression: buildSimpleCel(
parsed.variable,
parsed.operator,
e.target.value,
),
})
}
placeholder={t(
'agents.workflow.builder.valuePlaceholder',
)}
/>
</FormField>
</div>
</>
) : (
<FormField
label={t('agents.workflow.conditionRow', { index: idx + 1 })}
labelSurface="background"
hint={
<>
<Trans
i18nKey="agents.workflow.builder.celHint"
components={{ code: <code /> }}
values={{ braced: '{{query}}' }}
/>{' '}
<Button
variant="link"
size="inline"
asChild
// eslint-disable-next-line shadcn/no-restyle -- a link in a 12px hint keeps the sentence's size and weight
className="text-xs font-normal"
>
<a
href="https://cel.dev/"
target="_blank"
rel="noreferrer"
>
{t('agents.workflow.builder.learnMore')}
</a>
</Button>
</>
}
>
<Textarea
value={c.expression}
onChange={(e) =>
updateCase(idx, { expression: e.target.value })
}
rows={2}
placeholder={t(
'agents.workflow.builder.conditionPlaceholder',
)}
/>
</FormField>
)}
</Card>
);
})}
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={addCase}
className="self-start"
>
<Plus />
{t('agents.workflow.builder.addCondition')}
</Button>
</div>
);
}
@@ -0,0 +1,123 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import NodePanel from './NodePanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const agentNode: Node = {
id: 'classify',
type: 'agent',
position: { x: 0, y: 0 },
data: { title: 'Classify request' },
};
const startNode: Node = {
id: 'start',
type: 'start',
position: { x: 0, y: 0 },
data: { label: 'Start' },
};
describe('NodePanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (node: Node) => {
const handlers = {
onClose: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
onUpdate: vi.fn(),
};
act(() => {
root.render(
<NodePanel node={node} {...handlers}>
<div data-testid="body">body</div>
</NodePanel>,
);
});
return handlers;
};
const menuTrigger = () =>
container.querySelector(
'[aria-label="agents.workflow.builder.nodeActions"]',
) as HTMLButtonElement | null;
it('shows the icon, own title, type badge and id', () => {
render(agentNode);
const header = container.querySelector('header')!;
expect(header.textContent).toContain('Classify request');
const badge = header.querySelector('[data-slot="badge"]')!;
expect(badge.textContent).toBe('agents.workflow.builder.aiAgent');
expect(badge.getAttribute('data-variant')).toBe('neutral');
expect(header.textContent).toContain('classify');
expect(header.querySelector('.bg-secondary')).not.toBeNull();
});
it('offers Duplicate and Delete node in the node menu', () => {
const { onDuplicate, onDelete } = render(agentNode);
const trigger = menuTrigger();
expect(trigger).not.toBeNull();
act(() => {
trigger!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
const items = Array.from(
document.querySelectorAll('[role="menuitem"]'),
) as HTMLElement[];
expect(items.map((i) => i.textContent)).toEqual([
'agents.workflow.builder.duplicateNode',
'agents.workflow.builder.deleteNode',
]);
expect(items[1].getAttribute('data-variant')).toBe('destructive');
act(() => items[0].click());
expect(onDuplicate).toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
});
it('has no node menu and no title field for the start node', () => {
render(startNode);
expect(menuTrigger()).toBeNull();
expect(container.querySelector('input')).toBeNull();
expect(container.textContent).not.toContain('cannotDeleteStart');
});
it('closes from the header', () => {
const { onClose } = render(agentNode);
const close = container.querySelector(
'[aria-label="agents.close"]',
) as HTMLButtonElement;
act(() => close.click());
expect(onClose).toHaveBeenCalled();
});
it('edits the title on the background surface and renders the body', () => {
const { onUpdate } = render(agentNode);
const label = container.querySelector(
'[data-slot="form-field-label"]',
) as HTMLElement;
expect(label.className).toContain('bg-background');
expect(container.querySelector('[data-testid="body"]')).not.toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,151 @@
import { Copy, Trash2, X } from 'lucide-react';
import { type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { type Node } from 'reactflow';
import { Badge } from '@/components/ui/badge';
import { ActionMenu } from '@/components/ui/dropdown-menu';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import CopyButton from '../../../components/CopyButton';
import { NODE_META, nodeToneClass, type WorkflowNodeType } from '../nodeTones';
interface NodePanelProps {
/** The node being edited. */
node: Node;
onClose: () => void;
onDuplicate: () => void;
onDelete: () => void;
/** Merge fields into the node's data (used by the title field). */
onUpdate: (data: Record<string, unknown>) => void;
/** The type's own settings (AgentPanel, ConditionPanel, …). */
children?: ReactNode;
}
// Start and End have fixed names and no settings of their own.
const FIXED_TYPES = new Set(['start', 'end']);
const FIXED_HINT_KEYS: Record<string, string> = {
start: 'agents.workflow.nodes.startHint',
end: 'agents.workflow.nodes.endHint',
};
/**
* The node settings column docked at the canvas's right edge: a header with
* the type's icon, the node's title, its type and id, a ⋯ menu (Duplicate,
* Delete node; none for Start) and close, then the one scrolling body.
*/
export default function NodePanel({
node,
onClose,
onDuplicate,
onDelete,
onUpdate,
children,
}: NodePanelProps) {
const { t } = useTranslation();
const meta = NODE_META[node.type as WorkflowNodeType];
const Icon = meta?.icon;
const typeLabel = meta ? t(meta.labelKey) : (node.type ?? '');
const fixed = FIXED_TYPES.has(node.type ?? '');
const title = fixed
? typeLabel
: node.data.title || node.data.label || typeLabel;
return (
<aside className="bg-background border-border flex w-96 shrink-0 flex-col border-l">
<header className="border-border flex items-start gap-3 border-b px-4 py-3">
<span
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md',
nodeToneClass(node.type),
)}
>
{Icon && <Icon className="size-4" aria-hidden="true" />}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-2">
<h2
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</h2>
{title !== typeLabel && (
<Badge variant="neutral">{typeLabel}</Badge>
)}
</div>
<div className="flex min-w-0 items-center gap-1">
<span
className="text-muted-foreground truncate font-mono text-xs"
title={node.id}
>
{node.id}
</span>
<CopyButton
textToCopy={node.id}
size="xs"
copyLabel={t('agents.workflow.builder.copyNodeId')}
side="bottom"
/>
</div>
</div>
{node.type !== 'start' && (
<ActionMenu
size="toolbar"
triggerLabel={t('agents.workflow.builder.nodeActions')}
options={[
{
label: t('agents.workflow.builder.duplicateNode'),
icon: Copy,
onClick: onDuplicate,
},
{
label: t('agents.workflow.builder.deleteNode'),
icon: Trash2,
variant: 'destructive',
onClick: onDelete,
},
]}
/>
)}
<IconButton
variant="ghost-muted"
size="icon-sm"
side="bottom"
onClick={onClose}
label={t('agents.close')}
icon={X}
/>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col gap-6 p-4">
{fixed ? (
<p className="text-muted-foreground text-sm">
{t(FIXED_HINT_KEYS[node.type ?? ''])}
</p>
) : (
<FormField
label={t('agents.workflow.builder.title')}
labelSurface="background"
>
<Input
type="text"
value={node.data.title || node.data.label || ''}
onChange={(e) =>
onUpdate({ title: e.target.value, label: e.target.value })
}
placeholder={t('agents.workflow.builder.titlePlaceholder')}
/>
</FormField>
)}
{children}
</div>
</div>
</aside>
);
}
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { FormField } from '@/components/ui/form-field';
import { Textarea } from '@/components/ui/textarea';
import { type NodePanelBodyProps } from './types';
/** Settings for a note node: its text. */
export default function NotePanel({
node: selectedNode,
onUpdate: handleUpdateNodeData,
}: NodePanelBodyProps) {
const { t } = useTranslation();
return (
<FormField
labelSurface="background"
label={t('agents.workflow.builder.noteContent')}
>
<Textarea
value={selectedNode.data.content || ''}
onChange={(e) =>
handleUpdateNodeData({
content: e.target.value,
})
}
rows={4}
placeholder={t('agents.workflow.builder.noteContentPlaceholder')}
/>
</FormField>
);
}
@@ -0,0 +1,92 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { index?: number }) =>
opts?.index !== undefined ? `${key}:${opts.index}` : key,
}),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
import StatePanel from './StatePanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const node: Node = {
id: 'state_1',
type: 'state',
position: { x: 0, y: 0 },
data: {
title: 'Set priority',
config: {
operations: [
{ expression: '"P1"', target_variable: 'priority' },
{ expression: '"4h"', target_variable: 'sla' },
],
},
},
};
describe('StatePanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = () => {
const onUpdate = vi.fn();
act(() => {
root.render(<StatePanel node={node} onUpdate={onUpdate} />);
});
return { onUpdate };
};
it('explains CEL once, in the intro', () => {
render();
const matches = container.textContent!.match(
/agents\.workflow\.builder\.celHint/g,
);
expect(matches).toHaveLength(1);
expect(
container.querySelectorAll('a[href="https://cel.dev/"]'),
).toHaveLength(1);
});
it('numbers each assignment and puts the variable before the value', () => {
render();
expect(container.textContent).toContain(
'agents.workflow.builder.assignment:1',
);
expect(container.textContent).toContain(
'agents.workflow.builder.assignment:2',
);
const labels = Array.from(
container.querySelectorAll('[data-slot="form-field-label"]'),
).map((l) => l.textContent);
expect(labels.slice(0, 2)).toEqual([
'agents.workflow.builder.variable',
'agents.workflow.builder.valueCel',
]);
});
it('adds an assignment with an outline pill button', () => {
const { onUpdate } = render();
const add = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.addAssignment'),
)!;
expect(add.getAttribute('data-variant')).toBe('outline');
act(() => add.click());
expect(onUpdate.mock.calls[0][0].config.operations).toHaveLength(3);
});
});
@@ -0,0 +1,132 @@
import { Plus, Trash2 } from 'lucide-react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { SectionHeader } from '@/components/ui/section-header';
import { Textarea } from '@/components/ui/textarea';
import { StateOperationConfig } from '../../types/workflow';
import { NO_ESCAPE } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
/**
* Settings for a Set State node: an intro that explains CEL once, then one
* numbered row box per assignment (variable, then its CEL value).
*/
export default function StatePanel({ node, onUpdate }: NodePanelBodyProps) {
const { t } = useTranslation();
const operations: StateOperationConfig[] = node.data.config?.operations || [];
const setOperations = (next: StateOperationConfig[]) =>
onUpdate({ config: { ...(node.data.config || {}), operations: next } });
const updateOperation = (
idx: number,
patch: Partial<StateOperationConfig>,
) => {
const next = [...operations];
next[idx] = { ...next[idx], ...patch };
setOperations(next);
};
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.stateIntro')}{' '}
<Trans
i18nKey="agents.workflow.builder.celHint"
components={{ code: <code /> }}
values={{ braced: '{{query}}' }}
/>{' '}
<Button variant="link" size="inline" asChild>
<a href="https://cel.dev/" target="_blank" rel="noreferrer">
{t('agents.workflow.builder.learnMore')}
</a>
</Button>
</p>
{operations.map((op, idx) => (
<Card key={idx} variant="subtle" padding="sm" className="gap-4">
<div className="flex items-center gap-2">
<SectionHeader
as="h4"
size="xs"
className="flex-1"
title={t('agents.workflow.builder.assignment', {
index: idx + 1,
})}
/>
{operations.length > 1 && (
<IconButton
variant="ghost-destructive"
size="icon-xs"
label={t('agents.workflow.removeAssignment', {
index: idx + 1,
})}
icon={Trash2}
onClick={() =>
setOperations(operations.filter((_, i) => i !== idx))
}
/>
)}
</div>
<FormField
label={t('agents.workflow.builder.variable')}
labelSurface="background"
>
<Input
type="text"
className="font-mono"
value={op.target_variable}
onChange={(e) =>
updateOperation(idx, { target_variable: e.target.value })
}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'variable_name',
})}
/>
</FormField>
<FormField
label={t('agents.workflow.builder.valueCel')}
labelSurface="background"
>
<Textarea
className="font-mono"
value={op.expression}
onChange={(e) =>
updateOperation(idx, { expression: e.target.value })
}
rows={2}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'query',
})}
/>
</FormField>
</Card>
))}
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() =>
setOperations([
...operations,
{ expression: '', target_variable: '' },
])
}
className="self-start"
>
<Plus />
{t('agents.workflow.builder.addAssignment')}
</Button>
</div>
);
}
@@ -0,0 +1,12 @@
import { type Node } from 'reactflow';
/** Props every node settings panel body takes. */
export interface NodePanelBodyProps {
/** The node being edited. */
node: Node;
/** Merge fields into the node's data (a snapshot first unless disabled). */
onUpdate: (
data: Record<string, unknown>,
options?: { snapshot?: boolean },
) => void;
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { findFreePosition } from './workflowHelpers';
const box = (id: string, x: number, y: number, width = 200, height = 60) => ({
id,
position: { x, y },
width,
height,
});
describe('findFreePosition', () => {
it('keeps the wanted spot when nothing is there', () => {
expect(findFreePosition([box('a', 0, 0)], { x: 400, y: 0 })).toEqual({
x: 400,
y: 0,
});
});
it('moves down past a node that covers the spot', () => {
const spot = findFreePosition([box('a', 380, 0)], { x: 400, y: 0 });
expect(spot.x).toBe(400);
expect(spot.y).toBeGreaterThanOrEqual(60 + 24);
});
it('keeps moving down past a column of nodes', () => {
const nodes = [box('a', 400, 0), box('b', 400, 84), box('c', 400, 168)];
const spot = findFreePosition(nodes, { x: 400, y: 0 });
expect(spot.y).toBeGreaterThanOrEqual(168 + 60 + 24);
});
it('uses a default size for nodes React Flow has not measured yet', () => {
const spot = findFreePosition([{ id: 'a', position: { x: 400, y: 0 } }], {
x: 450,
y: 10,
});
expect(spot.y).toBeGreaterThan(10);
});
});
@@ -0,0 +1,204 @@
import { type TFunction } from 'i18next';
import { ConditionCase } from '../types/workflow';
import { FilePassing } from './documentConfig';
// Names and handles are the user's own text: React escapes on render, so
// i18next must not escape them first.
export const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
export interface AgentNodeConfig {
agent_type: 'classic' | 'research';
llm_name?: string;
model_id?: string;
system_prompt: string;
prompt_template: string;
output_variable?: string;
stream_to_user: boolean;
sources: string[];
tools: string[];
chunks?: string;
retriever?: string;
json_schema?: Record<string, unknown>;
input_documents?: string[];
file_passing?: FilePassing;
}
export interface UserTool {
id: string;
name: string;
displayName: string;
customName?: string;
// Workflow-only builtins (e.g. read_document) are kept here; the classic
// agent picker filters them out.
workflow_only?: boolean;
}
/**
* Check an agent node's structured-output schema.
*
* Args:
* schema: The parsed schema, or undefined when none is set.
*
* Returns:
* A short English error fragment, or null when the schema is usable.
*/
export function validateJsonSchemaConfig(schema: unknown): string | null {
if (schema === undefined || schema === null) return null;
if (typeof schema !== 'object' || Array.isArray(schema)) {
return 'must be a valid JSON object';
}
const schemaObject = schema as Record<string, unknown>;
if (!('schema' in schemaObject) && !('type' in schemaObject)) {
return 'must include either a "type" or "schema" field';
}
return null;
}
// The schema validators return short English fragments (tests and the
// validation list key off them); these map each to its locale key.
const SCHEMA_ERROR_KEYS: Record<string, string> = {
'must be a valid JSON object': 'agents.workflow.schema.notObject',
'must include either a "type" or "schema" field':
'agents.workflow.schema.missingType',
'must be valid JSON': 'agents.workflow.schema.invalidJson',
};
/**
* Translate a JSON schema validation fragment.
*
* Args:
* t: The i18next translate function.
* fragment: The fragment a schema validator returned.
*
* Returns:
* The translated fragment, or the fragment itself when it is unknown.
*/
export function schemaErrorText(t: TFunction, fragment: string): string {
const key = SCHEMA_ERROR_KEYS[fragment];
return key ? t(key) : fragment;
}
/**
* Give every condition case a unique `case_N` source handle.
*
* Args:
* cases: The cases as stored.
*
* Returns:
* The cases, with missing or duplicate handles replaced.
*/
export function normalizeConditionCases(
cases: ConditionCase[],
): ConditionCase[] {
const usedHandles = new Set<string>();
let nextIndex = 0;
return cases.map((conditionCase) => {
const candidate = (conditionCase.sourceHandle || '').trim();
if (candidate && !usedHandles.has(candidate)) {
usedHandles.add(candidate);
const match = candidate.match(/^case_(\d+)$/);
if (match) {
nextIndex = Math.max(nextIndex, Number(match[1]) + 1);
}
return conditionCase;
}
while (usedHandles.has(`case_${nextIndex}`)) {
nextIndex += 1;
}
const generatedHandle = `case_${nextIndex}`;
usedHandles.add(generatedHandle);
nextIndex += 1;
return {
...conditionCase,
sourceHandle: generatedHandle,
};
});
}
/**
* The next free `case_N` handle for a new condition case.
*
* Args:
* cases: The existing cases.
*
* Returns:
* An unused handle.
*/
export function getNextConditionHandle(cases: ConditionCase[]): string {
const usedHandles = new Set(
cases.map((conditionCase) => conditionCase.sourceHandle).filter(Boolean),
);
const usedIndices = Array.from(usedHandles)
.map((handle) => handle.match(/^case_(\d+)$/))
.filter((match): match is RegExpMatchArray => Boolean(match))
.map((match) => Number(match[1]));
let nextIndex = usedIndices.length > 0 ? Math.max(...usedIndices) + 1 : 0;
while (usedHandles.has(`case_${nextIndex}`)) {
nextIndex += 1;
}
return `case_${nextIndex}`;
}
interface PlacedNode {
id: string;
position: { x: number; y: number };
width?: number | null;
height?: number | null;
}
// Nodes React Flow hasn't measured yet count as a typical node.
const DEFAULT_NODE_WIDTH = 200;
const DEFAULT_NODE_HEIGHT = 64;
const NODE_GAP = 24;
const MAX_PLACEMENT_STEPS = 50;
/**
* Find a spot for a new node that doesn't cover an existing one.
*
* Starts at `wanted` (the new node's top-left corner) and steps straight down
* until the new node's box, with a gap around it, clears every other node.
*
* Args:
* nodes: The nodes already on the canvas.
* wanted: Where the new node would ideally go.
* size: The new node's expected size.
*
* Returns:
* The first free top-left position at or below `wanted`.
*/
export function findFreePosition(
nodes: PlacedNode[],
wanted: { x: number; y: number },
size: { width: number; height: number } = {
width: DEFAULT_NODE_WIDTH,
height: DEFAULT_NODE_HEIGHT,
},
): { x: number; y: number } {
const overlapping = (y: number) =>
nodes.find((node) => {
const width = node.width ?? DEFAULT_NODE_WIDTH;
const height = node.height ?? DEFAULT_NODE_HEIGHT;
return (
wanted.x < node.position.x + width + NODE_GAP &&
wanted.x + size.width + NODE_GAP > node.position.x &&
y < node.position.y + height + NODE_GAP &&
y + size.height + NODE_GAP > node.position.y
);
});
let y = wanted.y;
for (let step = 0; step < MAX_PLACEMENT_STEPS; step += 1) {
const blocker = overlapping(y);
if (!blocker) break;
y = blocker.position.y + (blocker.height ?? DEFAULT_NODE_HEIGHT) + NODE_GAP;
}
return { x: wanted.x, y };
}
+15
View File
@@ -68,6 +68,19 @@ const endpoints = {
ENABLE_GRAPHRAG: (id: string) => `/api/sources/${id}/graphrag/enable`,
SOURCE_GRAPH: (id: string, limit?: number) =>
`/api/sources/${id}/graph${limit ? `?limit=${limit}` : ''}`,
SOURCE_GRAPH_NODES: (
id: string,
params: { q?: string; type?: string; page?: number; perPage?: number },
) => {
const search = new URLSearchParams();
if (params.q) search.set('q', params.q);
// An empty type filters to untyped nodes, so it is sent only when set.
if (params.type !== undefined) search.set('type', params.type);
if (params.page) search.set('page', String(params.page));
if (params.perPage) search.set('per_page', String(params.perPage));
const qs = search.toString();
return `/api/sources/${id}/graph/nodes${qs ? `?${qs}` : ''}`;
},
SOURCE_GRAPH_NODE: (id: string, nodeId: string) =>
`/api/sources/${id}/graph/node/${encodeURIComponent(nodeId)}`,
TASK_STATUS: (taskId: string) => `/api/task_status?task_id=${taskId}`,
@@ -141,6 +154,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,
+9
View File
@@ -153,6 +153,15 @@ const userService = {
limit?: number,
): Promise<Response> =>
throttledApiClient.get(endpoints.USER.SOURCE_GRAPH(sourceId, limit), token),
getSourceGraphNodes: (
sourceId: string,
params: { q?: string; type?: string; page?: number; perPage?: number },
token: string | null,
): Promise<Response> =>
throttledApiClient.get(
endpoints.USER.SOURCE_GRAPH_NODES(sourceId, params),
token,
),
getSourceGraphNode: (
sourceId: string,
nodeId: string,
@@ -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'));
+12 -25
View File
@@ -11,7 +11,7 @@ import {
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import remarkGfm from 'remark-gfm';
import { markdownHeadings } from '@/lib/markdown';
import { markdownHeadings, markdownTables } from '@/lib/markdown';
import { cn } from '@/lib/utils';
import userService from '../api/services/userService';
@@ -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;
@@ -197,6 +197,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
remarkPlugins={[remarkGfm]}
components={{
...markdownHeadings,
...markdownTables,
code(props) {
const {
children,
@@ -528,10 +529,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 +557,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>
+780 -97
View File
@@ -1,47 +1,87 @@
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const { dispatch, service, pagerProps } = vi.hoisted(() => ({
pagerProps: {
current: null as null | { onPageSizeChange?: (size: number) => void },
},
dispatch: vi.fn(),
service: {
getDocumentChunks: vi.fn(),
addChunk: vi.fn(),
updateChunk: vi.fn(),
deleteChunk: vi.fn(),
},
}));
// Keys come back as-is; interpolation values are appended so the tests can
// read them ("key {"n":1}").
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key} ${JSON.stringify(opts)}` : key,
}),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useDispatch: () => dispatch,
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false],
useDebouncedValue: (value: unknown) => value,
useLoaderState: (initial: boolean) => useState(initial),
useMediaQuery: () => ({ isMobile: false }),
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
useOutsideAlerter: () => undefined,
}));
vi.mock('../api/services/userService', () => ({
default: {
getDocumentChunks: vi.fn(async () => ({
ok: true,
json: async () => ({
page: 1,
per_page: 5,
total: 1,
chunks: [{ doc_id: 'c1', text: 'Late pickup clause', metadata: {} }],
}),
})),
},
}));
vi.mock('../api/services/userService', () => ({ default: service }));
// The real pager, with its props kept so a test can pick a page size without
// driving the Select popover.
vi.mock('./ui/pagination', async (importOriginal) => {
const mod = await importOriginal<typeof import('./ui/pagination')>();
return {
...mod,
Pagination: (props: React.ComponentProps<typeof mod.Pagination>) => {
pagerProps.current = props;
return mod.Pagination(props);
},
};
});
import Chunks from './Chunks';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
const chunksResponse = (overrides: Record<string, unknown> = {}) => ({
ok: true,
json: async () => ({
page: 1,
per_page: 12,
total: 1,
chunks: [
{
doc_id: 'c1',
text: '## Late pickup clause',
metadata: { token_count: 42 },
},
],
...overrides,
}),
});
const setFieldValue = (
field: HTMLInputElement | HTMLTextAreaElement,
value: string,
) => {
const proto =
field instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(field, value);
field.dispatchEvent(new Event('input', { bubbles: true }));
};
describe('Chunks', () => {
@@ -49,6 +89,12 @@ describe('Chunks', () => {
let root: Root;
beforeEach(() => {
dispatch.mockReset();
service.getDocumentChunks.mockReset();
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
service.addChunk.mockReset();
service.updateChunk.mockReset();
service.deleteChunk.mockReset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
@@ -57,104 +103,741 @@ describe('Chunks', () => {
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = async (onFileSelect = vi.fn()) => {
const render = async (
props: Partial<React.ComponentProps<typeof Chunks>> = {},
) => {
await act(async () => {
root.render(
<Chunks
documentId="doc"
documentName="Contracts"
handleGoBack={vi.fn()}
onFileSearch={() => [
{ name: 'msa.pdf', path: 'contracts/msa.pdf', isFile: true },
{ name: 'sla.docx', path: 'contracts/sla.docx', isFile: true },
]}
onFileSelect={onFileSelect}
/>,
);
});
return onFileSelect;
};
it('picks a file search result with the keyboard', async () => {
const onFileSelect = await render();
const input = container.querySelector<HTMLInputElement>(
'[data-slot="command-input"]',
)!;
expect(input).not.toBeNull();
await act(async () => setInputValue(input, 'contracts'));
const items = container.querySelectorAll('[data-slot="command-item"]');
expect(items).toHaveLength(2);
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
});
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(onFileSelect).toHaveBeenCalledWith('contracts/sla.docx');
});
it('renders each chunk tile as a button', async () => {
await render();
const tile = container.querySelector<HTMLButtonElement>(
'button[data-slot="card"]',
);
expect(tile).not.toBeNull();
expect(tile!.textContent).toContain('Late pickup clause');
expect(tile!.className).not.toContain('hover:scale-105');
});
const renderPath = async (props: {
onPathSelect?: (depth: number) => void;
handleGoBack?: () => void;
}) => {
await act(async () => {
root.render(
<Chunks
documentId="doc"
documentName="Contracts"
handleGoBack={props.handleGoBack ?? vi.fn()}
path="legal/2024/msa.pdf"
displayPath="legal/2024/msa.pdf"
onPathSelect={props.onPathSelect}
{...props}
/>,
);
});
};
const crumbButton = (label: string) =>
const tile = () =>
container.querySelector<HTMLButtonElement>('button[data-slot="card"]');
const buttonByText = (text: string) =>
Array.from(
document.body.querySelectorAll<HTMLButtonElement>('button'),
).find((el) => el.textContent?.trim() === text);
const crumbs = () =>
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
(el) => el.textContent,
);
const clickCrumb = async (label: string) => {
const link = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-slot="breadcrumb-link"]',
),
).find((el) => el.textContent === label);
if (!link) throw new Error(`no crumb link ${label}`);
await act(async () => link.click());
};
it('opens the root and parent folders from the path crumbs', async () => {
const onPathSelect = vi.fn();
await renderPath({ onPathSelect });
const buttonByLabel = (label: string) =>
document.body.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
);
await act(async () => crumbButton('Contracts')!.click());
expect(onPathSelect).toHaveBeenLastCalledWith(0);
await act(async () => crumbButton('2024')!.click());
expect(onPathSelect).toHaveBeenLastCalledWith(2);
// The file is the current crumb, not a link.
expect(crumbButton('msa.pdf')).toBeUndefined();
it('has no file search column', async () => {
await render();
expect(container.querySelector('[data-slot="command-input"]')).toBeNull();
expect(container.innerHTML).not.toContain('198px');
});
it('without onPathSelect the root goes back and folders are text', async () => {
const handleGoBack = vi.fn();
await renderPath({ handleGoBack });
it('draws each chunk as a filled tile with a cleaned preview and #n · tokens footer', async () => {
await render();
const card = tile()!;
expect(card.dataset.variant).toBe('filled');
expect(card.dataset.padding).toBe('lg');
expect(card.querySelector('p')!.textContent).toBe('Late pickup clause');
const footer = card.querySelector('[data-slot="card-footer"]')!;
expect(footer.textContent).toBe(
'settings.sources.chunkTileMeta {"n":1,"tokens":"42"}',
);
// No muted strip on the muted tile: it would disappear.
expect(card.querySelector('.bg-muted')).toBeNull();
});
await act(async () => crumbButton('Contracts')!.click());
it('numbers tiles from the page offset', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
page: 2,
total: 30,
chunks: [
{ doc_id: 'a', text: 'A', metadata: {} },
{ doc_id: 'b', text: 'B', metadata: {} },
],
}),
);
await render();
const footers = Array.from(
container.querySelectorAll('[data-slot="card-footer"]'),
).map((el) => el.textContent);
expect(footers[0]).toContain('"n":13');
expect(footers[1]).toContain('"n":14');
});
// 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();
expect(tile()!.parentElement!.className).toBe(
'grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]',
);
});
it('shows the search field, the muted count and Add chunk', async () => {
await render({ embedded: true });
expect(container.textContent).toContain('settings.sources.searchChunks');
expect(container.textContent).toContain(
'settings.sources.chunkCount {"count":1,"formatted":"1"}',
);
expect(buttonByText('settings.sources.addChunk')).toBeDefined();
});
// The header's byline already carries it; only a chunk list inside a file
// tree (whose byline is the tree's totals) repeats the count in the toolbar.
it('standalone: the chunk count shows once, in the byline', async () => {
await render();
const count = 'settings.sources.chunkCount {"count":1,"formatted":"1"}';
expect(container.textContent!.split(count)).toHaveLength(2);
expect(
container.querySelector('p.text-sm.text-muted-foreground')?.textContent,
).toBe(count);
});
it('asks for 12 chunks per page and labels the page-size select', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({ total: 30 }),
);
await render();
expect(service.getDocumentChunks.mock.calls[0][2]).toBe(12);
const pager = container.querySelector('[data-slot="pagination"]')!;
expect(pager.textContent).toContain('pagination.chunksPerPage');
});
// Bugs row 1: at 48 per page a 30-chunk source still keeps its pager, so a
// smaller page size can be picked again.
it('keeps the pager when the page size outgrows the chunk count', async () => {
service.getDocumentChunks.mockImplementation(
async (_id: string, _page: number, perPage: number) =>
chunksResponse({ total: 30, per_page: perPage }),
);
await render();
await act(async () => pagerProps.current!.onPageSizeChange!(48));
expect(service.getDocumentChunks.mock.lastCall![2]).toBe(48);
const pager = container.querySelector('[data-slot="pagination"]');
expect(pager).not.toBeNull();
expect(pager!.textContent).toContain('pagination.chunksPerPage');
});
it('draws no pager for 12 chunks or fewer', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({ total: 12 }),
);
await render();
expect(container.querySelector('[data-slot="pagination"]')).toBeNull();
});
it('standalone: Sources › the source, and the Sources crumb leaves it', async () => {
const handleGoBack = vi.fn();
await render({ handleGoBack, headerAction: <span>retrieval</span> });
expect(crumbs()).toEqual(['settings.sources.label', 'Contracts']);
expect(container.textContent).toContain('retrieval');
await clickCrumb('settings.sources.label');
expect(handleGoBack).toHaveBeenCalledTimes(1);
expect(crumbButton('legal')).toBeUndefined();
expect(crumbButton('2024')).toBeUndefined();
});
it('embedded: draws no header', async () => {
await render({ embedded: true });
expect(container.querySelector('[data-slot="breadcrumb-item"]')).toBeNull();
});
const drawer = () => document.body.querySelector('[role="dialog"]');
// Chunk n of a 3-chunk list served one per page.
const THREE = ['# First', '# Second', '# Third'];
const serveThree = () =>
service.getDocumentChunks.mockImplementation(
async (_id: string, page: number, perPage: number) =>
perPage === 1
? chunksResponse({
page,
per_page: 1,
total: 3,
chunks: [
{
doc_id: `c${page}`,
text: THREE[page - 1],
metadata: { token_count: page },
},
],
})
: chunksResponse({
total: 3,
chunks: THREE.map((text, i) => ({
doc_id: `c${i + 1}`,
text,
metadata: { token_count: i + 1 },
})),
}),
);
it('opens a chunk rendered in the reader panel and returns to the list', async () => {
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
{ current: null };
const onOpenChunkChange = vi.fn();
await render({ embedded: true, controllerRef, onOpenChunkChange });
await act(async () => tile()!.click());
expect(tile()).toBeNull();
expect(container.textContent).toContain(
'settings.sources.chunkPosition {"n":1,"total":1,"tokens":"42"}',
);
// Markdown reads rendered: no textarea, the heading is a heading.
expect(container.querySelector('textarea')).toBeNull();
expect(container.querySelector('h2')?.textContent).toBe(
'Late pickup clause',
);
// The host's crumbs close it; the reader has no Back of its own.
expect(onOpenChunkChange).toHaveBeenLastCalledWith(1);
expect(container.querySelector('.lucide-arrow-left')).toBeNull();
await act(async () => controllerRef.current!.closeChunk());
expect(tile()).not.toBeNull();
expect(onOpenChunkChange).toHaveBeenLastCalledWith(null);
});
it('renders a markdown heading as an h1', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
chunks: [{ doc_id: 'c1', text: '# Rates\n\nBody', metadata: {} }],
}),
);
await render({ embedded: true });
await act(async () => tile()!.click());
expect(container.querySelector('h1')?.textContent).toBe('Rates');
});
it('standalone: an open chunk is the last crumb and the source crumb closes it', async () => {
const handleGoBack = vi.fn();
await render({ handleGoBack });
await act(async () => tile()!.click());
expect(crumbs()).toEqual([
'settings.sources.label',
'Contracts',
'settings.sources.chunkCrumb {"n":1}',
]);
await clickCrumb('Contracts');
expect(handleGoBack).not.toHaveBeenCalled();
expect(tile()).not.toBeNull();
expect(crumbs()).toEqual(['settings.sources.label', 'Contracts']);
});
it('previous / next fetch chunk n one per page and stop at the ends', async () => {
serveThree();
await render({ embedded: true, path: 'a/b.md' });
await act(async () => tile()!.click());
const prev = () => buttonByLabel('settings.sources.previousChunk')!;
const next = () => buttonByLabel('settings.sources.nextChunk')!;
expect(prev().disabled).toBe(true);
expect(next().disabled).toBe(false);
service.getDocumentChunks.mockClear();
await act(async () => next().click());
expect(service.getDocumentChunks).toHaveBeenCalledWith(
'doc',
2,
1,
null,
'a/b.md',
'',
);
expect(container.querySelector('h1')?.textContent).toBe('Second');
expect(container.textContent).toContain(
'settings.sources.chunkPosition {"n":2,"total":3,"tokens":"2"}',
);
await act(async () => next().click());
expect(container.querySelector('h1')?.textContent).toBe('Third');
expect(next().disabled).toBe(true);
expect(prev().disabled).toBe(false);
await act(async () => prev().click());
expect(service.getDocumentChunks).toHaveBeenLastCalledWith(
'doc',
2,
1,
null,
'a/b.md',
'',
);
expect(container.querySelector('h1')?.textContent).toBe('Second');
});
it('the arrow keys walk the chunks while no field has focus', async () => {
serveThree();
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
});
expect(container.querySelector('h1')?.textContent).toBe('Second');
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
});
expect(container.querySelector('h1')?.textContent).toBe('First');
});
// 13 chunks, 12 per page: chunk 13 sits on page 2.
const serveThirteen = () =>
service.getDocumentChunks.mockImplementation(
async (_id: string, page: number, perPage: number) =>
perPage === 1
? chunksResponse({
page,
per_page: 1,
total: 13,
chunks: [
{ doc_id: `c${page}`, text: `# C${page}`, metadata: {} },
],
})
: chunksResponse({
page,
total: 13,
chunks: Array.from({ length: page === 2 ? 1 : 12 }, (_, i) => ({
doc_id: `p${page}-${i}`,
text: `tile ${page}-${i}`,
metadata: {},
})),
}),
);
it('back to the grid lands on the page holding the last chunk', async () => {
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
{ current: null };
serveThirteen();
await render({ embedded: true, controllerRef });
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[11].click());
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
expect(container.querySelector('h1')?.textContent).toBe('C13');
await act(async () => controllerRef.current!.closeChunk());
const lastGridCall = service.getDocumentChunks.mock.calls
.filter((call) => call[2] !== 1)
.pop()!;
expect(lastGridCall[1]).toBe(2);
expect(tile()!.textContent).toContain('tile 2-0');
});
it('Edit opens the drawer with the chunk text and saves it', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
chunks: [
{
doc_id: 'c1',
text: '## Late pickup clause',
metadata: { token_count: 42, title: 'Clause' },
},
],
}),
);
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
await render({ embedded: true, path: 'legal/msa.pdf' });
await act(async () => tile()!.click());
// No in-panel edit mode any more.
expect(buttonByText('modals.chunk.cancel')).toBeUndefined();
await act(async () => buttonByText('modals.chunk.edit')!.click());
expect(drawer()).not.toBeNull();
expect(drawer()!.textContent).toContain('settings.sources.editChunk');
expect(drawer()!.textContent).toContain(
'settings.sources.editChunkDescription {"file":"legal/msa.pdf","n":1,"tokens":"42"',
);
const field = drawer()!.querySelector('textarea')!;
expect(field.value).toBe('## Late pickup clause');
await act(async () => setFieldValue(field, '## New text'));
service.getDocumentChunks.mockClear();
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(service.updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: '## New text',
metadata: { title: 'Clause' },
},
null,
);
expect(drawer()).toBeNull();
// That chunk and the grid page are fetched again.
const sizes = service.getDocumentChunks.mock.calls.map((call) => call[2]);
expect(sizes).toContain(1);
expect(sizes).toContain(12);
});
it('an edit the search no longer matches still shows the saved text', async () => {
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
// After the save, the open position holds nothing (or another chunk).
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({ total: 0, chunks: [] }),
);
const field = drawer()!.querySelector('textarea')!;
await act(async () => setFieldValue(field, '## Rewritten'));
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(drawer()).toBeNull();
expect(container.querySelector('h2')?.textContent).toBe('Rewritten');
expect(container.textContent).toContain('chunkPositionUnplaced');
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
});
it('deleting the only chunk on the last page lands on the page before', async () => {
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
{ current: null };
serveThirteen();
service.deleteChunk.mockImplementation(async () => ({ ok: true }));
await render({ embedded: true, controllerRef });
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[11].click());
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
expect(container.querySelector('h1')?.textContent).toBe('C13');
const trigger = buttonByLabel('settings.sources.menuAlt')!;
await act(async () => {
trigger.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
);
trigger.click();
});
const del = Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((el) => el.textContent === 'modals.chunk.delete')!;
await act(async () => del.click());
await act(async () => buttonByText('modals.chunk.delete')!.click());
expect(service.deleteChunk).toHaveBeenCalledWith('doc', 'c13', null);
const lastGridCall = service.getDocumentChunks.mock.calls
.filter((call) => call[2] !== 1)
.pop()!;
expect(lastGridCall[1]).toBe(1);
});
it('drops a grid response that a newer fetch overtook', async () => {
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
{ current: null };
serveThirteen();
await render({ embedded: true, controllerRef });
// Open chunk 13 (page 2), then start a slow page-1 refresh and close the
// chunk before it answers.
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[11].click());
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
let release: () => void = () => undefined;
const slow = new Promise<void>((resolve) => {
release = resolve;
});
const fast = service.getDocumentChunks.getMockImplementation()! as (
...a: unknown[]
) => unknown;
// Grid page 1 answers only once released.
service.getDocumentChunks.mockImplementation(async (...args: unknown[]) => {
if (args[1] === 1 && args[2] !== 1) await slow;
return fast(...args);
});
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c13' }),
}));
await act(async () => buttonByText('modals.chunk.edit')!.click());
const field = drawer()!.querySelector('textarea')!;
await act(async () => setFieldValue(field, '# C13 edited'));
await act(async () => buttonByText('modals.chunk.save')!.click());
await act(async () => controllerRef.current!.closeChunk());
expect(tile()!.textContent).toContain('tile 2-0');
await act(async () => release());
expect(tile()!.textContent).toContain('tile 2-0');
});
it('a failed save keeps the drawer open with a destructive alert', async () => {
service.updateChunk.mockImplementation(async () => ({ ok: false }));
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
const field = drawer()!.querySelector('textarea')!;
await act(async () => setFieldValue(field, 'New text'));
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(service.updateChunk).toHaveBeenCalledTimes(1);
expect(drawer()).not.toBeNull();
const alert = drawer()!.querySelector('[data-slot="alert"]')!;
expect(alert.getAttribute('data-variant')).toBe('destructive');
expect(alert.textContent).toBe('settings.sources.chunkErrors.save');
expect(dispatch).not.toHaveBeenCalled();
// The edits stay in the drawer.
expect(drawer()!.querySelector('textarea')!.value).toBe('New text');
});
it('Add chunk opens the drawer empty, adds and stays on the grid', async () => {
service.addChunk.mockImplementation(async () => ({ ok: true }));
await render({ embedded: true, path: 'a.md' });
await act(async () => buttonByText('settings.sources.addChunk')!.click());
expect(
drawer()!.querySelector('h2, [data-slot="sheet-title"]')!.textContent,
).toBe('settings.sources.addChunk');
const field = drawer()!.querySelector('textarea')!;
expect(field.value).toBe('');
// The title defaults to the file's name, as ingest sets it.
expect(drawer()!.querySelector('input')!.value).toBe('a.md');
await act(async () => setFieldValue(field, 'Fresh'));
service.getDocumentChunks.mockClear();
await act(async () => buttonByText('modals.chunk.add')!.click());
expect(service.addChunk).toHaveBeenCalledWith(
{
id: 'doc',
text: 'Fresh',
metadata: { source: 'a.md', source_id: 'doc', title: 'a.md' },
},
null,
);
expect(drawer()).toBeNull();
expect(service.getDocumentChunks).toHaveBeenCalledTimes(1);
expect(tile()).not.toBeNull();
});
it("Add chunk's title follows the file's other chunks, then its display name", async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
chunks: [
{ doc_id: 'c1', text: 'x', metadata: { title: 'Carrier profile' } },
],
}),
);
await render({
embedded: true,
path: 'x_1.docx',
fileName: 'Carrier.docx',
});
await act(async () => buttonByText('settings.sources.addChunk')!.click());
expect(drawer()!.querySelector('input')!.value).toBe('Carrier profile');
await act(async () =>
buttonByText('settings.sources.editor.cancel')!.click(),
);
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
await render({ embedded: true, path: 'x_2.docx', fileName: 'Other.docx' });
await act(async () => buttonByText('settings.sources.addChunk')!.click());
expect(drawer()!.querySelector('input')!.value).toBe('Other.docx');
});
it('editing only the title enables Save and sends it', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
chunks: [
{ doc_id: 'c1', text: 'Body', metadata: { title: 'Old name' } },
],
}),
);
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
const title = drawer()!.querySelector('input')!;
expect(title.value).toBe('Old name');
expect(buttonByText('modals.chunk.save')!.disabled).toBe(true);
await act(async () => setFieldValue(title, 'New name'));
expect(buttonByText('modals.chunk.save')!.disabled).toBe(false);
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(service.updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: 'Body',
metadata: { title: 'New name' },
},
null,
);
});
it('saving an untitled chunk sends no empty title', async () => {
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
await act(async () =>
setFieldValue(drawer()!.querySelector('textarea')!, 'Rewritten'),
);
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(service.updateChunk).toHaveBeenCalledWith(
{ id: 'doc', chunk_id: 'c1', text: 'Rewritten' },
null,
);
});
it('an Add drawer opened before the grid lands closes without a discard prompt', async () => {
let release: () => void = () => undefined;
const slow = new Promise<void>((resolve) => {
release = resolve;
});
service.getDocumentChunks.mockImplementation(async () => {
await slow;
return chunksResponse({
chunks: [{ doc_id: 'c1', text: 'x', metadata: { title: 'Profile' } }],
});
});
await render({ embedded: true, fileName: 'Carrier.docx' });
await act(async () => buttonByText('settings.sources.addChunk')!.click());
expect(drawer()!.querySelector('input')!.value).toBe('Carrier.docx');
await act(async () => release());
await act(async () =>
buttonByText('settings.sources.editor.cancel')!.click(),
);
expect(document.body.textContent).not.toContain(
'settings.sources.editor.discardMessage',
);
expect(drawer()).toBeNull();
});
/**
* A 3-chunk store the tests reorder: an update adds the new copy where
* `placeAt` says (the end by default) and drops the old one.
*/
const serveStore = (placeAt?: number) => {
const store = [
{ doc_id: 'c1', text: '# First', metadata: {} },
{ doc_id: 'c2', text: '# Second', metadata: {} },
{ doc_id: 'c3', text: '# Third', metadata: {} },
];
service.getDocumentChunks.mockImplementation(
async (_id: string, page: number, perPage: number) => {
const start = (page - 1) * perPage;
return chunksResponse({
page,
per_page: perPage,
total: store.length,
chunks: store.slice(start, start + perPage),
});
},
);
service.updateChunk.mockImplementation(
async (body: { chunk_id: string; text: string }) => {
const index = store.findIndex((c) => c.doc_id === body.chunk_id);
const [old] = store.splice(index, 1);
const copy = { ...old, doc_id: `${old.doc_id}-new`, text: body.text };
store.splice(placeAt ?? store.length, 0, copy);
return { ok: true, json: async () => ({ chunk_id: copy.doc_id }) };
},
);
};
const editOpenChunk = async (text: string) => {
await act(async () => buttonByText('modals.chunk.edit')!.click());
await act(async () =>
setFieldValue(drawer()!.querySelector('textarea')!, text),
);
await act(async () => buttonByText('modals.chunk.save')!.click());
};
const position = () =>
container
.textContent!.match(/chunkPosition \{"n":(\d+),"total":(\d+)/)!
.slice(1)
.map(Number);
it('an edited chunk the store moved to the end is shown at its new position', async () => {
serveStore();
await render({ embedded: true });
await act(async () => tile()!.click());
await editOpenChunk('# First edited');
expect(container.querySelector('h1')?.textContent).toBe('First edited');
expect(position()).toEqual([3, 3]);
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
// Previous walks the store's current order: nothing is skipped.
await act(async () =>
buttonByLabel('settings.sources.previousChunk')!.click(),
);
expect(container.querySelector('h1')?.textContent).toBe('Third');
await act(async () =>
buttonByLabel('settings.sources.previousChunk')!.click(),
);
expect(container.querySelector('h1')?.textContent).toBe('Second');
});
it('an edited chunk the store moved off the probed positions keeps its text but not its place', async () => {
serveStore(1);
await render({ embedded: true });
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[0].click());
service.getDocumentChunks.mockClear();
await editOpenChunk('# First edited');
expect(container.querySelector('h1')?.textContent).toBe('First edited');
// Its position is unknown: no number is claimed and paging is off, so
// previous / next can't step from a place the chunk no longer holds.
expect(container.textContent).not.toMatch(/chunkPosition \{/);
expect(container.textContent).toContain('chunkPositionUnplaced');
expect(buttonByLabel('settings.sources.previousChunk')!.disabled).toBe(
true,
);
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
// Only the open and last positions are probed: the whole filtered list
// (one page of `total`) is never fetched.
const sizes = service.getDocumentChunks.mock.calls.map(
(call: unknown[]) => call[2],
);
expect(sizes).toContain(1);
expect(sizes).not.toContain(3);
});
it('an edited chunk that keeps its place stays put', async () => {
serveStore(0);
await render({ embedded: true });
await act(async () => tile()!.click());
await editOpenChunk('# First edited');
expect(position()).toEqual([1, 3]);
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
expect(container.querySelector('h1')?.textContent).toBe('Second');
});
it('shows a destructive empty state with Retry when the fetch fails', async () => {
service.getDocumentChunks.mockImplementation(async () => ({ ok: false }));
await render();
const alert = container.querySelector('[role="alert"]')!;
expect(alert).not.toBeNull();
expect(alert.textContent).toContain('settings.sources.chunkErrors.load');
expect(tile()).toBeNull();
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
await act(async () => buttonByText('retry')!.click());
expect(tile()).not.toBeNull();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,147 @@
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useDispatch: () => vi.fn(),
useStore: () => ({ getState: () => ({}), subscribe: () => () => {} }),
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false],
useDebouncedValue: (value: unknown) => value,
useLoaderState: (initial: boolean) => useState(initial),
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
useOutsideAlerter: () => undefined,
}));
vi.mock('../api/services/userService', () => ({
default: {
getDirectoryStructure: vi.fn(async () => ({
json: async () => ({
provider: 'google_drive',
directory_structure: {
'a.docx': { type: 'docx' },
'b.docx': { type: 'docx' },
},
}),
})),
getDocumentChunks: vi.fn(async () => ({
ok: true,
json: async () => ({ page: 1, per_page: 12, total: 0, chunks: [] }),
})),
},
}));
import ConnectorTree, { providerLabel } from './ConnectorTree';
import FileTree from './FileTree';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('providerLabel', () => {
it('names known providers and title-cases the rest', () => {
expect(providerLabel('google_drive')).toBe('Google Drive');
expect(providerLabel('share_point')).toBe('SharePoint');
expect(providerLabel('box_sync')).toBe('Box Sync');
});
});
describe('ConnectorTree and FileTree headers', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (node: React.ReactNode) => {
await act(async () => root.render(node));
};
const badge = () => container.querySelector('[data-slot="badge"]');
const retrieval = <button type="button">retrieval</button>;
it('ConnectorTree shows the provider badge, headerAction and Sync', async () => {
await render(
<ConnectorTree
docId="doc"
sourceName="Drive"
onBackToDocuments={vi.fn()}
headerAction={retrieval}
/>,
);
expect(badge()!.textContent).toBe('Google Drive');
expect(badge()!.getAttribute('data-variant')).toBe('neutral');
expect(container.textContent).toContain('retrieval');
expect(container.textContent).toContain('settings.sources.sync');
});
it('embedded ConnectorTree drops the badge and headerAction, keeps Sync', async () => {
await render(
<ConnectorTree
embedded
docId="doc"
sourceName="Drive"
onBackToDocuments={vi.fn()}
headerAction={retrieval}
/>,
);
expect(badge()).toBeNull();
expect(container.textContent).not.toContain('retrieval');
expect(container.textContent).toContain('settings.sources.sync');
expect(container.textContent).not.toContain('settings.sources.label');
});
it('embedded FileTree drops headerAction, keeps Add file', async () => {
await render(
<FileTree
embedded
docId="doc"
sourceName="Files"
onBackToDocuments={vi.fn()}
headerAction={retrieval}
/>,
);
expect(badge()).toBeNull();
expect(container.textContent).not.toContain('retrieval');
expect(container.textContent).toContain('settings.sources.addFile');
});
const crumbs = () =>
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
(el) => el.textContent,
);
it('ConnectorTree and FileTree open the initialPath file', async () => {
await render(
<ConnectorTree
docId="doc"
sourceName="Drive"
onBackToDocuments={vi.fn()}
initialPath="b.docx"
/>,
);
expect(crumbs()).toEqual(['settings.sources.label', 'Drive', 'b.docx']);
await render(
<FileTree
docId="doc"
sourceName="Files"
onBackToDocuments={vi.fn()}
initialPath="a.docx"
/>,
);
expect(crumbs()).toEqual(['settings.sources.label', 'Files', 'a.docx']);
});
});
+53 -10
View File
@@ -1,14 +1,16 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Check, RefreshCw } from 'lucide-react';
import { Check, Cloud, RefreshCw } from 'lucide-react';
import userService from '../api/services/userService';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import type { Crumb } from './tree/PathHeader';
import TreeBrowser from './tree/TreeBrowser';
import type { TreeBrowserController } from './tree/types';
import { useReingestSseWaiter } from './tree/useReingestWait';
@@ -19,6 +21,38 @@ interface ConnectorTreeProps {
onBackToDocuments: () => void;
/** Extra header control, rendered left of the Sync button. */
headerAction?: React.ReactNode;
/**
* Inside another source view (the graph source's Files tab): no Sources
* crumb, badge or byline, and no headerAction; Sync stays.
*/
embedded?: boolean;
/** Embedded only: the host header's action slot (see TreeBrowser). */
actionsTarget?: HTMLElement | null;
/** A file to open once the structure loads (path, file name or display name). */
initialPath?: string;
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
onCrumbsChange?: (crumbs: Crumb[]) => void;
}
// Provider names are brand names, so they are not translated.
const PROVIDER_LABELS: Record<string, string> = {
google_drive: 'Google Drive',
share_point: 'SharePoint',
confluence: 'Confluence',
};
/**
* The display name of a connector provider: a known brand name, else the raw
* id with underscores as spaces, title-cased ("box_sync" → "Box Sync").
*/
export function providerLabel(provider: string): string {
const known = PROVIDER_LABELS[provider.toLowerCase()];
if (known) return known;
return provider
.split('_')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
const ConnectorTree: React.FC<ConnectorTreeProps> = ({
@@ -26,6 +60,10 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
sourceName,
onBackToDocuments,
headerAction,
embedded = false,
actionsTarget,
initialPath,
onCrumbsChange,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -106,20 +144,13 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
const topRightAction = (
<>
{headerAction}
{embedded ? null : headerAction}
<Button
type="button"
size="field"
shape="pill"
onClick={() => setSyncConfirmationModal('ACTIVE')}
disabled={isSyncing}
title={
isSyncing
? `${t('settings.sources.syncing')} ${syncProgress}%`
: syncDone
? 'Done'
: t('settings.sources.sync')
}
>
{syncDone ? (
<Check />
@@ -133,7 +164,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
{isSyncing
? `${syncProgress}%`
: syncDone
? 'Done'
? t('settings.sources.syncDone')
: t('settings.sources.sync')}
</Button>
</>
@@ -155,6 +186,18 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
docId={docId}
sourceName={sourceName}
onBackToDocuments={onBackToDocuments}
embedded={embedded}
onCrumbsChange={onCrumbsChange}
actionsTarget={actionsTarget}
initialPath={initialPath}
badge={
sourceProvider ? (
<Badge variant="neutral">
<Cloud />
{providerLabel(sourceProvider)}
</Badge>
) : undefined
}
columnOrder="tokens-first"
sortEntries
controllerRef={controllerRef}
+21 -1
View File
@@ -6,6 +6,7 @@ import { Trash2 } from 'lucide-react';
import { SOURCE_FILE_TREE_ACCEPT_ATTR } from '../constants/fileUpload';
import ConfirmationModal from '../modals/ConfirmationModal';
import { selectToken } from '../preferences/preferenceSlice';
import type { Crumb } from './tree/PathHeader';
import TreeBrowser from './tree/TreeBrowser';
import { Button } from './ui/button';
import type { MenuOption } from './ui/dropdown-menu';
@@ -26,6 +27,17 @@ interface FileTreeProps {
onBackToDocuments: () => void;
/** Extra header control, rendered left of "Add file". */
headerAction?: React.ReactNode;
/**
* Inside another source view (the graph source's Files tab): no Sources
* crumb, badge or byline, and no headerAction; Add file stays.
*/
embedded?: boolean;
/** Embedded only: the host header's action slot (see TreeBrowser). */
actionsTarget?: HTMLElement | null;
/** A file to open once the structure loads (path, file name or display name). */
initialPath?: string;
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
onCrumbsChange?: (crumbs: Crumb[]) => void;
}
const FileTree: React.FC<FileTreeProps> = ({
@@ -33,6 +45,10 @@ const FileTree: React.FC<FileTreeProps> = ({
sourceName,
onBackToDocuments,
headerAction,
embedded = false,
actionsTarget,
initialPath,
onCrumbsChange,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -231,7 +247,7 @@ const FileTree: React.FC<FileTreeProps> = ({
// Add file button is suppressed then.
const topRightAction = (
<>
{headerAction}
{embedded ? null : headerAction}
{!isProcessing ? (
<Button type="button" size="field" shape="pill" onClick={handleAddFile}>
{t('settings.sources.addFile')}
@@ -263,6 +279,10 @@ const FileTree: React.FC<FileTreeProps> = ({
docId={docId}
sourceName={sourceName}
onBackToDocuments={onBackToDocuments}
embedded={embedded}
onCrumbsChange={onCrumbsChange}
actionsTarget={actionsTarget}
initialPath={initialPath}
columnOrder="size-first"
sortEntries={false}
controllerRef={controllerRef}
@@ -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(
+37 -2
View File
@@ -21,7 +21,11 @@ interface FileUploadProps {
showPreview?: boolean;
previewSize?: number;
/** `compact` is a one-row target for forms and panels. */
size?: 'default' | 'compact';
size?: 'default' | 'compact' | 'tile';
/** A tile's width; `fixed` keeps it 64px in a narrow drawer. */
tileSize?: 'responsive' | 'fixed';
/** 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 +57,8 @@ export const FileUpload = ({
showPreview = false,
previewSize = 80,
size = 'default',
tileSize,
currentImage,
children,
className,
uploadText,
@@ -196,8 +202,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">
@@ -225,6 +259,7 @@ export const FileUpload = ({
maxSize={maxSize}
disabled={disabled}
size={size}
tileSize={tileSize}
title={title}
description={description}
icon={<ImageUp />}
+587 -256
View File
@@ -1,103 +1,193 @@
import { forceCollide, type SimulationNodeDatum } from 'd3-force';
import { ArrowLeft, Network, X } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d';
import ForceGraph2D, {
type ForceGraphMethods,
type LinkObject,
type NodeObject,
} from 'react-force-graph-2d';
import userService from '../api/services/userService';
import { selectToken } from '../preferences/preferenceSlice';
import { useMediaQuery } from '../hooks';
import { useThemeVersion } from '../utils/chartUtils';
import { Badge } from './ui/badge';
import { IconButton } from './ui/icon-button';
import { SectionHeader } from './ui/section-header';
import SkeletonLoader from './SkeletonLoader';
import { formatCount } from '../utils/dateTimeUtils';
import GraphCanvasControls from './graph/GraphCanvasControls';
import GraphEntitySearch from './graph/GraphEntitySearch';
import GraphNodePanel, {
GraphNodePanelDock,
type GraphNodeRef,
} from './graph/GraphNodePanel';
import { GraphSeriesDot } from './graph/GraphTypeDot';
import {
ForceGraphData,
GraphNode,
GraphNodeDetail,
GraphOverview,
DIM_ALPHA,
OTHER_GROUP_KEY,
buildAdjacency,
endpointId,
focusSet,
legendGroupOf,
linkTouches,
nodeHasLabel,
pickLabels,
topHubIds,
type LabelBox,
} from './graph/graphCanvasUtils';
import { useGraphNodeDetail } from './graph/useGraphNodeDetail';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { EmptyState } from './ui/empty-state';
import { LoadingState } from './ui/loading-state';
import { Sheet, SheetContent } from './ui/sheet';
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group';
import {
type FoldedGraphTypes,
type ForceGraphData,
type GraphEdge,
type GraphNode,
collideRadius,
escapeDeselects,
maxDegree,
nodeAtPoint,
nodeRadius,
otherTypesList,
readGraphPalette,
toForceGraphData,
} from './graphViewUtils';
/** The overview sizes the "Show top" control offers. */
const GRAPH_LIMITS = [50, 100, 250] as const;
export type GraphLoadStatus = 'loading' | 'error' | 'ready';
interface GraphViewProps {
docId: string;
sourceName: string;
onBackToDocuments: () => void;
/** Extra header control, right-aligned in the title row. */
headerAction?: React.ReactNode;
/** The loaded overview (top nodes by degree and the edges among them). */
data: ForceGraphData;
/** The one type fold shared with the header, the entity list and the panel. */
fold: FoldedGraphTypes;
status: GraphLoadStatus;
onRetry: () => void;
limit: number;
onLimitChange: (limit: number) => void;
selected: GraphNodeRef | null;
onSelect: (node: GraphNodeRef | null) => void;
/** False while another tab is showing; Escape then leaves the selection alone. */
active?: boolean;
/** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */
onOpenInFiles?: (path: string) => void;
}
const GRAPH_LIMIT = 100;
const HIT_SLOP = 4;
type PositionedNode = NodeObject<GraphNode> & { x?: number; y?: number };
const HIT_SLOP = 4;
const ZOOM_STEP = 1.3;
const ZOOM_MS = 200;
const FOCUS_MS = 400;
const FOCUS_ZOOM = 2;
const FIT_PADDING = 40;
const LABEL_PX = 11;
/** Screen-pixel clearance kept between two labels. */
const LABEL_GAP_PX = 2;
/**
* The Graph tab of a knowledge-graph source: entity search, the "Show top"
* size, the type legend (a filter), and the canvas with its controls and the
* docked node panel (a bottom sheet on a phone).
*/
const GraphView: React.FC<GraphViewProps> = ({
docId,
sourceName,
onBackToDocuments,
headerAction,
data,
fold,
status,
onRetry,
limit,
onLimitChange,
selected,
onSelect,
active = true,
onOpenInFiles,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [data, setData] = useState<ForceGraphData>({ nodes: [], links: [] });
const [loading, setLoading] = useState(true);
const [selectedNode, setSelectedNode] = useState<GraphNodeDetail | null>(
null,
);
const [loadingNode, setLoadingNode] = useState(false);
const { isDesktop } = useMediaQuery();
const showTopId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const hoveredNodeIdRef = useRef<string | null>(null);
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
const [size, setSize] = useState({ width: 0, height: 480 });
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphEdge> | undefined>(
undefined,
);
const hoveredIdRef = useRef<string | null>(null);
const fittedRef = useRef(false);
const [size, setSize] = useState({ width: 0, height: 0 });
const [zoom, setZoom] = useState(1);
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
// The canvas can't read CSS variables: resolve the tokens, and re-read them
// whenever the theme changes.
const themeVersion = useThemeVersion();
// themeVersion is not used inside the factory: it only signals a change.
const palette = useMemo(() => readGraphPalette(), [themeVersion]);
const fadedLink = `color-mix(in srgb, ${palette.link} 15%, transparent)`;
useEffect(() => {
let cancelled = false;
setLoading(true);
userService
.getSourceGraph(docId, token, GRAPH_LIMIT)
.then((response) => response.json())
.then((body) => {
if (cancelled) return;
const overview: GraphOverview = {
nodes: body?.nodes ?? [],
edges: body?.edges ?? [],
};
setData(toForceGraphData(overview));
})
.catch((error) => console.error('Error loading graph:', error))
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [docId, token]);
useEffect(() => {
if (!containerRef.current) return;
const element = containerRef.current;
const observer = new ResizeObserver(() => {
setSize({ width: element.clientWidth, height: 480 });
});
observer.observe(element);
setSize({ width: element.clientWidth, height: 480 });
return () => observer.disconnect();
}, [loading, data.nodes.length]);
const selectedId = selected?.id ?? null;
const nodeDetail = useGraphNodeDetail(docId, selectedId);
const maxNodeDegree = useMemo(() => maxDegree(data.nodes), [data.nodes]);
const nodeById = useMemo(
() => new Map(data.nodes.map((node) => [node.id, node])),
[data.nodes],
);
const loadedIds = useMemo(() => new Set(nodeById.keys()), [nodeById]);
const adjacency = useMemo(() => buildAdjacency(data.links), [data.links]);
const focus = useMemo(
() => focusSet(selectedId, adjacency, loadedIds),
[selectedId, adjacency, loadedIds],
);
const legendKeys = useMemo(
() => [
...fold.groups.map((group) => group.key),
...(fold.other.count > 0 ? [OTHER_GROUP_KEY] : []),
],
[fold],
);
const isVisible = useCallback(
(node: GraphNode | undefined) =>
!!node && !hidden.has(legendGroupOf(fold, node.type)),
[fold, hidden],
);
const visibleNodes = useMemo(
() => data.nodes.filter((node) => isVisible(node)),
[data.nodes, isVisible],
);
// The busiest of what's shown keep their labels.
const hubs = useMemo(() => topHubIds(visibleNodes), [visibleNodes]);
// A new overview (another "Show top") starts with a fresh fit.
useEffect(() => {
fittedRef.current = false;
}, [data]);
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const measure = () => {
// A hidden tab measures 0: keep the last size so the canvas survives.
if (element.clientWidth === 0) return;
setSize({ width: element.clientWidth, height: element.clientHeight });
};
const observer = new ResizeObserver(measure);
observer.observe(element);
measure();
return () => observer.disconnect();
}, [status, data.nodes.length]);
const hasCanvas = size.width > 0;
useEffect(() => {
if (!fgRef.current || data.nodes.length === 0) return;
fgRef.current.d3Force(
@@ -107,239 +197,480 @@ const GraphView: React.FC<GraphViewProps> = ({
),
);
fgRef.current.d3ReheatSimulation();
}, [data, maxNodeDegree, size.width]);
}, [data, maxNodeDegree, hasCanvas]);
const handleNodeClick = (node: GraphNode) => {
setLoadingNode(true);
setSelectedNode(null);
userService
.getSourceGraphNode(docId, node.id, token)
.then((response) => response.json())
.then((body) => {
if (body?.node) setSelectedNode(body.node as GraphNodeDetail);
})
.catch((error) => console.error('Error loading graph node:', error))
.finally(() => setLoadingNode(false));
};
// A settled simulation stops drawing, so paint changes once.
const repaint = useCallback(() => {
const fg = fgRef.current;
if (fg) fg.zoom(fg.zoom());
}, []);
useEffect(() => {
repaint();
}, [palette, focus, hidden, fold, repaint]);
const fitView = useCallback(
(durationMs = FOCUS_MS) => {
fgRef.current?.zoomToFit(durationMs, FIT_PADDING, (node) =>
isVisible(node as GraphNode),
);
},
[isVisible],
);
// Centre the selection; zoom in once per new selection. Re-centre when the
// docked panel changes the canvas width.
const zoomedForRef = useRef<string | null>(null);
const hadSelectionRef = useRef(false);
useEffect(() => {
const fg = fgRef.current;
if (!fg) return;
if (!selectedId) {
zoomedForRef.current = null;
if (!hadSelectionRef.current) return;
hadSelectionRef.current = false;
// Deselecting hands the width back to the canvas: refit.
const frame = requestAnimationFrame(() => fitView());
return () => cancelAnimationFrame(frame);
}
hadSelectionRef.current = true;
const node = nodeById.get(selectedId) as PositionedNode | undefined;
if (!node || node.x == null || node.y == null) return;
const { x, y } = node;
const frame = requestAnimationFrame(() => {
fg.centerAt(x, y, FOCUS_MS);
if (zoomedForRef.current !== selectedId) {
zoomedForRef.current = selectedId;
fg.zoom(Math.max(fg.zoom(), FOCUS_ZOOM), FOCUS_MS);
}
});
return () => cancelAnimationFrame(frame);
}, [selectedId, nodeById, size.width, fitView]);
useEffect(() => {
if (!active || !selectedId) return;
const onKeyDown = (event: KeyboardEvent) => {
if (escapeDeselects(event)) onSelect(null);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [active, selectedId, onSelect]);
// Geometric hit test, immune to canvas read-back farbling (e.g. Brave): map
// the pointer into graph coordinates and pick the nearest node directly.
// the pointer into graph coordinates and pick the nearest visible node.
const pickNodeAt = (clientX: number, clientY: number): GraphNode | null => {
const fg = fgRef.current;
if (!fg || !containerRef.current) return null;
const rect = containerRef.current.getBoundingClientRect();
const g = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top);
return nodeAtPoint(data.nodes, g.x, g.y, maxNodeDegree, HIT_SLOP);
return nodeAtPoint(visibleNodes, g.x, g.y, maxNodeDegree, HIT_SLOP);
};
const repaint = () => {
const fg = fgRef.current;
if (fg) fg.zoom(fg.zoom());
};
// A settled simulation stops drawing, so paint the new colours once.
useEffect(() => {
repaint();
}, [palette]);
const onCanvas = (event: React.MouseEvent) =>
event.target instanceof HTMLCanvasElement;
const handlePointerMove = (event: React.MouseEvent<HTMLDivElement>) => {
const node = pickNodeAt(event.clientX, event.clientY);
const node = onCanvas(event)
? pickNodeAt(event.clientX, event.clientY)
: null;
if (containerRef.current) {
containerRef.current.style.cursor = node ? 'pointer' : 'default';
}
const nextId = node?.id ?? null;
if (nextId !== hoveredNodeIdRef.current) {
hoveredNodeIdRef.current = nextId;
if (nextId !== hoveredIdRef.current) {
hoveredIdRef.current = nextId;
repaint();
}
};
const handlePointerLeave = () => {
if (containerRef.current) containerRef.current.style.cursor = 'default';
if (hoveredNodeIdRef.current !== null) {
hoveredNodeIdRef.current = null;
if (hoveredIdRef.current !== null) {
hoveredIdRef.current = null;
repaint();
}
};
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
// Clicks on the controls strip are not canvas clicks.
if (!onCanvas(event)) return;
const node = pickNodeAt(event.clientX, event.clientY);
if (node) handleNodeClick(node);
if (node) onSelect({ id: node.id, name: node.name, type: node.type });
else if (selectedId) onSelect(null);
};
const isEmpty = !loading && data.nodes.length === 0;
const nodeVisibility = useCallback(
(node: NodeObject<GraphNode>) => isVisible(node as GraphNode),
[isVisible],
);
const linkVisibility = useCallback(
(link: LinkObject<GraphNode, GraphEdge>) =>
isVisible(nodeById.get(endpointId(link.source as never))) &&
isVisible(nodeById.get(endpointId(link.target as never))),
[isVisible, nodeById],
);
const linkColor = useCallback(
(link: LinkObject<GraphNode, GraphEdge>) => {
if (!focus) return palette.link;
return linkTouches(link as GraphEdge, selectedId)
? palette.primary
: fadedLink;
},
[focus, selectedId, palette, fadedLink],
);
const linkWidth = useCallback(
(link: LinkObject<GraphNode, GraphEdge>) =>
focus && linkTouches(link as GraphEdge, selectedId) ? 1.5 : 1,
[focus, selectedId],
);
const nodeVal = useCallback(
(node: NodeObject<GraphNode>) =>
nodeRadius((node as GraphNode).degree, maxNodeDegree),
[maxNodeDegree],
);
const nodeCanvasObject = useCallback(
(
node: NodeObject<GraphNode>,
ctx: CanvasRenderingContext2D,
globalScale: number,
) => {
const graphNode = node as PositionedNode;
if (graphNode.x == null || graphNode.y == null) return;
const { x, y } = graphNode;
const r = nodeRadius(graphNode.degree, maxNodeDegree);
const hoveredId = hoveredIdRef.current;
const isSelected = graphNode.id === selectedId;
const series = fold.seriesOf(graphNode.type);
ctx.save();
ctx.globalAlpha = focus && !focus.has(graphNode.id) ? DIM_ALPHA : 1;
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fillStyle =
series == null
? palette.other
: (palette.series[series] ?? palette.other);
ctx.fill();
if (isSelected || graphNode.id === hoveredId) {
ctx.lineWidth = (isSelected ? 2.5 : 1.5) / globalScale;
ctx.strokeStyle = palette.hoverStroke;
ctx.stroke();
}
ctx.restore();
},
[maxNodeDegree, selectedId, fold, focus, palette],
);
// Labels go on top of every node (a per-node label would sit under the
// nodes drawn after it): one pass after the frame. In priority order (the
// selection, the hovered node, then by degree) a label that would sit on
// one already placed is skipped; the first two always draw.
const drawLabels = useCallback(
(ctx: CanvasRenderingContext2D, globalScale: number) => {
const hoveredId = hoveredIdRef.current;
const rank = (node: GraphNode) =>
node.id === selectedId ? 0 : node.id === hoveredId ? 1 : 2;
const labelled = (visibleNodes as PositionedNode[])
.filter(
(node) =>
node.x != null &&
node.y != null &&
nodeHasLabel(node.id, { hubs, focus, hoveredId }),
)
.sort(
(a, b) =>
rank(a) - rank(b) ||
(b.degree || 0) - (a.degree || 0) ||
a.id.localeCompare(b.id),
);
ctx.save();
ctx.font = `${LABEL_PX / globalScale}px ${palette.font}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.lineWidth = 3 / globalScale;
ctx.lineJoin = 'round';
const boxes: (LabelBox & { label: string })[] = labelled.map((node) => {
const label = node.name ?? '';
return {
id: node.id,
label,
x: node.x as number,
y:
(node.y as number) +
nodeRadius(node.degree, maxNodeDegree) +
2 / globalScale,
width: ctx.measureText(label).width,
height: LABEL_PX / globalScale,
always: rank(node) < 2,
};
});
const shown = pickLabels(boxes, LABEL_GAP_PX / globalScale);
for (const box of boxes) {
if (!shown.has(box.id)) continue;
ctx.strokeStyle = palette.halo;
ctx.strokeText(box.label, box.x, box.y);
ctx.fillStyle = palette.label;
ctx.fillText(box.label, box.x, box.y);
}
ctx.restore();
},
[visibleNodes, hubs, focus, maxNodeDegree, palette, selectedId],
);
// Once a new layout settles: fit it, or, with a node selected, centre on
// that node (its position only exists now; the focus effect ran before).
const handleEngineStop = useCallback(() => {
if (fittedRef.current) return;
fittedRef.current = true;
const node = selectedId
? (nodeById.get(selectedId) as PositionedNode | undefined)
: undefined;
if (node && node.x != null && node.y != null) {
fgRef.current?.centerAt(node.x, node.y, FOCUS_MS);
} else {
fitView();
}
}, [fitView, selectedId, nodeById]);
// The graph reports zoom while it renders (a prop update can move the
// view), so the readout follows on the next frame, at most once a frame.
const zoomFrameRef = useRef<number | null>(null);
const handleZoom = useCallback(({ k }: { k: number }) => {
if (zoomFrameRef.current !== null)
cancelAnimationFrame(zoomFrameRef.current);
zoomFrameRef.current = requestAnimationFrame(() => {
zoomFrameRef.current = null;
setZoom(k);
});
}, []);
useEffect(
() => () => {
if (zoomFrameRef.current !== null)
cancelAnimationFrame(zoomFrameRef.current);
},
[],
);
const zoomBy = (factor: number) => {
const fg = fgRef.current;
if (fg) fg.zoom(fg.zoom() * factor, ZOOM_MS);
};
const legendValue = legendKeys.filter((key) => !hidden.has(key));
const otherLabels = fold.other.labels;
const toolbar = (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<GraphEntitySearch docId={docId} fold={fold} onPick={onSelect} />
{/* One phrase at one size: "Show top [50 | 100 | 250] by
connections"; the muted track holds only the group. */}
<div className="flex flex-wrap items-center gap-2">
<span id={showTopId} className="text-muted-foreground text-sm">
{t('settings.sources.graphrag.view.showTop')}
</span>
<div className="bg-muted shrink-0 rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
className="flex-nowrap"
value={String(limit)}
onValueChange={(value) => value && onLimitChange(Number(value))}
aria-labelledby={showTopId}
>
{GRAPH_LIMITS.map((option) => (
<ToggleGroupItem key={option} value={String(option)}>
{option}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<span className="text-muted-foreground text-sm">
{t('settings.sources.graphrag.view.byConnections')}
</span>
</div>
</div>
{legendKeys.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<div className="bg-muted rounded-full p-1">
<ToggleGroup
type="multiple"
size="xs"
value={legendValue}
onValueChange={(values) =>
setHidden(
new Set(legendKeys.filter((key) => !values.includes(key))),
)
}
aria-label={t('settings.sources.graphrag.view.typeFilter')}
>
{fold.groups.map((group) => (
<ToggleGroupItem key={group.key} value={group.key}>
<GraphSeriesDot series={group.series} />
{group.label}
<span className="text-muted-foreground tabular-nums">
{formatCount(group.count)}
</span>
</ToggleGroupItem>
))}
{fold.other.count > 0 ? (
<ToggleGroupItem value={OTHER_GROUP_KEY}>
<GraphSeriesDot series={null} />
{t('settings.analytics.otherSeries')}
<span className="text-muted-foreground tabular-nums">
{formatCount(fold.other.count)}
</span>
</ToggleGroupItem>
) : null}
</ToggleGroup>
</div>
{otherLabels.length > 0 ? (
<span className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.otherTypes', {
types: otherTypesList(otherLabels, (count) =>
t('settings.sources.graphrag.view.otherTypesMore', {
count,
formatted: formatCount(count),
}),
),
interpolation: { escapeValue: false },
})}
</span>
) : null}
</div>
) : null}
</div>
);
if (status === 'error') {
return (
<EmptyState
size="sm"
tone="destructive"
illustration="none"
title={t('settings.sources.graphrag.view.loadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={onRetry}
>
{t('retry')}
</Button>
}
/>
);
}
if (status === 'ready' && data.nodes.length === 0) {
return (
<EmptyState
size="sm"
illustration="none"
title={t('settings.sources.graphrag.view.empty')}
/>
);
}
const panel = selected ? (
<GraphNodePanel
key={selected.id}
docId={docId}
node={selected}
detail={nodeDetail.detail}
status={nodeDetail.status}
onRetry={nodeDetail.retry}
fold={fold}
onClose={() => onSelect(null)}
showClose={isDesktop}
onSelectNode={onSelect}
overview={data}
onOpenInFiles={onOpenInFiles}
onChunkSaved={nodeDetail.reload}
/>
) : null;
return (
<div className="flex flex-col">
<div className="mb-4 flex items-center">
<IconButton
variant="outline"
size="icon-xs"
shape="pill"
className="mr-3"
onClick={onBackToDocuments}
label={t('settings.sources.backToAll')}
icon={ArrowLeft}
side="bottom"
/>
<span className="text-primary font-semibold wrap-break-word">
{sourceName}
</span>
{headerAction ? <div className="ml-auto">{headerAction}</div> : null}
</div>
<div className="bg-muted text-muted-foreground mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
<Network className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>
<span className="text-foreground font-medium">
{t('settings.sources.graphrag.view.title')}
</span>{' '}
{t('settings.sources.graphrag.view.explainer')}
</p>
</div>
{loading ? (
<SkeletonLoader count={4} />
) : isEmpty ? (
<div className="border-border text-muted-foreground flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center text-sm">
<Network className="size-7" aria-hidden="true" />
<p>{t('settings.sources.graphrag.view.empty')}</p>
</div>
) : (
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.stats', {
nodes: data.nodes.length,
edges: data.links.length,
})}
</p>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<div
ref={containerRef}
onMouseMove={handlePointerMove}
onMouseLeave={handlePointerLeave}
onClick={handleContainerClick}
className="border-border bg-card relative min-h-[480px] flex-1 overflow-hidden rounded-xl border"
>
{size.width > 0 && (
<ForceGraph2D
<div className="flex flex-col gap-4">
{toolbar}
<Card
variant="subtle"
padding="none"
className="h-[70svh] flex-row gap-0 overflow-hidden"
>
<div
ref={containerRef}
onMouseMove={handlePointerMove}
onMouseLeave={handlePointerLeave}
onClick={handleContainerClick}
className="relative min-w-0 flex-1"
>
{status === 'loading' ? (
<LoadingState fill="parent" />
) : (
<>
{hasCanvas && (
<ForceGraph2D<GraphNode, GraphEdge>
ref={fgRef}
graphData={data}
width={size.width}
height={size.height}
nodeRelSize={1}
minZoom={0.2}
maxZoom={8}
enablePointerInteraction={false}
enableNodeDrag={false}
nodeVal={(node) =>
nodeRadius((node as GraphNode).degree, maxNodeDegree)
}
linkColor={() => palette.link}
nodeVal={nodeVal}
nodeVisibility={nodeVisibility}
linkVisibility={linkVisibility}
linkColor={linkColor}
linkWidth={linkWidth}
cooldownTicks={80}
nodeCanvasObject={(node, ctx, globalScale) => {
const graphNode = node as GraphNode & {
x?: number;
y?: number;
};
if (graphNode.x == null) return;
const r = nodeRadius(graphNode.degree, maxNodeDegree);
const hovered = hoveredNodeIdRef.current === graphNode.id;
ctx.beginPath();
ctx.arc(graphNode.x, graphNode.y ?? 0, r, 0, 2 * Math.PI);
ctx.fillStyle = palette.node;
ctx.fill();
if (hovered) {
ctx.lineWidth = 2 / globalScale;
ctx.strokeStyle = palette.hoverStroke;
ctx.stroke();
}
if (globalScale >= 1.2) {
const label = graphNode.name ?? '';
const x = graphNode.x;
const y = (graphNode.y ?? 0) + r + 1;
ctx.font = `${10 / globalScale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.lineWidth = 3 / globalScale;
ctx.lineJoin = 'round';
ctx.strokeStyle = palette.halo;
ctx.strokeText(label, x, y);
ctx.fillStyle = palette.label;
ctx.fillText(label, x, y);
}
}}
onEngineStop={handleEngineStop}
onZoom={handleZoom}
nodeCanvasObject={nodeCanvasObject}
onRenderFramePost={drawLabels}
/>
)}
</div>
<aside className="border-border bg-card flex max-h-[480px] w-full shrink-0 flex-col overflow-y-auto rounded-xl border p-4 lg:w-80">
{loadingNode ? (
<SkeletonLoader count={3} />
) : selectedNode ? (
<div className="flex flex-col">
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<SectionHeader
as="h3"
size="xs"
className="wrap-break-word"
title={selectedNode.name}
/>
{selectedNode.type && (
<Badge variant="neutral" className="mt-1">
{selectedNode.type}
</Badge>
)}
</div>
<IconButton
variant="ghost-muted"
size="icon-xs"
className="shrink-0"
onClick={() => setSelectedNode(null)}
label={t('settings.sources.graphrag.view.close')}
icon={X}
side="bottom"
/>
</div>
{selectedNode.description && (
<p className="text-muted-foreground mb-3 text-sm leading-relaxed wrap-break-word">
{selectedNode.description}
</p>
)}
<div className="flex flex-col gap-2">
<SectionHeader
as="h4"
size="xs"
title={t('settings.sources.graphrag.view.linkedChunks')}
/>
{selectedNode.chunks.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.noChunks')}
</p>
) : (
<ul className="flex flex-col gap-2">
{selectedNode.chunks.map((chunk) => (
<li
key={chunk.chunk_id}
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
>
{chunk.text}
</li>
))}
</ul>
)}
</div>
</div>
) : (
<p className="text-muted-foreground py-2 text-sm">
{!selected ? (
<p className="text-muted-foreground pointer-events-none absolute top-3 left-3 text-xs">
{t('settings.sources.graphrag.view.selectNode')}
</p>
)}
</aside>
</div>
) : null}
<GraphCanvasControls
zoom={zoom}
onZoomIn={() => zoomBy(ZOOM_STEP)}
onZoomOut={() => zoomBy(1 / ZOOM_STEP)}
onFit={() => fitView()}
/>
</>
)}
</div>
)}
{isDesktop && panel ? (
<GraphNodePanelDock>{panel}</GraphNodePanelDock>
) : null}
</Card>
{!isDesktop ? (
<Sheet
open={!!selected && active}
onOpenChange={(open) => {
if (!open) onSelect(null);
}}
>
<SheetContent
side="bottom"
handle
title={selected?.name}
// Open on the panel, not with a ring on its first control.
onOpenAutoFocus={(event) => event.preventDefault()}
>
{panel}
</SheetContent>
</Sheet>
) : null}
</div>
);
};
+2 -1
View File
@@ -1,7 +1,7 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { markdownHeadings } from '@/lib/markdown';
import { markdownHeadings, markdownTables } from '@/lib/markdown';
import { Button } from './ui/button';
@@ -18,6 +18,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
remarkPlugins={[remarkGfm]}
components={{
...markdownHeadings,
...markdownTables,
a({ children, href }) {
return (
<Button variant="link" size="inline" asChild>
+17 -12
View File
@@ -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"
+1 -1
View File
@@ -1880,7 +1880,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">
@@ -124,7 +124,7 @@ export function MultiSelectPopover({
if (!item.disabled) onToggle(item.id);
}}
checked={isSelected}
className="cursor-pointer justify-between"
className="justify-between"
aria-selected={isSelected}
>
<div className="mr-3 flex grow items-center gap-3 overflow-hidden">
@@ -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>
+5 -1
View File
@@ -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);
});
});
+41 -62
View File
@@ -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,57 +144,44 @@ 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
// count in the footer.
const renderChunkCards = () => (
<>
{Array.from({ length: count }).map((_, index) => (
<div
<Card
key={`chunk-skel-${index}`}
className="border-border relative flex h-[197px] w-full max-w-[487px] animate-pulse flex-col overflow-hidden rounded-2xl border"
variant="filled"
padding="lg"
className="h-50 w-full justify-between"
>
<div className="w-full">
<div className="border-border bg-muted flex w-full items-center justify-between border-b px-4 py-3">
<div className="bg-muted-foreground/20 h-4 w-20 rounded"></div>
</div>
<div className="flex flex-col gap-3 px-4 pt-4 pb-6">
<div className="bg-muted h-3 w-full rounded"></div>
<div className="bg-muted h-3 w-11/12 rounded"></div>
<div className="bg-muted h-3 w-5/6 rounded"></div>
<div className="bg-muted h-3 w-4/5 rounded"></div>
<div className="bg-muted h-3 w-3/4 rounded"></div>
<div className="bg-muted h-3 w-2/3 rounded"></div>
</div>
<div className="flex flex-col gap-3">
<Skeleton surface="muted" className="h-3 w-full" />
<Skeleton surface="muted" className="h-3 w-11/12" />
<Skeleton surface="muted" className="h-3 w-5/6" />
<Skeleton surface="muted" className="h-3 w-4/5" />
<Skeleton surface="muted" className="h-3 w-2/3" />
</div>
</div>
<Skeleton surface="muted" className="h-3 w-20" />
</Card>
))}
</>
);
@@ -328,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 = () => (
@@ -355,7 +335,6 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
const componentMap = {
fileTable: renderTable,
chatbot: renderChatbot,
dropdown: renderDropdown,
logs: renderLogs,
default: renderDefault,
analysis: renderAnalysis,
@@ -0,0 +1,55 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import SourceMarkdown from './SourceMarkdown';
const render = (content: string, highlight?: string) =>
renderToStaticMarkup(
<SourceMarkdown content={content} highlight={highlight} />,
);
describe('SourceMarkdown', () => {
it('renders headings, lists and tables like a chat answer', () => {
const html = render(
'# Title\n\n- one\n- two\n\n| A | B |\n|---|---|\n| 1 | 2 |',
);
expect(html).toContain('<h1 class="mt-4 mb-2 text-xl font-semibold">');
expect(html).toContain('list-outside list-disc pl-5');
expect(html).toContain('<table');
expect(html).toContain('overflow-x-auto');
});
it('marks every case-insensitive match of the highlight in text, not in code', () => {
const html = render(
'Nordhaven Logistics B.V. is a carrier; nordhaven logistics b.v. again. `Nordhaven Logistics B.V.`',
'Nordhaven Logistics B.V.',
);
expect(html.match(/<mark/g)).toHaveLength(2);
expect(html).toContain('>Nordhaven Logistics B.V.</mark>');
expect(html).toContain('>nordhaven logistics b.v.</mark>');
expect(html).toContain('<code');
});
it('treats the highlight as literal text, not a pattern', () => {
const html = render('Assured+ tier and Assured tier', 'Assured+');
expect(html.match(/<mark/g)).toHaveLength(1);
});
it('renders no marks without a highlight or for a blank one', () => {
expect(render('plain text')).not.toContain('<mark');
expect(render('plain text', ' ')).not.toContain('<mark');
});
it('puts fenced code in a scrolling box, and keeps the pill for inline code', () => {
const html = render(
'Run `npm ci` first.\n\n```sh\nnpm ci\nnpm run build\n```',
);
const host = document.createElement('div');
host.innerHTML = html;
const pre = host.querySelector('pre')!;
expect(pre.className).toContain('overflow-x-auto');
expect(pre.className).toContain('border');
expect(pre.textContent).toContain('npm run build');
expect(host.querySelector('p code')!.className).toContain('bg-muted');
});
});
+128
View File
@@ -0,0 +1,128 @@
import React from 'react';
import ReactMarkdown, {
type Components,
type ExtraProps,
} from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { markdownHeadings, markdownTables } from '@/lib/markdown';
import { Button } from './ui/button';
type TableProps = React.ComponentProps<'table'> & ExtraProps;
// The shared table frame, with the paragraph's bottom margin so the next
// block does not butt against it.
const SharedTable = markdownTables.table as React.ComponentType<TableProps>;
const TableFrame = (props: TableProps) => (
<div className="mb-3">
<SharedTable {...props} />
</div>
);
const components: Components = {
...markdownHeadings,
...markdownTables,
table: TableFrame,
p: ({ children }) => <p className="mb-3">{children}</p>,
ul: ({ children }) => (
<ul className="mb-3 list-outside list-disc pl-5">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-3 list-outside list-decimal pl-5">{children}</ol>
),
a: ({ children, href }) => (
<Button variant="link" size="inline" asChild>
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
</Button>
),
// Fenced and indented code keep their lines and scroll sideways in a
// bordered box; the inline pill below applies to inline code only.
pre: ({ children }) => (
<pre className="border-border mb-3 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-5 [&>code]:bg-transparent [&>code]:p-0">
{children}
</pre>
),
code: ({ children }) => (
<code className="bg-muted rounded-sm px-1 py-0.5 font-mono text-xs">
{children}
</code>
),
mark: ({ children }) => (
<mark className="bg-secondary text-foreground rounded-xs px-0.5">
{children}
</mark>
),
};
type MdNode = {
type: string;
value?: string;
children?: MdNode[];
data?: { hName?: string };
};
/**
* A remark plugin that wraps every case-insensitive, literal match of `term`
* in text (never in code) in a `<mark>`.
*/
function remarkHighlight(term: string) {
const needle = term.toLowerCase();
const split = (value: string): MdNode[] => {
const out: MdNode[] = [];
const lower = value.toLowerCase();
let from = 0;
let at = lower.indexOf(needle);
while (at !== -1) {
if (at > from) out.push({ type: 'text', value: value.slice(from, at) });
const hit = value.slice(at, at + needle.length);
out.push({
type: 'highlight',
data: { hName: 'mark' },
children: [{ type: 'text', value: hit }],
});
from = at + needle.length;
at = lower.indexOf(needle, from);
}
if (from < value.length)
out.push({ type: 'text', value: value.slice(from) });
return out;
};
const walk = (node: MdNode) => {
if (!node.children) return;
node.children = node.children.flatMap((child) => {
if (child.type === 'text' && child.value) return split(child.value);
walk(child);
return [child];
});
};
return () => (tree: MdNode) => walk(tree);
}
/**
* Markdown as a source view renders it: a wiki page, an open chunk, the edit
* drawer's preview. The chat answer's headings and tables, lists outside the
* text, links as `link inline`. `highlight` marks an entity's name (the graph's
* chunk drawer).
*/
export default function SourceMarkdown({
content,
highlight,
}: {
content: string;
highlight?: string;
}) {
const term = highlight?.trim();
return (
<div className="text-foreground text-sm leading-relaxed wrap-break-word">
<ReactMarkdown
remarkPlugins={term ? [remarkGfm, remarkHighlight(term)] : [remarkGfm]}
components={components}
>
{content}
</ReactMarkdown>
</div>
);
}
+407
View File
@@ -0,0 +1,407 @@
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && key === 'settings.sources.wiki.byline'
? `${opts.pages} pages · ${opts.tokens} tokens`
: key,
}),
}));
const auth = vi.hoisted(() => ({ token: null as string | null }));
vi.mock('react-redux', () => ({
useSelector: () => auth.token,
useDispatch: () => vi.fn(),
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false],
useDebouncedValue: (value: unknown) => value,
useLoaderState: (initial: boolean) => useState(initial),
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
useOutsideAlerter: () => undefined,
}));
const PAGES = [
{ path: 'company/meeting-types.md', title: null, token_count: 1000 },
{ path: 'index.md', title: null, token_count: 200 },
{ path: 'company/async_by-default.md', title: null, token_count: 34 },
];
const ok = (body: unknown) => ({
ok: true,
status: 200,
json: async () => body,
});
vi.mock('../api/services/userService', () => ({
default: {
getWikiPages: vi.fn(),
getWikiPage: vi.fn(),
updateWikiPage: vi.fn(),
},
}));
import i18next from 'i18next';
import userService from '../api/services/userService';
import { TooltipProvider } from './ui/tooltip';
import WikiViewer from './WikiViewer';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const service = userService as unknown as {
getWikiPages: ReturnType<typeof vi.fn>;
getWikiPage: ReturnType<typeof vi.fn>;
updateWikiPage: ReturnType<typeof vi.fn>;
};
describe('WikiViewer', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
auth.token = null;
service.getWikiPages.mockReset();
service.getWikiPage.mockReset();
service.updateWikiPage.mockReset();
service.getWikiPages.mockResolvedValue(ok({ pages: PAGES }));
service.getWikiPage.mockImplementation(async (_id: string, path: string) =>
ok({ page: { path, content: '# Hello\n\nBody text', version: 1 } }),
);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = async (canEdit = false) => {
await act(async () => {
root.render(
<TooltipProvider>
<WikiViewer
docId="doc"
sourceName="Handbook"
canEdit={canEdit}
onBackToDocuments={vi.fn()}
/>
</TooltipProvider>,
);
});
};
const buttonByText = (text: string) =>
Array.from(document.body.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === text,
);
const sheet = () =>
document.body.querySelector<HTMLElement>('[data-slot="sheet-content"]');
const editor = () =>
sheet()?.querySelector<HTMLTextAreaElement>('textarea') ?? null;
const typeDraft = async (value: string) => {
const field = editor()!;
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)!.set!;
await act(async () => {
setter.call(field, value);
field.dispatchEvent(new Event('input', { bubbles: true }));
});
};
const openEditor = async () => {
await act(async () => buttonByText('settings.sources.wiki.edit')!.click());
};
const save = async () => {
await act(async () => buttonByText('settings.sources.wiki.save')!.click());
};
// Bugs row 8: the app language's separators, not the browser's.
it('formats the byline counts in the app language', async () => {
const language = i18next.language;
i18next.language = 'de';
try {
await render();
expect(container.textContent).toContain('3 pages · 1.234 tokens');
} finally {
i18next.language = language;
}
});
it('renders the living wiki badge and the page and token counts', async () => {
await render();
const text = container.textContent ?? '';
expect(text).toContain('Handbook');
expect(text).toContain('settings.sources.wiki.badge');
expect(text).toContain(
`3 pages · ${(1234).toLocaleString()} tokens · settings.sources.wiki.explainer`,
);
expect(text).not.toContain('settings.sources.wiki.livingTitle');
});
it('uses the editable explainer when the wiki can be edited', async () => {
await render(true);
expect(container.textContent).toContain(
'settings.sources.wiki.explainerEditable',
);
});
it('groups pages under their folder with readable labels', async () => {
await render();
const nav = container.querySelector(
'nav[aria-label="settings.sources.wiki.pagesTitle"]',
)!;
const text = nav.textContent ?? '';
expect(text).toContain('settings.sources.wiki.home');
expect(text).toContain('company');
expect(text).toContain('Meeting types');
expect(text).toContain('Async by default');
// The home page leads, before the folder group.
expect(text.indexOf('settings.sources.wiki.home')).toBeLessThan(
text.indexOf('company'),
);
// The first page in the list opens, its path in the reader meta.
expect(service.getWikiPage).toHaveBeenCalledWith(
'doc',
'company/meeting-types.md',
null,
);
expect(container.textContent).toContain('Body text');
});
it('hides Edit without canEdit', async () => {
await render(false);
expect(buttonByText('settings.sources.wiki.edit')).toBeUndefined();
expect(sheet()).toBeNull();
});
// The chunk reader's actions: Edit, then the toolbar ⋯ with Copy text. A
// viewer who can't edit still gets the menu.
it('puts a toolbar ⋯ menu after Edit, for editors and viewers alike', async () => {
const menu = () =>
container.querySelector<HTMLButtonElement>(
'button[aria-label="settings.sources.menuAlt"]',
);
await render(true);
const edit = buttonByText('settings.sources.wiki.edit')!;
expect(menu()).not.toBeNull();
expect(
edit.compareDocumentPosition(menu()!) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await render(false);
expect(menu()).not.toBeNull();
});
it('renders the page body as markdown through SourceMarkdown, tables included', async () => {
service.getWikiPage.mockImplementation(async (_id: string, path: string) =>
ok({
page: {
path,
content: '# Hello\n\n| Name | Role |\n| --- | --- |\n| Ana | Lead |',
version: 1,
},
}),
);
await render();
const table = container.querySelector('table');
expect(table).not.toBeNull();
expect(table?.textContent).toContain('Ana');
expect(container.querySelector('h1, h2, h3')?.textContent).toBe('Hello');
expect(container.querySelector('textarea')).toBeNull();
});
it('opens the edit drawer with the page content as the draft', async () => {
await render(true);
expect(sheet()).toBeNull();
await openEditor();
expect(sheet()).not.toBeNull();
expect(sheet()!.textContent).toContain('settings.sources.wiki.editTitle');
expect(sheet()!.textContent).toContain('company/meeting-types.md');
expect(editor()?.value).toBe('# Hello\n\nBody text');
// The path is the drawer's description; the field is named by a label.
expect(editor()?.getAttribute('aria-label')).toBe('modals.chunk.bodyText');
// The panel itself never swaps to an editor.
expect(container.querySelector('textarea')).toBeNull();
});
it('closes the drawer and shows the new content when the save succeeds', async () => {
service.updateWikiPage.mockResolvedValue(
ok({
page: {
path: 'company/meeting-types.md',
content: '# Hello\n\nNew body',
version: 2,
},
}),
);
await render(true);
await openEditor();
await typeDraft('# Hello\n\nNew body');
await save();
expect(service.updateWikiPage).toHaveBeenCalled();
expect(sheet()).toBeNull();
expect(container.textContent).toContain('New body');
expect(container.textContent).not.toContain('Body text');
});
it('a token refresh keeps the open drawer and its draft', async () => {
auth.token = 'old';
await render(true);
await openEditor();
await typeDraft('# Hello\n\nHalf-written');
service.getWikiPages.mockClear();
service.getWikiPage.mockClear();
// SSE 401 recovery dispatches a fresh token for the same user.
auth.token = 'new';
await render(true);
expect(sheet()).not.toBeNull();
expect(editor()?.value).toBe('# Hello\n\nHalf-written');
expect(service.getWikiPage).not.toHaveBeenCalled();
expect(service.getWikiPages).not.toHaveBeenCalled();
// Later requests carry the fresh token.
service.updateWikiPage.mockResolvedValue(ok({ page: null }));
await save();
expect(service.updateWikiPage.mock.calls[0]).toContain('new');
});
it('a saved page updates the byline token total', async () => {
service.updateWikiPage.mockResolvedValue(
ok({
page: {
path: 'company/meeting-types.md',
content: '# Hello\n\nLonger body',
token_count: 1500,
version: 2,
},
}),
);
await render(true);
await openEditor();
await typeDraft('# Hello\n\nLonger body');
await save();
expect(container.textContent).toContain(
`3 pages · ${(1734).toLocaleString()} tokens`,
);
});
it('keeps the drawer open with a warning and the draft when the save conflicts', async () => {
service.updateWikiPage.mockResolvedValue({
ok: false,
status: 409,
json: async () => ({}),
});
await render(true);
await openEditor();
await typeDraft('My edit');
await save();
expect(sheet()).not.toBeNull();
const alert = sheet()!.querySelector('[data-slot="alert"]');
expect(alert?.getAttribute('data-variant')).toBe('warning');
expect(alert?.textContent).toContain('settings.sources.wiki.conflict');
expect(editor()?.value).toBe('My edit');
});
it('shows a destructive Alert in the drawer when the save is forbidden', async () => {
service.updateWikiPage.mockResolvedValue({
ok: false,
status: 403,
json: async () => ({}),
});
await render(true);
await openEditor();
await typeDraft('My edit');
await save();
expect(sheet()).not.toBeNull();
const alert = sheet()!.querySelector('[data-slot="alert"]');
expect(alert?.getAttribute('data-variant')).toBe('destructive');
expect(alert?.textContent).toContain('settings.sources.wiki.forbidden');
expect(editor()?.value).toBe('My edit');
});
// DESIGN.md: the navigator shows only when there is more than one thing to
// open; a one-page wiki reads full width, like a one-file source.
it('hides the navigator for a one-page wiki and opens that page', async () => {
service.getWikiPages.mockResolvedValue(
ok({ pages: [{ path: 'index.md', title: null, token_count: 5 }] }),
);
await render();
expect(
container.querySelector(
'nav[aria-label="settings.sources.wiki.pagesTitle"]',
),
).toBeNull();
expect(service.getWikiPage).toHaveBeenCalledWith('doc', 'index.md', null);
expect(container.textContent).toContain('index.md');
expect(container.textContent).toContain('Body text');
});
it('shows the empty state when the wiki has no pages', async () => {
service.getWikiPages.mockResolvedValue(ok({ pages: [] }));
await render();
expect(container.textContent).toContain('settings.sources.wiki.empty');
expect(
container.querySelector(
'nav[aria-label="settings.sources.wiki.pagesTitle"]',
),
).toBeNull();
});
it('offers Retry when the pages fail to load', async () => {
service.getWikiPages.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({}),
});
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
await render();
expect(container.textContent).toContain('settings.sources.wiki.loadFailed');
await act(async () => buttonByText('retry')!.click());
expect(service.getWikiPages).toHaveBeenCalledTimes(2);
expect(
container.querySelector(
'nav[aria-label="settings.sources.wiki.pagesTitle"]',
),
).not.toBeNull();
spy.mockRestore();
});
// Bug: a failed page switch kept the previous page's "Edited by · vN".
it("drops the previous page's stamp when the next page fails to load", async () => {
service.getWikiPage.mockImplementation(async (_id: string, path: string) =>
path === 'index.md'
? { ok: false, status: 500, json: async () => ({}) }
: ok({ page: { path, content: 'Body text', version: 3 } }),
);
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
await render();
expect(container.textContent).toContain(
'settings.sources.wiki.stamp.editedBy',
);
const home = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
).find((el) => el.textContent?.includes('settings.sources.wiki.home'))!;
await act(async () => home.click());
expect(container.textContent).toContain(
'settings.sources.wiki.pageLoadFailed',
);
expect(container.textContent).not.toContain(
'settings.sources.wiki.stamp.editedBy',
);
expect(container.textContent).not.toContain('Body text');
spy.mockRestore();
});
});
+351 -203
View File
@@ -1,22 +1,37 @@
import { ArrowLeft, BookOpen } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import copy from 'copy-to-clipboard';
import {
BookOpen,
CircleAlert,
Copy,
FileText,
Pencil,
TriangleAlert,
} from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { markdownHeadings } from '@/lib/markdown';
import { cn } from '@/lib/utils';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../api/services/userService';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import { formatRelative } from '../utils/dateTimeUtils';
import { formatCount, formatRelative } from '../utils/dateTimeUtils';
import { decodeJwtPayload } from '../utils/jwtUtils';
import SourceMarkdown from './SourceMarkdown';
import PathHeader from './tree/PathHeader';
import ReaderPanel from './tree/ReaderPanel';
import SourceEditSheet from './tree/SourceEditSheet';
import SourceNavigator from './tree/SourceNavigator';
import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { IconButton } from './ui/icon-button';
import { Textarea } from './ui/textarea';
import SkeletonLoader from './SkeletonLoader';
import { WikiPageNode, provenanceKey, saveWikiPage } from './wikiViewerUtils';
import { ActionMenu } from './ui/dropdown-menu';
import { EmptyState } from './ui/empty-state';
import { Skeleton } from './ui/skeleton';
import {
WikiPageNode,
buildWikiNavigator,
provenanceKey,
saveWikiPage,
} from './wikiViewerUtils';
interface WikiViewerProps {
docId: string;
@@ -27,30 +42,19 @@ interface WikiViewerProps {
headerAction?: React.ReactNode;
}
const markdownComponents = {
...markdownHeadings,
p: ({ children }: { children?: React.ReactNode }) => (
<p className="mb-3">{children}</p>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="mb-3 list-inside list-disc pl-4">{children}</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="mb-3 list-inside list-decimal pl-4">{children}</ol>
),
a: ({ children, href }: { children?: React.ReactNode; href?: string }) => (
<Button variant="link" size="inline" asChild>
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
</Button>
),
code: ({ children }: { children?: React.ReactNode }) => (
<code className="bg-accent rounded-md px-1.5 py-0.5 text-xs">
{children}
</code>
),
};
type EditError = { kind: 'conflict' | 'forbidden' | 'failed' } | null;
/** Bars in the reader body while a page loads. */
function ReaderBars() {
return (
<div className="flex flex-col gap-3" data-testid="wiki-reader-loading">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-4/5" />
</div>
);
}
const WikiViewer: React.FC<WikiViewerProps> = ({
docId,
@@ -60,6 +64,7 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
headerAction,
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const currentUserSub = token
? ((decodeJwtPayload(token)?.sub as string | undefined) ?? null)
@@ -70,70 +75,108 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
const [page, setPage] = useState<WikiPageNode | null>(null);
const [content, setContent] = useState<string>('');
const [loadingPages, setLoadingPages] = useState(true);
const [pagesFailed, setPagesFailed] = useState(false);
const [pagesAttempt, setPagesAttempt] = useState(0);
const [loadingContent, setLoadingContent] = useState(false);
const [contentFailed, setContentFailed] = useState(false);
const [contentAttempt, setContentAttempt] = useState(0);
const [isEditing, setIsEditing] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [draft, setDraft] = useState<string>('');
const [saving, setSaving] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [editError, setEditError] = useState<EditError>(null);
// The loads read the token through a ref: a refresh (setToken during SSE
// 401 recovery) is the same user, so it must not refetch, and above all not
// reset the page and close an open editor with its draft.
const tokenRef = useRef(token);
tokenRef.current = token;
useEffect(() => {
let cancelled = false;
setLoadingPages(true);
setPagesFailed(false);
userService
.getWikiPages(docId, token)
.then((response) => response.json())
.getWikiPages(docId, tokenRef.current)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((data) => {
if (cancelled) return;
const list: WikiPageNode[] = data?.pages ?? [];
setPages(list);
if (list.length > 0) setSelectedPath(list[0].path);
setSelectedPath((prev) =>
prev && list.some((p) => p.path === prev)
? prev
: (list[0]?.path ?? null),
);
})
.catch((error) => {
console.error('Error loading wiki pages:', error);
if (!cancelled) setPagesFailed(true);
})
.catch((error) => console.error('Error loading wiki pages:', error))
.finally(() => {
if (!cancelled) setLoadingPages(false);
});
return () => {
cancelled = true;
};
}, [docId, token]);
}, [docId, pagesAttempt]);
useEffect(() => {
setIsEditing(false);
setEditorOpen(false);
setEditError(null);
if (!selectedPath) {
setPage(null);
setContent('');
return;
}
setContentFailed(false);
// Drop the last page before the next loads, so a failed load never shows
// its stamp (editor, version) or its text under the new path.
setPage(null);
setContent('');
if (!selectedPath) return;
let cancelled = false;
setLoadingContent(true);
userService
.getWikiPage(docId, selectedPath, token)
.then((response) => response.json())
.getWikiPage(docId, selectedPath, tokenRef.current)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((data) => {
if (cancelled) return;
const node: WikiPageNode | null = data?.page ?? null;
setPage(node);
setContent((data?.page?.content as string) ?? '');
})
.catch((error) => console.error('Error loading wiki page:', error))
.catch((error) => {
console.error('Error loading wiki page:', error);
if (!cancelled) setContentFailed(true);
})
.finally(() => {
if (!cancelled) setLoadingContent(false);
});
return () => {
cancelled = true;
};
}, [docId, selectedPath, token]);
}, [docId, selectedPath, contentAttempt]);
const homeLabel = t('settings.sources.wiki.home');
const navigatorNodes = useMemo(
() => buildWikiNavigator(pages, homeLabel),
[pages, homeLabel],
);
const totalTokens = useMemo(
() => pages.reduce((sum, p) => sum + (p.token_count ?? 0), 0),
[pages],
);
const startEditing = () => {
setDraft(content);
setEditError(null);
setIsEditing(true);
setEditorOpen(true);
};
const cancelEditing = () => {
setIsEditing(false);
const closeEditor = () => {
setEditorOpen(false);
setEditError(null);
};
@@ -152,26 +195,38 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
);
switch (outcome.status) {
case 'saved':
if (outcome.page) setPage(outcome.page);
if (outcome.page) {
const saved = outcome.page;
setPage(saved);
// The list feeds the byline's token total and the navigator's
// labels; keep its entry in step (the list carries no content).
setPages((list) =>
list.map((p) =>
p.path === selectedPath
? { ...p, ...saved, content: p.content }
: p,
),
);
}
setContent(draft);
setIsEditing(false);
setEditorOpen(false);
break;
case 'conflict':
if (outcome.page) {
setPage(outcome.page);
setContent(outcome.page.content ?? '');
}
setEditError(t('settings.sources.wiki.conflict'));
setEditError({ kind: 'conflict' });
break;
case 'forbidden':
setEditError(t('settings.sources.wiki.forbidden'));
setEditError({ kind: 'forbidden' });
break;
default:
setEditError(t('settings.sources.wiki.saveFailed'));
setEditError({ kind: 'failed' });
}
} catch (error) {
console.error('Error saving wiki page:', error);
setEditError(t('settings.sources.wiki.saveFailed'));
setEditError({ kind: 'failed' });
} finally {
setSaving(false);
}
@@ -180,7 +235,7 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
const provenanceLabel = (via?: string | null, by?: string | null): string =>
t(`settings.sources.wiki.stamp.${provenanceKey(via, by, currentUserSub)}`);
const renderStamp = () => {
const stamp = (): string | null => {
if (!page) return null;
const who = provenanceLabel(page.updated_via, page.updated_by);
const when = formatRelative(page.updated_at);
@@ -191,150 +246,243 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
t('settings.sources.wiki.stamp.version', { version: page.version }),
);
}
return parts.join(' · ');
};
const explainer = canEdit
? t('settings.sources.wiki.explainerEditable')
: t('settings.sources.wiki.explainer');
const byline = loadingPages
? explainer
: `${t('settings.sources.wiki.byline', {
count: pages.length,
pages: formatCount(pages.length),
tokens: formatCount(totalTokens),
})} · ${explainer}`;
const renderEditError = () => {
if (!editError) return null;
if (editError.kind === 'conflict') {
return (
<Alert variant="warning">
<TriangleAlert />
<AlertDescription>
{t('settings.sources.wiki.conflict')}
</AlertDescription>
</Alert>
);
}
return (
<p className="text-muted-foreground mb-3 text-xs">{parts.join(' · ')}</p>
<Alert variant="destructive">
<CircleAlert />
<AlertDescription>
{editError.kind === 'forbidden'
? t('settings.sources.wiki.forbidden')
: t('settings.sources.wiki.saveFailed')}
</AlertDescription>
</Alert>
);
};
// The chunk reader's actions: Edit (editors only), then the toolbar ⋯.
const readerActions =
loadingContent || contentFailed ? null : (
<>
{canEdit ? (
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={startEditing}
>
<Pencil />
{t('settings.sources.wiki.edit')}
</Button>
) : null}
<ActionMenu
size="toolbar"
triggerLabel={t('settings.sources.menuAlt')}
options={[
{
icon: Copy,
label: t('settings.sources.copyText'),
onClick: () => {
copy(content);
dispatch(
showActionToast({
variant: 'success',
message: t('conversation.copied'),
}),
);
},
},
]}
/>
</>
);
const readerMeta = selectedPath ? (
<>
<span className="font-mono wrap-anywhere">{selectedPath}</span>
{!loadingContent && !contentFailed && stamp() ? (
<span>{stamp()}</span>
) : null}
</>
) : null;
const readerBody = () => {
if (loadingContent) return <ReaderBars />;
if (contentFailed) {
return (
<EmptyState
size="xs"
tone="destructive"
illustration="none"
title={t('settings.sources.wiki.pageLoadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => setContentAttempt((n) => n + 1)}
>
{t('retry')}
</Button>
}
/>
);
}
return <SourceMarkdown content={content} />;
};
const renderBody = () => {
if (loadingPages) {
return (
<div
className="flex flex-col gap-4 lg:flex-row lg:gap-6"
data-testid="wiki-loading"
>
<div className="flex w-full shrink-0 flex-col gap-2 lg:w-64">
<Skeleton className="h-9.5 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-5/6" />
<Skeleton className="h-8 w-4/6" />
<Skeleton className="h-8 w-5/6" />
</div>
<ReaderPanel
className="min-w-0 flex-1"
meta={<Skeleton className="h-3 w-40" />}
>
<ReaderBars />
</ReaderPanel>
</div>
);
}
if (pagesFailed) {
return (
<EmptyState
tone="destructive"
illustration="none"
title={t('settings.sources.wiki.loadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => setPagesAttempt((n) => n + 1)}
>
{t('retry')}
</Button>
}
/>
);
}
if (pages.length === 0) {
return (
<EmptyState
size="xs"
illustration="none"
title={t('settings.sources.wiki.empty')}
/>
);
}
const reader = selectedPath ? (
<ReaderPanel meta={readerMeta} actions={readerActions}>
{readerBody()}
</ReaderPanel>
) : (
<EmptyState
size="xs"
illustration="none"
title={t('settings.sources.wiki.selectPage')}
/>
);
// One page: nothing to choose between, so no navigator; the reader takes
// the full width, as TreeBrowser does for a one-file source.
if (pages.length === 1) return reader;
return (
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
<SourceNavigator
nodes={navigatorNodes}
selectedId={selectedPath}
onSelect={(node) => {
if (node.kind === 'leaf') setSelectedPath(node.id);
}}
filterLabel={t('settings.sources.wiki.filterPages')}
emptyLabel={t('settings.sources.noResults')}
title={t('settings.sources.wiki.pagesTitle')}
folderMode="groups"
leafIcon={FileText}
/>
<div className="min-w-0 flex-1">{reader}</div>
</div>
);
};
return (
<div className="flex flex-col">
<div className="mb-4 flex items-center">
<IconButton
variant="outline"
size="icon-xs"
shape="pill"
className="mr-3"
onClick={onBackToDocuments}
label={t('settings.sources.backToAll')}
icon={ArrowLeft}
side="bottom"
<div className="flex flex-col gap-4">
<PathHeader
root={{
label: t('settings.sources.label'),
onSelect: onBackToDocuments,
}}
segments={[{ label: sourceName }]}
badge={
<Badge variant="neutral">
<BookOpen />
{t('settings.sources.wiki.badge')}
</Badge>
}
byline={byline}
actions={headerAction}
/>
{renderBody()}
{canEdit && selectedPath ? (
<SourceEditSheet
open={editorOpen}
onClose={closeEditor}
title={t('settings.sources.wiki.editTitle')}
description={
page?.version != null
? `${selectedPath} · ${t('settings.sources.wiki.stamp.version', {
version: page.version,
})}`
: selectedPath
}
value={draft}
onChange={setDraft}
dirty={draft !== content}
onSave={handleSave}
saving={saving}
saveLabel={t('settings.sources.wiki.save')}
alert={renderEditError()}
fieldLabel={t('modals.chunk.bodyText')}
placeholder={t('settings.sources.wiki.editPlaceholder')}
/>
<span className="text-primary font-semibold wrap-break-word">
{sourceName}
</span>
{headerAction ? <div className="ml-auto">{headerAction}</div> : null}
</div>
<div className="bg-muted text-muted-foreground mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
<BookOpen className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>
<span className="text-foreground font-medium">
{t('settings.sources.wiki.livingTitle')}
</span>{' '}
{t('settings.sources.wiki.livingExplainer')}
{canEdit && (
<> {t('settings.sources.wiki.livingExplainerEditable')}</>
)}
</p>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<div className="border-border md:w-64 md:shrink-0 md:border-r md:pr-4">
{loadingPages ? (
<SkeletonLoader count={3} />
) : pages.length === 0 ? (
<p className="text-muted-foreground py-2 text-sm">
{t('settings.sources.wiki.empty')}
</p>
) : (
<ul className="flex flex-col gap-1">
{pages.map((p) => (
<li key={p.path}>
<button
type="button"
onClick={() => setSelectedPath(p.path)}
className={cn(
'w-full truncate rounded-lg px-3 py-2 text-left text-sm transition-colors',
selectedPath === p.path
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent',
)}
title={p.path}
>
{p.title || p.path}
</button>
</li>
))}
</ul>
)}
</div>
<div className="min-w-0 flex-1">
{loadingContent ? (
<SkeletonLoader count={4} />
) : selectedPath ? (
<div className="flex flex-col">
<div className="mb-2 flex items-center justify-between gap-2">
{renderStamp()}
{canEdit && !isEditing && (
<Button
type="button"
variant="outline"
size="sm"
onClick={startEditing}
>
{t('settings.sources.wiki.edit')}
</Button>
)}
</div>
{editError && (
<p
role="alert"
className="border-border text-muted-foreground mb-3 rounded-md border px-3 py-2 text-xs"
>
{editError}
</p>
)}
{isEditing ? (
<div className="flex flex-col gap-3">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t('settings.sources.wiki.editPlaceholder')}
variant="filled"
className="min-h-[320px] font-mono"
aria-label={selectedPath}
/>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={handleSave}
loading={saving}
>
{t('settings.sources.wiki.save')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelEditing}
disabled={saving}
>
{t('settings.sources.wiki.cancel')}
</Button>
</div>
</div>
) : (
<article className="text-foreground max-w-none text-sm leading-relaxed wrap-break-word">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</article>
)}
</div>
) : (
<p className="text-muted-foreground py-2 text-sm">
{t('settings.sources.wiki.selectPage')}
</p>
)}
</div>
</div>
) : null}
</div>
);
};
+67 -8
View File
@@ -1,20 +1,36 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { formatChunkTokens, UNKNOWN_TOKEN_COUNT } from './chunkUtils';
import i18next from 'i18next';
import {
abbreviateCount,
chunkPreviewText,
formatChunkTokens,
UNKNOWN_TOKEN_COUNT,
} from './chunkUtils';
type ChunkMetadata = Parameters<typeof formatChunkTokens>[0];
describe('formatChunkTokens', () => {
const language = i18next.language;
afterEach(() => {
i18next.language = language;
});
it('formats a numeric count with separators', () => {
expect(formatChunkTokens({ token_count: 1234 })).toBe(
(1234).toLocaleString(),
);
i18next.language = 'en';
expect(formatChunkTokens({ token_count: 1234 })).toBe('1,234');
});
it('formats a count a store handed back as a string', () => {
expect(formatChunkTokens({ token_count: '1234' })).toBe(
(1234).toLocaleString(),
);
i18next.language = 'en';
expect(formatChunkTokens({ token_count: '1234' })).toBe('1,234');
});
// Bugs row 8: the app language's separators, not the browser's.
it('groups in the app language', () => {
i18next.language = 'de';
expect(formatChunkTokens({ token_count: 11420 })).toBe('11.420');
});
it('falls back to a dash when the count is unusable', () => {
@@ -29,3 +45,46 @@ describe('formatChunkTokens', () => {
);
});
});
describe('chunkPreviewText', () => {
it('strips markdown heading markers and folds line breaks', () => {
expect(
chunkPreviewText('##### Taxation and\n\n##### Customs Union\n# 2026'),
).toBe('Taxation and · Customs Union · 2026');
});
it('collapses dot leaders and runs of whitespace', () => {
expect(chunkPreviewText('List of Tables......................12')).toBe(
'List of Tables … 12',
);
expect(chunkPreviewText('a b\t\tc')).toBe('a b c');
});
it('keeps ordinary text, an ellipsis and a hash inside a line', () => {
expect(chunkPreviewText('Wait... issue #12 is open.')).toBe(
'Wait... issue #12 is open.',
);
expect(chunkPreviewText('')).toBe('');
});
});
describe('abbreviateCount', () => {
it('shortens where the language has a short form', () => {
expect(abbreviateCount(1234, 'en')).toBe('1.2K');
expect(abbreviateCount(1500000, 'en')).toBe('1.5M');
expect(abbreviateCount(1234, 'ru')).toBe('1,2\u00a0тыс.');
expect(abbreviateCount(1500000, 'jp')).toBe('150万');
});
// de and ja have no compact thousands: the digits still group.
it('groups the digits where it has none', () => {
expect(abbreviateCount(1234, 'de')).toBe('1.234');
expect(abbreviateCount(1234, 'jp')).toBe('1,234');
expect(abbreviateCount(1500000, 'de')).toBe('1,5\u00a0Mio.');
});
it('leaves counts under a thousand as they are', () => {
expect(abbreviateCount(999, 'en')).toBe('999');
expect(abbreviateCount(0, 'zhTW')).toBe('0');
});
});
+44 -2
View File
@@ -3,7 +3,10 @@
* the formatting rules are unit-testable in isolation.
*/
import i18next from 'i18next';
import { ChunkType } from '../settings/types';
import { formatCount, intlLocale } from '../utils/dateTimeUtils';
/** What a chunk card shows when the token count is genuinely unknown. */
export const UNKNOWN_TOKEN_COUNT = '-';
@@ -14,7 +17,7 @@ export const UNKNOWN_TOKEN_COUNT = '-';
* The backend fills the count in for chunks that were indexed without one, but
* stores round-trip metadata types differently: pgvector keeps JSON numbers
* while other backends hand the same value back as a string. Both are counts,
* so both get thousands separators; only a missing or unusable value falls
* so both get the UI language's thousands separators; only a missing or unusable value falls
* back to a dash.
*/
export function formatChunkTokens(metadata: ChunkType['metadata']): string {
@@ -23,5 +26,44 @@ export function formatChunkTokens(metadata: ChunkType['metadata']): string {
if (typeof count !== 'number' || !Number.isFinite(count) || count <= 0) {
return UNKNOWN_TOKEN_COUNT;
}
return count.toLocaleString();
return formatCount(count);
}
/**
* The chunk count beside the search and in the byline: short where the
* language has a short form ("1.2K", "1,2 тыс.", "150万"), grouped digits where
* it has none (de and ja below 10,000: "1.234").
*
* @param total The chunk count.
* @param language An app language code; defaults to the current one.
* @returns The formatted count.
*/
export function abbreviateCount(
total: number,
language: string = i18next.language,
): string {
return new Intl.NumberFormat(intlLocale(language), {
notation: 'compact',
useGrouping: 'always',
}).format(total);
}
/**
* The text a chunk tile previews: the stored text with markdown heading
* markers stripped, table-of-contents dot leaders collapsed to "…", runs of
* whitespace folded and line breaks joined with " · ". Display only; the
* stored text and the editor keep the original.
*/
export function chunkPreviewText(text: string): string {
return (text ?? '')
.split(/\r?\n/)
.map((line) =>
line
.replace(/^\s*#+\s*/, '')
.replace(/\.{4,}/g, ' … ')
.replace(/[ \t]+/g, ' ')
.trim(),
)
.filter(Boolean)
.join(' · ');
}
@@ -0,0 +1,58 @@
import { Maximize, Minus, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { IconButton } from '../ui/icon-button';
interface GraphCanvasControlsProps {
/** The current zoom factor (1 = 100%). */
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onFit: () => void;
}
/**
* The graph canvas's control strip, bottom left: zoom out, the zoom level,
* zoom in, then fit view. The workflow builder's `CanvasControls` recipe
* without React Flow.
*/
export default function GraphCanvasControls({
zoom,
onZoomIn,
onZoomOut,
onFit,
}: GraphCanvasControlsProps) {
const { t } = useTranslation();
return (
<div className="bg-card border-border absolute bottom-3 left-3 flex items-center gap-0.5 rounded-full border p-1">
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onZoomOut}
label={t('agents.workflow.zoomOut')}
icon={Minus}
/>
<span className="text-foreground w-11 text-center text-xs tabular-nums">
{Math.round(zoom * 100)}%
</span>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onZoomIn}
label={t('agents.workflow.zoomIn')}
icon={Plus}
/>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onFit}
label={t('agents.workflow.fitView')}
icon={Maximize}
/>
</div>
);
}
@@ -0,0 +1,214 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const dispatch = vi.fn();
vi.mock('react-redux', () => ({
useSelector: () => 'tok',
useDispatch: () => dispatch,
}));
vi.mock('../../api/services/userService', () => ({
default: { updateChunk: vi.fn() },
}));
import userService from '../../api/services/userService';
import type { GraphNodeChunk } from '../graphViewUtils';
import GraphChunkSheet from './GraphChunkSheet';
const updateChunk = (
userService as unknown as { updateChunk: ReturnType<typeof vi.fn> }
).updateChunk;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('GraphChunkSheet', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
updateChunk.mockReset();
updateChunk.mockResolvedValue({ ok: true, status: 200 });
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = '';
dispatch.mockReset();
});
const button = (text: string) =>
[...document.body.querySelectorAll('button')].find(
(b) => b.textContent === text,
) as HTMLButtonElement | undefined;
const saveEdit = async (chunk: GraphNodeChunk, value: string) => {
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={chunk}
highlight=""
onClose={vi.fn()}
/>,
);
});
await act(async () => button('modals.chunk.edit')!.click());
const field = document.body.querySelector('textarea')!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)!.set!.call(field, value);
field.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => button('modals.chunk.save')!.click());
};
it('saving an untitled chunk sends no title', async () => {
await saveEdit(
{ chunk_id: 'c1', text: 'Old', metadata: { source: 'a.md' } },
'New',
);
expect(updateChunk).toHaveBeenCalledTimes(1);
const body = updateChunk.mock.calls[0][0];
expect(body).toEqual({ id: 'doc', chunk_id: 'c1', text: 'New' });
expect(body.metadata?.title).toBeUndefined();
});
it('saving a titled chunk keeps its title', async () => {
await saveEdit(
{ chunk_id: 'c1', text: 'Old', metadata: { title: 'Brief.md' } },
'New',
);
expect(updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: 'New',
metadata: { title: 'Brief.md' },
},
'tok',
);
});
it('the edit drawer edits the title like Chunks', async () => {
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={{ chunk_id: 'c1', text: 'Body', metadata: { title: 'Old' } }}
highlight=""
onClose={vi.fn()}
/>,
);
});
await act(async () => button('modals.chunk.edit')!.click());
const input = document.body.querySelector(
'input[type="text"], input:not([type])',
) as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.value).toBe('Old');
expect(document.body.textContent).toContain('modals.chunk.title');
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!.call(input, ' New title ');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
// A title-only edit is a change: Save is enabled and sends it trimmed.
await act(async () => button('modals.chunk.save')!.click());
expect(updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: 'Body',
metadata: { title: 'New title' },
},
'tok',
);
});
it('Open in Files opens the tree path, not a web page URL', async () => {
const onOpenInFiles = vi.fn();
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={{
chunk_id: 'c1',
text: 'Body',
metadata: {
source: 'https://docs.example.com/guides/setup',
file_path: 'guides/setup.md',
title: 'Setup',
},
}}
highlight=""
onClose={vi.fn()}
onOpenInFiles={onOpenInFiles}
/>,
);
});
await act(async () =>
button('settings.sources.graphrag.view.openInFiles')!.click(),
);
expect(onOpenInFiles).toHaveBeenCalledWith('guides/setup.md');
});
// Leaving the edit drawer (Cancel, Discard, Esc, X) returns to the read
// drawer on the same chunk; only a successful save closes both.
it('Cancel in the edit drawer returns to the read drawer', async () => {
const onClose = vi.fn();
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={{ chunk_id: 'c1', text: 'Old body', metadata: {} }}
highlight=""
onClose={onClose}
/>,
);
});
await act(async () => button('modals.chunk.edit')!.click());
expect(document.body.querySelector('textarea')).not.toBeNull();
await act(async () => button('settings.sources.editor.cancel')!.click());
expect(onClose).not.toHaveBeenCalled();
expect(document.body.querySelector('textarea')).toBeNull();
expect(button('modals.chunk.edit')).toBeDefined();
expect(document.body.textContent).toContain('Old body');
});
it('a successful save closes both drawers', async () => {
const onClose = vi.fn();
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={{ chunk_id: 'c1', text: 'Old', metadata: {} }}
highlight=""
onClose={onClose}
/>,
);
});
await act(async () => button('modals.chunk.edit')!.click());
const field = document.body.querySelector('textarea')!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)!.set!.call(field, 'New');
field.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => button('modals.chunk.save')!.click());
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,237 @@
import { Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { showActionToast } from '../../notifications/actionToastSlice';
import { selectToken } from '../../preferences/preferenceSlice';
import { UNKNOWN_TOKEN_COUNT, formatChunkTokens } from '../chunkUtils';
import SourceMarkdown from '../SourceMarkdown';
import SourceEditSheet from '../tree/SourceEditSheet';
import { Alert, AlertDescription } from '../ui/alert';
import { Button } from '../ui/button';
import { FormField } from '../ui/form-field';
import { Input } from '../ui/input';
import { Separator } from '../ui/separator';
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '../ui/sheet';
import type { GraphNodeChunk } from '../graphViewUtils';
import { chunkFileName, chunkFilePath, chunkHeading } from './graphCanvasUtils';
interface GraphChunkSheetProps {
docId: string;
/** The chunk to show; null closes both drawers. */
chunk: GraphNodeChunk | null;
/** The entity's name, marked in the rendered chunk. */
highlight: string;
/** Called once the drawers close (the node stays selected). */
onClose: () => void;
/** Show the chunk's file on the Files tab; omitted hides the button. */
onOpenInFiles?: (path: string) => void;
/** Called after a saved edit, to refetch the node detail. */
onSaved?: () => void;
}
/**
* A source chunk of a graph entity, opened from the node panel: a read drawer
* with the chunk rendered (the entity's name marked), "Open in Files" and
* Edit; Edit swaps it for the shared edit drawer. Built like
* WorkflowDetailsSheet: header, one scrolling body, then the actions.
*/
export default function GraphChunkSheet({
docId,
chunk,
highlight,
onClose,
onOpenInFiles,
onSaved,
}: GraphChunkSheetProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [draftTitle, setDraftTitle] = useState('');
const [saving, setSaving] = useState(false);
const [saveFailed, setSaveFailed] = useState(false);
// Every newly opened chunk starts in the read drawer.
useEffect(() => {
setEditing(false);
setSaveFailed(false);
}, [chunk?.chunk_id]);
if (!chunk) return null;
const file = chunkFileName(chunk.metadata);
// The tree path "Open in Files" opens (a web page's is not its URL).
const path = chunkFilePath(chunk.metadata);
const tokens = formatChunkTokens(
(chunk.metadata ?? {}) as Parameters<typeof formatChunkTokens>[0],
);
const hasTokens = tokens !== UNKNOWN_TOKEN_COUNT;
const heading = chunkHeading(chunk.text);
const title = heading || file || t('settings.sources.graphrag.view.chunk');
const meta =
file && hasTokens
? t('settings.sources.graphrag.view.chunkMeta', {
file,
tokens,
interpolation: { escapeValue: false },
})
: hasTokens
? t('settings.sources.graphrag.view.chunkTokens', { tokens })
: file;
// When the file name is already the title, the read header shows only the
// token count; the edit drawer keeps the full line.
const readMeta =
heading || !file
? meta
: hasTokens
? t('settings.sources.graphrag.view.chunkTokens', { tokens })
: '';
const close = () => {
setEditing(false);
setSaveFailed(false);
onClose();
};
// Leaving the edit drawer without saving returns to the read drawer.
const leaveEdit = () => {
setEditing(false);
setSaveFailed(false);
};
const savedTitle =
typeof chunk.metadata?.title === 'string' ? chunk.metadata.title : '';
const startEdit = () => {
setDraft(chunk.text ?? '');
setDraftTitle(savedTitle);
setSaveFailed(false);
setEditing(true);
};
const save = async () => {
setSaving(true);
setSaveFailed(false);
// The backend merges metadata: send a title only when the chunk had one
// (clearing it sends '') or one was entered, never '' onto an untitled
// one. As Chunks does.
const sendTitle = !!savedTitle || !!draftTitle.trim();
try {
const response = await userService.updateChunk(
{
id: docId,
chunk_id: chunk.chunk_id,
text: draft,
...(sendTitle ? { metadata: { title: draftTitle.trim() } } : {}),
},
token,
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
close();
onSaved?.();
dispatch(
showActionToast({
variant: 'success',
message: t('settings.sources.graphrag.view.chunkSaved'),
}),
);
} catch (error) {
console.error('Error saving chunk:', error);
setSaveFailed(true);
} finally {
setSaving(false);
}
};
return (
<>
<Sheet open={!editing} onOpenChange={(open) => !open && close()}>
<SheetContent
side="right"
size="detail"
className="p-0"
closeLabel={t('settings.sources.editor.close')}
>
<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 flex-col gap-1 px-6 pt-6 pr-12 pb-4">
<SheetTitle className="wrap-break-word">{title}</SheetTitle>
{readMeta ? (
<SheetDescription className="wrap-anywhere">
{readMeta}
</SheetDescription>
) : null}
</div>
<Separator />
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
<SourceMarkdown
content={chunk.text ?? ''}
highlight={highlight}
/>
</div>
<Separator />
<div className="flex justify-end gap-3 px-6 py-4">
{onOpenInFiles && path ? (
<Button
type="button"
variant="outline"
size="lg"
shape="pill"
onClick={() => {
close();
onOpenInFiles(path);
}}
>
{t('settings.sources.graphrag.view.openInFiles')}
</Button>
) : null}
<Button type="button" size="lg" shape="pill" onClick={startEdit}>
<Pencil />
{t('modals.chunk.edit')}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
<SourceEditSheet
open={editing}
onClose={leaveEdit}
title={t('settings.sources.graphrag.view.editChunk')}
description={meta || undefined}
value={draft}
onChange={setDraft}
dirty={draft !== (chunk.text ?? '') || draftTitle.trim() !== savedTitle}
fields={
<FormField
label={t('modals.chunk.title')}
hint={t('settings.sources.chunkTitleHint')}
labelSurface="background"
>
<Input
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
disabled={saving}
/>
</FormField>
}
onSave={save}
saving={saving}
saveLabel={t('modals.chunk.save')}
alert={
saveFailed ? (
<Alert variant="destructive">
<AlertDescription>
{t('settings.sources.chunkErrors.save')}
</AlertDescription>
</Alert>
) : null
}
fieldLabel={t('modals.chunk.bodyText')}
/>
</>
);
}
@@ -0,0 +1,350 @@
import { Waypoints } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { useDebouncedValue, useMediaQuery } from '../../hooks';
import { selectToken } from '../../preferences/preferenceSlice';
import { formatCount } from '../../utils/dateTimeUtils';
import SearchInput from '../SearchInput';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { EmptyState } from '../ui/empty-state';
import { Pagination } from '../ui/pagination';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Sheet, SheetContent } from '../ui/sheet';
import { Skeleton } from '../ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../ui/table';
import type {
FoldedGraphTypes,
ForceGraphData,
GraphNodeSummary,
GraphTypeFacet,
} from '../graphViewUtils';
import GraphNodePanel, {
GraphNodePanelDock,
type GraphNodeRef,
} from './GraphNodePanel';
import { GraphTypeBadge } from './GraphTypeDot';
import { useGraphNodeDetail } from './useGraphNodeDetail';
const ENTITIES_PER_PAGE = 25;
// Radix Select can't hold "" as a value, and "" is the untyped key.
const ALL_TYPES = '__all';
const UNTYPED = '__untyped';
type ListStatus = 'loading' | 'error' | 'ready';
/**
* The Entities tab: every node of the source, searchable and filterable by
* type, busiest first, paged. The frame is the Graph tab's: one `subtle`
* Card at `h-[70svh]` with the table scrolling inside and a row's node panel
* docked at its right edge (a bottom sheet on a phone), whose "Show in graph"
* jumps to the canvas.
*/
export default function GraphEntities({
docId,
fold,
onShowInGraph,
overview,
onOpenInFiles,
}: {
docId: string;
fold: FoldedGraphTypes;
onShowInGraph: (node: GraphNodeRef) => void;
/** The loaded overview, for the node panel's relationships fallback. */
overview?: ForceGraphData;
/** Show a chunk's file on the Files tab. */
onOpenInFiles?: (path: string) => void;
}) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const { isDesktop } = useMediaQuery();
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query.trim(), 300);
const [typeFilter, setTypeFilter] = useState(ALL_TYPES);
const [page, setPage] = useState(1);
const [nodes, setNodes] = useState<GraphNodeSummary[]>([]);
const [total, setTotal] = useState(0);
const [types, setTypes] = useState<GraphTypeFacet[]>([]);
const [status, setStatus] = useState<ListStatus>('loading');
const [attempt, setAttempt] = useState(0);
const [selected, setSelected] = useState<GraphNodeRef | null>(null);
const nodeDetail = useGraphNodeDetail(docId, selected?.id ?? null);
// A new search or type filter starts on page 1; that page change then
// fetches, so the old page is never requested with the new filter.
const filterKey = `${debouncedQuery}\u0000${typeFilter}`;
const fetchedFilterRef = useRef(filterKey);
useEffect(() => {
if (fetchedFilterRef.current !== filterKey) {
fetchedFilterRef.current = filterKey;
if (page !== 1) {
setPage(1);
return;
}
}
let cancelled = false;
setStatus('loading');
userService
.getSourceGraphNodes(
docId,
{
q: debouncedQuery || undefined,
type:
typeFilter === ALL_TYPES
? undefined
: typeFilter === UNTYPED
? ''
: typeFilter,
page,
perPage: ENTITIES_PER_PAGE,
},
token,
)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((body) => {
if (cancelled) return;
setNodes(body?.nodes ?? []);
setTotal(body?.total ?? 0);
setTypes(body?.types ?? []);
setStatus('ready');
})
.catch((error) => {
if (cancelled) return;
console.error('Error loading graph entities:', error);
setStatus('error');
});
return () => {
cancelled = true;
};
}, [docId, filterKey, page, token, attempt]);
const pageCount = Math.max(1, Math.ceil(total / ENTITIES_PER_PAGE));
const untypedLabel = t('settings.sources.graphrag.view.untyped');
const filtered = debouncedQuery !== '' || typeFilter !== ALL_TYPES;
const panel = selected ? (
<GraphNodePanel
key={selected.id}
docId={docId}
node={selected}
detail={nodeDetail.detail}
status={nodeDetail.status}
onRetry={nodeDetail.retry}
fold={fold}
onClose={() => setSelected(null)}
showClose={isDesktop}
onSelectNode={setSelected}
overview={overview}
onOpenInFiles={onOpenInFiles}
onChunkSaved={nodeDetail.reload}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => onShowInGraph(selected)}
>
<Waypoints />
{t('settings.sources.graphrag.view.showInGraph')}
</Button>
}
/>
) : null;
const body =
status === 'error' ? (
<EmptyState
size="sm"
tone="destructive"
illustration="none"
title={t('settings.sources.graphrag.view.entitiesLoadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => setAttempt((n) => n + 1)}
>
{t('retry')}
</Button>
}
/>
) : status === 'ready' && nodes.length === 0 ? (
<EmptyState
size="xs"
illustration="none"
title={
filtered
? t('settings.sources.graphrag.view.noEntities')
: t('settings.sources.graphrag.view.empty')
}
/>
) : (
<div className="scrollbar-overlay min-h-0 flex-1 overflow-auto">
<Table minWidth="min-w-0">
<TableHead>
<TableRow>
<TableHeader>
{t('settings.sources.graphrag.view.entityName')}
</TableHeader>
<TableHeader>{t('settings.sources.type')}</TableHeader>
<TableHeader align="right">
{t('settings.sources.graphrag.view.connections')}
</TableHeader>
<TableHeader align="right">
{t('settings.sources.chunks')}
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{status === 'loading'
? Array.from({ length: 6 }, (_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-3 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-3 w-20" />
</TableCell>
<TableCell align="right">
<Skeleton className="ml-auto h-3 w-8" />
</TableCell>
<TableCell align="right">
<Skeleton className="ml-auto h-3 w-8" />
</TableCell>
</TableRow>
))
: nodes.map((node) => (
<TableRow
key={node.id}
selected={selected?.id === node.id}
onClick={() =>
setSelected({
id: node.id,
name: node.name,
type: node.type,
})
}
>
<TableCell className="font-medium wrap-break-word">
{node.name}
</TableCell>
<TableCell>
<GraphTypeBadge fold={fold} type={node.type} />
</TableCell>
<TableCell align="right" className="tabular-nums">
{formatCount(node.degree)}
</TableCell>
<TableCell align="right" className="tabular-nums">
{formatCount(node.doc_freq ?? 0)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
// Kept through a page load (the previous page's rows stay until the next
// arrive), so Prev / Next do not shift the layout.
const showPager = status !== 'error' && nodes.length > 0;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<div className="w-full sm:w-72">
<SearchInput
label={t('settings.sources.graphrag.view.findEntity')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
size="field"
shape="pill"
className="w-full sm:w-56"
aria-label={t('settings.sources.type')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_TYPES}>
{t('settings.sources.graphrag.view.allTypes')}
</SelectItem>
{types.map((facet) => (
<SelectItem
key={facet.key || UNTYPED}
value={facet.key || UNTYPED}
>
{facet.key ? (facet.label ?? facet.key) : untypedLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card
variant="subtle"
padding="none"
className="h-[70svh] flex-row gap-0 overflow-hidden"
>
<div className="flex min-w-0 flex-1 flex-col justify-center">
{body}
</div>
{isDesktop && panel ? (
<GraphNodePanelDock>{panel}</GraphNodePanelDock>
) : null}
</Card>
{showPager ? (
<Pagination
page={page}
pageCount={pageCount}
onPageChange={setPage}
summary={t('settings.sources.graphrag.view.entityCount', {
count: total,
formatted: formatCount(total),
})}
/>
) : null}
{!isDesktop ? (
<Sheet
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<SheetContent
side="bottom"
handle
title={selected?.name}
// Open on the panel, not with a ring on its first control.
onOpenAutoFocus={(event) => event.preventDefault()}
>
{panel}
</SheetContent>
</Sheet>
) : null}
</div>
);
}
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more