diff --git a/AGENTS.md b/AGENTS.md
index 143cda22..189b9fbf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -221,7 +221,7 @@ DocsGPT historically mixed three icon sources: `lucide-react`, inline SVG compon
plus, etc.). It tokenizes via `currentColor`, ships tree-shaken icons, and the codebase
already imports it in 30+ places. ``, ``, etc.
2. **Use `assets/.svg?react`** when you need a brand-specific or domain illustration
- that doesn't exist in lucide (the app logo, robot fallback, retry arrow, send arrow,
+ that doesn't exist in lucide (the app logo, robot fallback, send arrow,
etc.). Always set `fill="currentColor"` / `stroke="currentColor"` in the SVG file so
consumers can theme via Tailwind text classes.
3. **Avoid `` for new icons.** It blocks `currentColor` theming and
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 13f02e74..43b6e7bb 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -458,14 +458,18 @@ hand-positioned floating label, or a `div.flex-col` + `Label` + `
` stack.
### SettingRow (`ui/setting-row.tsx`)
A setting with a control on the right (a Switch, a short Input) is
-`{control}`,
+`{control}`,
grouped in ``, which splits rows with `divide-border/50` and pads
each 12px (none at the group's ends). The title is a `Label` for the control
(`htmlFor` = the control's id), so every switch has a name and clicking the
title toggles it; `as="h2" | "h3"` keeps a heading tag, and the control then
needs its own `aria-label`. `alignStart` top-aligns the control for wrapping
-descriptions. `after` holds a field that belongs to the row, 8px under it
-(the agent form's limit Inputs). Inline "switch + label" pairs (a filter
+descriptions. `stack` puts a control too wide for a phone row (a 224px
+picker) under the title below `sm` at full width; give the picker
+`w-full sm:w-56`. `after` holds a field that belongs to the row, 8px under it
+(the agent form's limit Inputs). Settings → General is the page-level example:
+`PageToolbar` intro and rule, then `SectionHeader`ed groups of SettingRows in a
+`max-w-3xl` column. Inline "switch + label" pairs (a filter
toggle) are not SettingRows.
### Checkbox (`ui/checkbox.tsx`)
@@ -662,6 +666,17 @@ title with its description. Every variant is
is announced politely; pass `role` only to override that. Replaces the hand-rolled
`rounded-lg border bg-amber-50 text-amber-800` boxes.
+A failed chat answer is an `Alert variant="destructive"` on the answer's
+`mr-5 ml-6` column: `CircleAlert`, the fixed title `conversation.failedTitle`,
+and the backend's error (often a raw provider exception) as `font-mono text-xs`
+detail in `AlertDescription`. Its action row is Retry (`RotateCcw`) and Copy,
+both `ghost-muted icon-sm pill` like every other answer action.
+
+The rows in an answer's step column (Sources, Reasoning, each tool step) are
+one recipe: `Button variant="ghost" size="sm"` at `ml-3.5 w-fit`, which puts a
+16px muted icon on the `ml-6` text column, then muted 14px text and a chevron.
+Sources adds its count and a right chevron, and opens the All sources sheet.
+
### Breadcrumb (`ui/breadcrumb.tsx`)
`BreadcrumbPage`, the current crumb, is always one line and truncates with
@@ -793,6 +808,18 @@ the overlay dismiss it; pass `showCloseButton` to keep one).
`pb-safe-0` are the `index.css` utilities for `env(safe-area-inset-bottom)`;
never spell `env()` in a class.
+**Open question: side panel or right sheet for chat content.** Chat has two
+ways to show something beside an answer. Notes, todos and files open in
+`components/ArtifactSidebar`, a panel that takes a column and leaves the chat
+usable. An answer's full source list opens in a right `Sheet`, which blurs
+and blocks the chat, so the answer being checked is hidden while its sources
+are read. No rule picks between them yet. The leading proposal is one
+surface: content read alongside the chat (artifacts, sources, a cited
+source) opens in the side panel, with citation chips opening it at that
+source; overlays stay for tasks that interrupt (forms, confirmations,
+pickers); phones keep the bottom sheet. Until that is decided, don't add a
+third pattern: new "read beside the chat" content uses the side panel.
+
In a picker list, mark the item that is currently chosen with
`CommandItem checked` (a `secondary` brand tint through `data-checked`), not
with `bg-accent`: cmdk's own `data-selected` highlight is `bg-accent` and
diff --git a/frontend/src/agents/types/schedule.ts b/frontend/src/agents/types/schedule.ts
index 2cbf3e83..75fa7971 100644
--- a/frontend/src/agents/types/schedule.ts
+++ b/frontend/src/agents/types/schedule.ts
@@ -3,12 +3,7 @@ export type ScheduleTriggerType = 'once' | 'recurring';
export type ScheduleStatus = 'active' | 'paused' | 'completed' | 'cancelled';
export type ScheduleRunStatus =
- | 'pending'
- | 'running'
- | 'success'
- | 'failed'
- | 'skipped'
- | 'timeout';
+ 'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'timeout';
// Mirrors the schedule_runs.error_type CHECK constraint (migrations 0010, 0027).
export type ScheduleRunErrorType =
diff --git a/frontend/src/agents/types/workflow.ts b/frontend/src/agents/types/workflow.ts
index 370dfb25..3d2994ee 100644
--- a/frontend/src/agents/types/workflow.ts
+++ b/frontend/src/agents/types/workflow.ts
@@ -1,11 +1,5 @@
export type NodeType =
- | 'start'
- | 'end'
- | 'agent'
- | 'note'
- | 'state'
- | 'condition'
- | 'code';
+ 'start' | 'end' | 'agent' | 'note' | 'state' | 'condition' | 'code';
export interface CodeNodeConfig {
code: string;
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 21ebf123..47daa009 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,8 +1,7 @@
import { envVar } from '@/env';
import { withThrottle, type FetchLike } from './throttle';
-export const baseURL =
- envVar('VITE_API_HOST') || 'https://docsapi.arc53.com';
+export const baseURL = envVar('VITE_API_HOST') || 'https://docsapi.arc53.com';
const getHeaders = (
token: string | null,
diff --git a/frontend/src/assets/retry.svg b/frontend/src/assets/retry.svg
deleted file mode 100644
index 6a47b545..00000000
--- a/frontend/src/assets/retry.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/frontend/src/components/ui/setting-row.test.tsx b/frontend/src/components/ui/setting-row.test.tsx
index c293efa1..2df53004 100644
--- a/frontend/src/components/ui/setting-row.test.tsx
+++ b/frontend/src/components/ui/setting-row.test.tsx
@@ -89,4 +89,27 @@ describe('SettingRows / SettingRow', () => {
.parentElement as HTMLElement;
expect(after.className).toContain('mt-2');
});
+
+ it('stacks a wide control under the label on phones with `stack`', () => {
+ const host = render(
+
+
+ ,
+ );
+ const line = host.querySelector('[data-slot="setting-row"] > div')!;
+ expect(line.className.split(' ')).toEqual(
+ expect.arrayContaining(['flex-col', 'sm:flex-row', 'sm:items-center']),
+ );
+ const slot = host.querySelector('[data-testid="picker"]')!
+ .parentElement as HTMLElement;
+ expect(slot.className.split(' ')).toEqual(
+ expect.arrayContaining(['w-full', 'sm:w-auto']),
+ );
+ const plain = render(
+
+
+ ,
+ );
+ expect(plain.innerHTML).not.toContain('sm:flex-row');
+ });
});
diff --git a/frontend/src/components/ui/setting-row.tsx b/frontend/src/components/ui/setting-row.tsx
index 66ebfee2..970cb3ae 100644
--- a/frontend/src/components/ui/setting-row.tsx
+++ b/frontend/src/components/ui/setting-row.tsx
@@ -21,6 +21,11 @@ type SettingRowProps = {
htmlFor?: string;
/** Top-align the control, for descriptions that wrap. */
alignStart?: boolean;
+ /**
+ * Put the control under the title below `sm` and let it take the row's
+ * width, for controls too wide to share a phone row (a picker).
+ */
+ stack?: boolean;
/**
* The title element. `label` (default) names the control; a heading tag
* keeps the document outline, and the control then needs its own
@@ -44,6 +49,7 @@ function SettingRow({
description,
htmlFor,
alignStart = false,
+ stack = false,
as = 'label',
after,
className,
@@ -57,8 +63,13 @@ function SettingRow({
>
@@ -78,7 +89,11 @@ function SettingRow({
{description}
) : null}
- {children ?
{children}
: null}
+ {children ? (
+
+ {children}
+
+ ) : null}
{after ?
{after}
: null}
diff --git a/frontend/src/conversation/ConversationBubble.test.tsx b/frontend/src/conversation/ConversationBubble.test.tsx
index 4aa31aed..62016644 100644
--- a/frontend/src/conversation/ConversationBubble.test.tsx
+++ b/frontend/src/conversation/ConversationBubble.test.tsx
@@ -355,4 +355,51 @@ describe('ConversationBubble', () => {
expect(sheet()).not.toBeNull();
});
});
+
+ it('shows a failed answer as a destructive Alert with the raw error as detail', async () => {
+ const raw = 'AuthenticationError: Error code: 401 - invalid_api_key';
+ await render(
+ retry}
+ />,
+ );
+ const alert = container.querySelector('[role="alert"]')!;
+ expect(alert.dataset.slot).toBe('alert');
+ expect(alert.dataset.variant).toBe('destructive');
+ expect(alert.className).not.toContain('rounded-full');
+ expect(alert.textContent).toContain(tr('conversation.failedTitle'));
+ const detail = alert.querySelector('[data-slot="alert-description"]')!;
+ expect(detail.textContent).toContain(raw);
+ expect(detail.innerHTML).toContain('font-mono');
+ // Retry sits beside a Copy button that copies the error for a report.
+ const copy = container.querySelector(
+ `button[aria-label="${tr('conversation.copy')}"]`,
+ );
+ expect(copy).not.toBeNull();
+ expect(buttonByText('retry')).toBeDefined();
+ });
+
+ it('makes the Sources header a step row that opens the All sources sheet', async () => {
+ const sources = [1, 2, 3, 4].map((n) => ({
+ title: `Doc ${n}`,
+ text: `Excerpt ${n}`,
+ link: `doc-${n}.pdf`,
+ }));
+ await render(
+ ,
+ );
+ const header = Array.from(
+ container.querySelectorAll(
+ 'button[data-variant="ghost"]',
+ ),
+ ).find((b) => b.textContent?.includes(tr('conversation.sources.title')))!;
+ expect(header).toBeDefined();
+ expect(header.dataset.size).toBe('sm');
+ expect(header.textContent).toContain('4');
+ expect(header.getAttribute('aria-haspopup')).toBe('dialog');
+ await act(async () => header.click());
+ expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
+ });
});
diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx
index 2ba9f670..7c3fa653 100644
--- a/frontend/src/conversation/ConversationBubble.tsx
+++ b/frontend/src/conversation/ConversationBubble.tsx
@@ -4,6 +4,8 @@ import 'katex/dist/katex.min.css';
import {
ChevronDown,
+ ChevronRight,
+ CircleAlert,
Database,
Download,
Eye,
@@ -21,6 +23,7 @@ import { useSelector } from 'react-redux';
import WorkflowRunArtifacts from '../agents/workflow/WorkflowRunArtifacts';
import CopyButton from '../components/CopyButton';
+import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { IconButton } from '../components/ui/icon-button';
@@ -40,7 +43,6 @@ import AnswerFlow from './AnswerFlow';
import { AnswerSegment } from './answerSegments';
import { deriveArtifactChips } from './artifactChips';
import { FEEDBACK, MESSAGE_TYPE, ResearchState } from './conversationModels';
-import MarkdownAnswer from './MarkdownAnswer';
import ResearchProgress from './ResearchProgress';
import { ToolCallsType } from './types';
import { wikiWriteActionKey, wikiWritePath } from './wikiToolCall';
@@ -291,12 +293,25 @@ const ConversationBubble = forwardRef<
// Stretched, not shrink-to-fit: the grid below sizes off this box,
// so a fit-content parent would leave its width to the cards.
-
-
-
+ {/* A step row like Reasoning and the tool steps below it: same
+ metrics, icon on the ml-6 column, and it opens the full list. */}
+
-
+
+
+ {sources.length}
+
+
+
{/* Width comes from the stretched parent minus these margins;
w-full here would be the column width plus them. */}