Real UI fixes

This commit is contained in:
Pavel committed 2026-09-26 14:02:19 +04:00
1 parent f66dc50eed
commit c4b1b2767f
27 files changed
+524 -210

No files matched your search

+1 -1
View File
@@ -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. `<X className="size-4" />`, `<ChevronDown />`, etc.
2. **Use `assets/<name>.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 `<img src={Asset}>` for new icons.** It blocks `currentColor` theming and
+30 -3
View File
@@ -458,14 +458,18 @@ hand-positioned floating label, or a `div.flex-col` + `Label` + `<p>` stack.
### SettingRow (`ui/setting-row.tsx`)
A setting with a control on the right (a Switch, a short Input) is
`<SettingRow label description htmlFor alignStart as after>{control}</SettingRow>`,
`<SettingRow label description htmlFor alignStart stack as after>{control}</SettingRow>`,
grouped in `<SettingRows>`, 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
+1 -6
View File
@@ -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 =
+1 -7
View File
@@ -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;
+1 -2
View File
@@ -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,
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 383.748 383.748" fill="currentColor" stroke="currentColor" stroke-width="10">
<path d="M62.772 95.042C90.904 54.899 137.496 30 187.343 30c83.743 0 151.874 68.13 151.874 151.874h30C369.217 81.588 287.629 0 187.343 0c-35.038 0-69.061 9.989-98.391 28.888a182.423 182.423 0 0 0-47.731 44.705L2.081 34.641v113.365h113.91L62.772 95.042zM381.667 235.742h-113.91l53.219 52.965c-28.132 40.142-74.724 65.042-124.571 65.042-83.744 0-151.874-68.13-151.874-151.874h-30c0 100.286 81.588 181.874 181.874 181.874 35.038 0 69.062-9.989 98.391-28.888a182.443 182.443 0 0 0 47.731-44.706l39.139 38.952V235.742z"/>
</svg>

Before

Width:  |  Height:  |  Size: 678 B

@@ -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(
<SettingRow label="Theme" stack>
<select data-testid="picker" />
</SettingRow>,
);
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(
<SettingRow label="Theme">
<select />
</SettingRow>,
);
expect(plain.innerHTML).not.toContain('sm:flex-row');
});
});
+18 -3
View File
@@ -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({
>
<div
className={cn(
'flex flex-row justify-between gap-4',
alignStart ? 'items-start' : 'items-center',
'flex justify-between gap-4',
stack
? cn(
'flex-col gap-y-2 sm:flex-row',
alignStart ? 'sm:items-start' : 'sm:items-center',
)
: cn('flex-row', alignStart ? 'items-start' : 'items-center'),
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -78,7 +89,11 @@ function SettingRow({
<p className="text-muted-foreground text-xs">{description}</p>
) : null}
</div>
{children ? <div className="shrink-0">{children}</div> : null}
{children ? (
<div className={cn('shrink-0', stack && 'w-full sm:w-auto')}>
{children}
</div>
) : null}
</div>
{after ? <div className="mt-2">{after}</div> : null}
</div>
@@ -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(
<ConversationBubble
type="ERROR"
message={raw}
retryBtn={<button type="button">retry</button>}
/>,
);
const alert = container.querySelector<HTMLElement>('[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<HTMLButtonElement>(
`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(
<ConversationBubble type="ANSWER" message="Answer" sources={sources} />,
);
const header = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'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();
});
});
@@ -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.
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-stretch lg:flex-nowrap">
<div className="my-2 ml-6 flex flex-row items-center justify-center gap-3">
<Database className="size-6" />
<p className="text-base font-semibold">
{/* 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. */}
<Button
type="button"
variant="ghost"
size="sm"
aria-haspopup="dialog"
onClick={() => setIsSidebarOpen(true)}
className="my-2 ml-3.5 w-fit"
>
<Database className="text-muted-foreground" aria-hidden />
<span className="text-muted-foreground">
{t('conversation.sources.title')}
</p>
</div>
</span>
<span className="text-muted-foreground/70 font-normal">
{sources.length}
</span>
<ChevronRight className="text-muted-foreground" aria-hidden />
</Button>
{/* Width comes from the stretched parent minus these margins;
w-full here would be the column width plus them. */}
<div className="animate-in fade-in mr-5 ml-6 duration-160 ease-out motion-reduce:animate-none">
@@ -410,10 +425,18 @@ const ConversationBubble = forwardRef<
)}
{type === 'ERROR' ? (
message && (
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="animate-in fade-in slide-in-from-bottom-1.5 text-destructive border-destructive bg-destructive/10 relative mr-5 flex max-w-full flex-row items-center rounded-full border p-2 px-6 py-5 text-sm font-normal duration-260 ease-out motion-reduce:animate-none">
<MarkdownAnswer content={message} isStreaming={isStreaming} />
</div>
// On the answer's ml-6 text column. The backend's error is often a
// raw provider exception, so it is the detail under a readable title.
<div className="animate-in fade-in slide-in-from-bottom-1.5 mr-5 ml-6 self-stretch duration-260 ease-out motion-reduce:animate-none">
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('conversation.failedTitle')}</AlertTitle>
<AlertDescription>
<p className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
{message}
</p>
</AlertDescription>
</Alert>
</div>
)
) : (
@@ -451,9 +474,14 @@ const ConversationBubble = forwardRef<
// ml-6 text column.
<div className="my-2 ml-4 flex flex-wrap justify-start gap-2">
{type === 'ERROR' ? (
<div className="relative block items-center justify-center">
<div>{retryBtn}</div>
</div>
<>
<div className="relative block items-center justify-center">
{retryBtn}
</div>
<div className="relative block items-center justify-center">
<CopyButton textToCopy={message} />
</div>
</>
) : (
<>
{onOpenArtifact &&
@@ -6,7 +6,18 @@ vi.mock('react-i18next', () => ({
}));
vi.mock('./ConversationBubble', () => ({
default: ({ message }: { message?: string }) => <div>{message}</div>,
default: ({
message,
retryBtn,
}: {
message?: string;
retryBtn?: React.ReactNode;
}) => (
<div>
{message}
{retryBtn}
</div>
),
}));
vi.mock('../Hero', () => ({ default: () => null }));
@@ -77,4 +88,14 @@ describe('ConversationMessages', () => {
expect(spacer.className).toContain('max-h-0');
});
it('renders Retry like the other answer actions: 32px ghost pill, lucide glyph', () => {
render([{ prompt: 'hi', error: 'boom' }]);
const retry = container.querySelector<HTMLButtonElement>(
'button[aria-label="conversation.retry"]',
)!;
expect(retry.dataset.variant).toBe('ghost-muted');
expect(retry.dataset.size).toBe('icon-sm');
expect(retry.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
});
});
@@ -7,10 +7,9 @@ import {
useRef,
useState,
} from 'react';
import { TriangleAlert } from 'lucide-react';
import { RotateCcw, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Retry from '../assets/retry.svg?react';
import { Alert, AlertDescription } from '../components/ui/alert';
import { IconButton } from '../components/ui/icon-button';
import {
@@ -163,9 +162,8 @@ export default function ConversationMessages({
<IconButton
label={t('conversation.retry')}
variant="ghost-muted"
size="icon"
size="icon-sm"
shape="pill"
className="self-center"
disabled={status === 'loading'}
onClick={() => {
const questionToRetry = queries[index].prompt;
@@ -176,7 +174,7 @@ export default function ConversationMessages({
});
}}
>
<Retry aria-hidden="true" />
<RotateCcw aria-hidden="true" />
</IconButton>
);
return (
@@ -15,6 +15,7 @@ import reducer, {
addQuery,
applyMessageTail,
fetchAnswer,
mapServerQueryToClient,
raiseNotice,
resendQuery,
setConversation,
@@ -328,3 +329,34 @@ describe('resendQuery', () => {
expect(state.queries[0].error).toBeUndefined();
});
});
describe('mapServerQueryToClient feedback', () => {
// The API stores feedback lowercase (analytics counts 'like'/'dislike'),
// while the thumbs compare against the FEEDBACK union.
it.each([
['like', 'LIKE'],
['dislike', 'DISLIKE'],
['LIKE', 'LIKE'],
['Dislike', 'DISLIKE'],
])('maps stored %s to %s', (stored, expected) => {
const query = mapServerQueryToClient({
prompt: 'q',
response: 'a',
status: 'complete',
feedback: stored,
});
expect(query.feedback).toBe(expected);
});
it('drops missing or unknown feedback', () => {
for (const feedback of [undefined, null, '', 'meh']) {
const query = mapServerQueryToClient({
prompt: 'q',
response: 'a',
status: 'complete',
feedback,
});
expect(query.feedback).toBeUndefined();
}
});
});
+10 -1
View File
@@ -29,6 +29,7 @@ import {
import {
Answer,
ConversationState,
FEEDBACK,
MessageStatus,
Query,
ResearchStep,
@@ -40,6 +41,14 @@ import { ToolCallsType } from './types';
// terminal ``complete`` rows expose ``response``; non-terminal rows
// would carry the WAL placeholder text, which must never render.
// ``failed`` rows surface as ``error`` so they pick up Retry.
// The API stores feedback lowercase ('like' / 'dislike') so analytics can
// count it; the thumbs compare against the uppercase FEEDBACK union.
function toClientFeedback(value: unknown): FEEDBACK | undefined {
if (typeof value !== 'string') return undefined;
const upper = value.toUpperCase();
return upper === 'LIKE' || upper === 'DISLIKE' ? upper : undefined;
}
export function mapServerQueryToClient(raw: any): Query {
const status = raw?.status as MessageStatus | undefined;
const isTerminalComplete = status === 'complete';
@@ -53,7 +62,7 @@ export function mapServerQueryToClient(raw: any): Query {
const sources = Array.isArray(raw?.sources) ? raw.sources : undefined;
const query: Query = {
prompt: raw?.prompt ?? '',
feedback: raw?.feedback ?? undefined,
feedback: toClientFeedback(raw?.feedback),
thought: raw?.thought ?? undefined,
sources: sources && sources.length > 0 ? sources : undefined,
tool_calls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined,
+13 -2
View File
@@ -60,14 +60,24 @@
"label": "Einstellungen",
"general": {
"label": "Allgemein",
"selectTheme": "Design auswählen",
"subtitle": "Legen Sie fest, wie DocsGPT aussieht, welche Sprache es verwendet und welchen Prompt Ihre Chats nutzen.",
"sections": {
"appearance": "Darstellung",
"chat": "Chat",
"dangerZone": "Gefahrenzone"
},
"theme": "Design",
"themeDescription": "Helle oder dunkle Oberfläche.",
"language": "Sprache",
"languageDescription": "Sprache von Menüs und Beschriftungen.",
"light": "Hell",
"dark": "Dunkel",
"selectLanguage": "Sprache auswählen",
"chunks": "Chunks pro Anfrage",
"prompt": "Aktiver Prompt",
"promptDescription": "Der System-Prompt für Chats ohne Agent. Agenten verwenden ihren eigenen.",
"deleteAllLabel": "Alle Konversationen löschen",
"deleteAllBtn": "Alle löschen",
"deleteAllDescription": "Löscht alle Unterhaltungen in Ihrem Konto dauerhaft. Dies kann nicht rückgängig gemacht werden.",
"addNew": "Neu hinzufügen",
"convHistory": "Konversationsverlauf",
"none": "Keine",
@@ -1368,6 +1378,7 @@
"attachment": "Anhang"
},
"retry": "Erneut versuchen",
"failedTitle": "Antwort konnte nicht erstellt werden",
"reasoning": "Begründung",
"wikiWrite": {
"edited": "Wiki bearbeitet",
+16 -5
View File
@@ -64,14 +64,24 @@
"label": "Settings",
"general": {
"label": "General",
"selectTheme": "Select Theme",
"subtitle": "Choose how DocsGPT looks, which language it uses, and the prompt your chats use.",
"sections": {
"appearance": "Appearance",
"chat": "Chat",
"dangerZone": "Danger zone"
},
"theme": "Theme",
"themeDescription": "Light or dark interface.",
"language": "Language",
"languageDescription": "Language of menus and labels.",
"light": "Light",
"dark": "Dark",
"selectLanguage": "Select Language",
"chunks": "Chunks processed per query",
"prompt": "Active Prompt",
"deleteAllLabel": "Delete All Conversations",
"deleteAllBtn": "Delete All",
"prompt": "Active prompt",
"promptDescription": "The system prompt for chats without an agent. Agents use their own.",
"deleteAllLabel": "Delete all conversations",
"deleteAllBtn": "Delete all",
"deleteAllDescription": "Permanently deletes every conversation in your account. This can't be undone.",
"addNew": "Add New",
"convHistory": "Conversation History",
"none": "None",
@@ -1380,6 +1390,7 @@
"attachment": "Attachment"
},
"retry": "Retry",
"failedTitle": "Couldn't generate a response",
"reasoning": "Reasoning",
"wikiWrite": {
"edited": "Edited wiki",
+13 -2
View File
@@ -60,14 +60,24 @@
"label": "Configuración",
"general": {
"label": "General",
"selectTheme": "Seleccionar Tema",
"subtitle": "Elige el aspecto de DocsGPT, el idioma que usa y el prompt de tus chats.",
"sections": {
"appearance": "Apariencia",
"chat": "Chat",
"dangerZone": "Zona de peligro"
},
"theme": "Tema",
"themeDescription": "Interfaz clara u oscura.",
"language": "Idioma",
"languageDescription": "Idioma de los menús y las etiquetas.",
"light": "Claro",
"dark": "Oscuro",
"selectLanguage": "Seleccionar Idioma",
"chunks": "Fragmentos procesados por consulta",
"prompt": "Prompt Activo",
"promptDescription": "El prompt del sistema para los chats sin agente. Los agentes usan el suyo.",
"deleteAllLabel": "Eliminar todas las conversaciones",
"deleteAllBtn": "Eliminar todo",
"deleteAllDescription": "Elimina de forma permanente todas las conversaciones de tu cuenta. No se puede deshacer.",
"addNew": "Añadir Nuevo",
"convHistory": "Historial de conversaciones",
"none": "Ninguno",
@@ -1368,6 +1378,7 @@
"attachment": "Adjunto"
},
"retry": "Reintentar",
"failedTitle": "No se pudo generar una respuesta",
"reasoning": "Razonamiento",
"wikiWrite": {
"edited": "Wiki editada",
+13 -2
View File
@@ -60,14 +60,24 @@
"label": "設定",
"general": {
"label": "一般",
"selectTheme": "テーマを選択",
"subtitle": "DocsGPTの外観、表示言語、チャットで使うプロンプトを選択します。",
"sections": {
"appearance": "外観",
"chat": "チャット",
"dangerZone": "危険な操作"
},
"theme": "テーマ",
"themeDescription": "ライトまたはダークのインターフェース。",
"language": "言語",
"languageDescription": "メニューとラベルの言語。",
"light": "ライト",
"dark": "ダーク",
"selectLanguage": "言語を選択",
"chunks": "クエリごとに処理されるチャンク",
"prompt": "アクティブプロンプト",
"promptDescription": "エージェントを使わないチャットのシステムプロンプト。エージェントは独自のものを使います。",
"deleteAllLabel": "すべての会話を削除",
"deleteAllBtn": "すべて削除",
"deleteAllDescription": "アカウント内のすべての会話を完全に削除します。この操作は元に戻せません。",
"addNew": "新規追加",
"convHistory": "会話履歴",
"none": "なし",
@@ -1365,6 +1375,7 @@
"attachment": "添付ファイル"
},
"retry": "再試行",
"failedTitle": "応答を生成できませんでした",
"reasoning": "推論",
"wikiWrite": {
"edited": "Wikiを編集",
+13 -2
View File
@@ -60,14 +60,24 @@
"label": "Настройки",
"general": {
"label": "Общие",
"selectTheme": "Выбрать тему",
"subtitle": "Выберите оформление DocsGPT, язык интерфейса и промпт для ваших чатов.",
"sections": {
"appearance": "Оформление",
"chat": "Чат",
"dangerZone": "Опасная зона"
},
"theme": "Тема",
"themeDescription": "Светлый или тёмный интерфейс.",
"language": "Язык",
"languageDescription": "Язык меню и подписей.",
"light": "Светлая",
"dark": "Тёмная",
"selectLanguage": "Выбрать язык",
"chunks": "Обработанные фрагменты на запрос",
"prompt": "Активная подсказка",
"promptDescription": "Системный промпт для чатов без агента. Агенты используют свой.",
"deleteAllLabel": "Удалить все беседы",
"deleteAllBtn": "Удалить все",
"deleteAllDescription": "Безвозвратно удаляет все беседы в вашем аккаунте. Это действие нельзя отменить.",
"addNew": "Добавить новый",
"convHistory": "История разговоров",
"none": "Нет",
@@ -1412,6 +1422,7 @@
"attachment": "Вложение"
},
"retry": "Повторить",
"failedTitle": "Не удалось сгенерировать ответ",
"reasoning": "Рассуждение",
"wikiWrite": {
"edited": "Вики отредактирована",
+13 -2
View File
@@ -60,14 +60,24 @@
"label": "設定",
"general": {
"label": "一般",
"selectTheme": "選擇主題",
"subtitle": "選擇 DocsGPT 的外觀、介面語言以及聊天使用的提示詞。",
"sections": {
"appearance": "外觀",
"chat": "聊天",
"dangerZone": "危險操作"
},
"theme": "主題",
"themeDescription": "淺色或深色介面。",
"language": "語言",
"languageDescription": "選單和標籤的語言。",
"light": "淺色",
"dark": "深色",
"selectLanguage": "選擇語言",
"chunks": "每次查詢處理的區塊數",
"prompt": "使用中的提示",
"promptDescription": "不使用代理的聊天所用的系統提示詞。代理會使用各自的提示詞。",
"deleteAllLabel": "刪除所有對話",
"deleteAllBtn": "全部刪除",
"deleteAllDescription": "永久刪除您帳戶中的所有對話。此操作無法復原。",
"addNew": "新增",
"convHistory": "對話歷史記錄",
"none": "無",
@@ -1365,6 +1375,7 @@
"attachment": "附件"
},
"retry": "重試",
"failedTitle": "無法產生回覆",
"reasoning": "推理",
"wikiWrite": {
"edited": "已編輯 Wiki",
+13 -2
View File
@@ -60,14 +60,24 @@
"label": "设置",
"general": {
"label": "一般",
"selectTheme": "选择主题",
"subtitle": "选择 DocsGPT 的外观、界面语言以及聊天使用的提示词。",
"sections": {
"appearance": "外观",
"chat": "聊天",
"dangerZone": "危险操作"
},
"theme": "主题",
"themeDescription": "浅色或深色界面。",
"language": "语言",
"languageDescription": "菜单和标签的语言。",
"light": "浅色",
"dark": "暗色",
"selectLanguage": "选择语言",
"chunks": "每个查询处理的块",
"prompt": "提示",
"promptDescription": "不使用智能体的聊天所用的系统提示词。智能体使用各自的提示词。",
"deleteAllLabel": "删除所有对话",
"deleteAllBtn": "删除所有",
"deleteAllDescription": "永久删除您账户中的所有对话。此操作无法撤销。",
"addNew": "添加新的",
"convHistory": "对话历史",
"none": "无",
@@ -1365,6 +1375,7 @@
"attachment": "附件"
},
"retry": "重试",
"failedTitle": "无法生成回复",
"reasoning": "推理",
"wikiWrite": {
"edited": "已编辑 Wiki",
+1 -5
View File
@@ -8,11 +8,7 @@ export type User = {
// (application/storage/db/source_config.py). All fields are optional; absent
// keys fall back to the backend defaults documented inline.
export type ChunkingStrategy =
| 'classic_chunk'
| 'recursive'
| 'markdown'
| 'parent_child'
| 'semantic';
'classic_chunk' | 'recursive' | 'markdown' | 'parent_child' | 'semantic';
export type RetrievalExposure = 'prefetch' | 'agentic_tool';
+1 -6
View File
@@ -1,12 +1,7 @@
export type ModelSource = 'builtin' | 'user';
export type ModelApiFlavor = 'chat_completions' | 'responses';
export type ReasoningEffort =
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh';
'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export interface AvailableModel {
id: string;
+110 -71
View File
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Button } from '../components/ui/button';
import { FormField } from '../components/ui/form-field';
import {
Select,
SelectContent,
@@ -11,7 +10,9 @@ import {
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { Separator } from '../components/ui/separator';
import { SectionHeader } from '../components/ui/section-header';
import { SettingRow, SettingRows } from '../components/ui/setting-row';
import PageToolbar from '../components/PageToolbar';
import { useDarkTheme } from '../hooks';
import {
selectPrompt,
@@ -47,6 +48,8 @@ export default function General() {
isDarkTheme ? 'Dark' : 'Light',
);
const dispatch = useDispatch();
const themeId = React.useId();
const languageId = React.useId();
const locale = localStorage.getItem('docsgpt-locale');
// Fall back to English when the stored locale is not one we offer. Without
// the fallback `find` returns undefined, the effect below writes the string
@@ -64,75 +67,111 @@ export default function General() {
changeLanguage(selectedLanguage.value);
}, [selectedLanguage, changeLanguage]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<Prompts
prompts={prompts}
selectedPrompt={selectedPrompt}
onSelectPrompt={(name, id, type) =>
dispatch(setPrompt({ name: name, id: id, type: type }))
}
setPrompts={(newPrompts) => dispatch(setPrompts(newPrompts))}
/>
</div>
<FormField
label={t('settings.general.selectTheme')}
labelSurface="background"
>
<Select
value={selectedTheme}
onValueChange={(value) => {
setSelectedTheme(value);
value !== selectedTheme && toggleTheme();
}}
>
<SelectTrigger className="w-56" size="field" shape="pill">
<SelectValue />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.value} value={theme.value}>
{theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label={t('settings.general.selectLanguage')}
labelSurface="background"
>
<Select
value={selectedLanguage?.value}
onValueChange={(value) => {
const opt = languageOptions.find((o) => o.value === value);
if (opt) setSelectedLanguage(opt);
}}
>
<SelectTrigger className="w-56" size="field" shape="pill">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languageOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<Separator className="my-4 w-[calc(min(665px,100%))]" />
<div className="flex flex-col gap-2">
<Button
type="button"
variant="destructive-outline"
title={t('settings.general.deleteAllLabel')}
shape="pill"
className="w-fit"
onClick={() => dispatch(setModalStateDeleteConv('ACTIVE'))}
>
{t('settings.general.deleteAllBtn')}
</Button>
<div>
<PageToolbar intro={t('settings.general.subtitle')} divider />
<div className="flex max-w-3xl flex-col gap-10">
<section className="flex flex-col gap-4">
<SectionHeader title={t('settings.general.sections.appearance')} />
<SettingRows>
<SettingRow
label={t('settings.general.theme')}
description={t('settings.general.themeDescription')}
htmlFor={themeId}
stack
>
<Select
value={selectedTheme}
onValueChange={(value) => {
setSelectedTheme(value);
if (value !== selectedTheme) toggleTheme();
}}
>
<SelectTrigger
id={themeId}
className="w-full sm:w-56"
size="field"
shape="pill"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.value} value={theme.value}>
{theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t('settings.general.language')}
description={t('settings.general.languageDescription')}
htmlFor={languageId}
stack
>
<Select
value={selectedLanguage?.value}
onValueChange={(value) => {
const opt = languageOptions.find((o) => o.value === value);
if (opt) setSelectedLanguage(opt);
}}
>
<SelectTrigger
id={languageId}
className="w-full sm:w-56"
size="field"
shape="pill"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{languageOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
</SettingRows>
</section>
<section className="flex flex-col gap-4">
<SectionHeader title={t('settings.general.sections.chat')} />
<SettingRows>
<Prompts
prompts={prompts}
selectedPrompt={selectedPrompt}
description={t('settings.general.promptDescription')}
onSelectPrompt={(name, id, type) =>
dispatch(setPrompt({ name: name, id: id, type: type }))
}
setPrompts={(newPrompts) => dispatch(setPrompts(newPrompts))}
/>
</SettingRows>
</section>
<section className="flex flex-col gap-4">
<SectionHeader
title={t('settings.general.sections.dangerZone')}
tone="destructive"
/>
<SettingRows>
<SettingRow
label={t('settings.general.deleteAllLabel')}
description={t('settings.general.deleteAllDescription')}
as="h3"
>
<Button
type="button"
variant="destructive-outline"
size="field"
shape="pill"
onClick={() => dispatch(setModalStateDeleteConv('ACTIVE'))}
>
{t('settings.general.deleteAllBtn')}
</Button>
</SettingRow>
</SettingRows>
</section>
</div>
</div>
);
+15 -11
View File
@@ -79,23 +79,27 @@ describe('Prompts', () => {
expect(edit).not.toBeNull();
expect(edit?.getAttribute('data-variant')).toBe('ghost-muted');
expect(edit?.getAttribute('data-size')).toBe('icon-xs');
expect(edit?.parentElement).toBe(
trigger?.closest('[data-slot="form-field"]')?.parentElement,
);
expect(edit?.parentElement).toBe(trigger?.parentElement);
});
it('labels the picker with a floating label on Settings, named by it', () => {
renderPrompts();
it('renders a SettingRow on Settings whose label names the picker', () => {
renderPrompts({ description: 'Used without an agent.' });
const trigger = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]',
)!;
const label = container.querySelector<HTMLLabelElement>(
'[data-slot="form-field-label"]',
)!;
expect(
container.querySelector('[data-slot="form-field-label"]'),
).toBeNull();
const row = container.querySelector('[data-slot="setting-row"]')!;
const label = row.querySelector<HTMLLabelElement>('label')!;
expect(label.textContent).toBe('settings.general.prompt');
expect(label.htmlFor).toBe(trigger.id);
expect(label.className).toContain('bg-background');
expect(row.textContent).toContain('Used without an agent.');
expect(trigger.hasAttribute('aria-label')).toBe(false);
// 224px from sm, full width when the row stacks on a phone.
expect(trigger.className.split(' ')).toEqual(
expect.arrayContaining(['w-full', 'sm:w-56']),
);
});
it('keeps a section heading above the picker with titleAs="heading"', () => {
@@ -120,14 +124,14 @@ describe('Prompts', () => {
).toBe('Prompt');
});
it('sizes the Add button to the field row', () => {
it('makes Add a neutral outline pill sized to the field row', () => {
renderPrompts();
const add = Array.from(
container.querySelectorAll<HTMLButtonElement>('[data-slot="button"]'),
).find((b) => b.textContent === 'settings.general.add');
expect(add?.getAttribute('data-size')).toBe('field');
expect(add?.getAttribute('data-shape')).toBe('pill');
expect(add?.getAttribute('data-variant')).toBe('default');
expect(add?.getAttribute('data-variant')).toBe('outline');
expect(add?.className).not.toMatch(/(^|\s)h-11\.5(\s|$)/);
});
+61 -47
View File
@@ -12,7 +12,6 @@ import {
CommandList,
} from '../components/ui/command';
import { Button } from '../components/ui/button';
import { FormField } from '../components/ui/form-field';
import { IconButton } from '../components/ui/icon-button';
import {
Popover,
@@ -20,6 +19,7 @@ import {
PopoverTrigger,
} from '../components/ui/popover';
import { SectionHeader } from '../components/ui/section-header';
import { SettingRow } from '../components/ui/setting-row';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, PromptProps } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
@@ -34,10 +34,12 @@ type PromptsDropdownProps = {
type ExtendedPromptProps = PromptProps & {
title?: string;
/**
* `label` (Settings → General): the title is the picker's floating label.
* `row` (Settings → General): a SettingRow whose label names the picker.
* `heading` (the agent form): the title is a section heading above it.
*/
titleAs?: 'label' | 'heading';
titleAs?: 'row' | 'heading';
/** The row's muted description, for `titleAs="row"`. */
description?: string;
dropdownProps?: PromptsDropdownProps;
showAddButton?: boolean;
};
@@ -48,7 +50,8 @@ export default function Prompts({
onSelectPrompt,
setPrompts,
title,
titleAs = 'label',
titleAs = 'row',
description,
dropdownProps = {},
showAddButton = true,
}: ExtendedPromptProps) {
@@ -282,7 +285,10 @@ export default function Prompts({
aria-expanded={open}
aria-label={titleAs === 'heading' ? titleText : undefined}
data-placeholder={selectedPrompt?.name ? undefined : ''}
className="min-w-0 flex-1 justify-between"
className={cn(
'w-full min-w-0 justify-between',
titleAs === 'row' && 'sm:w-56',
)}
>
<span className="truncate">
{selectedPrompt?.name || t('settings.general.promptActions.select')}
@@ -396,13 +402,55 @@ export default function Prompts({
</Popover>
);
const editButton = selectedPrompt?.id && selectedPrompt.type !== 'public' && (
<IconButton
variant="ghost-muted"
size="icon-xs"
shape="pill"
onClick={() => openEditModal(selectedPrompt)}
label={t('settings.general.promptActions.edit')}
icon={Pencil}
/>
);
// A prompt that belongs to this field, so a neutral pill rather than the
// primary one reserved for a page's own action.
const addButton = showAddButton && (
<Button
type="button"
variant="outline"
size="field"
shape="pill"
onClick={() => {
setModalType('ADD');
setDuplicateSource(null);
setModalState('ACTIVE');
}}
>
{t('settings.general.add')}
</Button>
);
return (
<>
<div>
{titleAs === 'row' ? (
<SettingRow
label={titleText}
description={description}
htmlFor={pickerId}
stack
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1 sm:flex-none">
{picker}
{editButton}
</div>
{addButton}
</div>
</SettingRow>
) : (
<div className="flex flex-col gap-3">
{titleAs === 'heading' ? (
<SectionHeader as="h2" title={titleText} />
) : null}
<SectionHeader as="h2" title={titleText} />
<div className="flex flex-row flex-wrap items-end justify-start gap-6">
<div
className={cn(
@@ -410,47 +458,13 @@ export default function Prompts({
dropdownProps.className,
)}
>
{titleAs === 'label' ? (
<FormField
label={titleText}
id={pickerId}
labelSurface="background"
className="min-w-0 flex-1"
>
{picker}
</FormField>
) : (
picker
)}
{selectedPrompt?.id && selectedPrompt.type !== 'public' && (
<IconButton
variant="ghost-muted"
size="icon-xs"
shape="pill"
onClick={() => openEditModal(selectedPrompt)}
label={t('settings.general.promptActions.edit')}
icon={Pencil}
/>
)}
{picker}
{editButton}
</div>
{showAddButton && (
<Button
type="button"
size="field"
shape="pill"
className="w-20"
onClick={() => {
setModalType('ADD');
setDuplicateSource(null);
setModalState('ACTIVE');
}}
>
{t('settings.general.add')}
</Button>
)}
{addButton}
</div>
</div>
</div>
)}
<PromptsModal
existingPrompts={prompts}
type={modalType}
+12 -8
View File
@@ -32,6 +32,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
import { Button } from '../components/ui/button';
type TeamSwitcherProps = {
// Called after a navigation/selection so callers (e.g. the mobile nav) can
@@ -104,10 +105,10 @@ export default function TeamSwitcher({
// Expanded brand row. In a personal context the full lockup replaces the
// mark-plus-label pair outright — the wordmark is part of the artwork, so a
// separate "DocsGPT" text label would repeat it. `mr-auto` keeps the chevron
// pinned right, the job the label's `flex-1` used to do, and `ml-4` lines the
// pinned right, the job the label's `flex-1` used to do, and `ml-3` lines the
// logo's left edge up with the "Agents"/"Chats" section headings below it
// (those sit 32px in: `mx-4` on their row plus `ml-4` on the label; the
// header strip and this button contribute 8px of padding each).
// header strip adds 8px and the sidebar-item row's `pl-3` 12px).
const expandedBrand = currentTeam ? (
<>
{triggerIcon}
@@ -117,7 +118,7 @@ export default function TeamSwitcher({
</>
) : (
<img
className="mr-auto ml-4 h-4 w-auto shrink-0"
className="mr-auto ml-3 h-4 w-auto shrink-0"
src={isDarkTheme ? DocsGPTLogoWhite : DocsGPTLogo}
alt="DocsGPT"
/>
@@ -150,19 +151,22 @@ export default function TeamSwitcher({
<button
type="button"
aria-label={t('teams.switcher.ariaLabel')}
className="hover:bg-accent flex items-center justify-center rounded-lg p-1 transition-colors"
className="hover:bg-sidebar-accent flex items-center justify-center rounded-full p-1 transition-colors"
>
{triggerIcon}
</button>
) : (
<button
<Button
type="button"
variant="sidebar-item"
aria-label={t('teams.switcher.ariaLabel')}
className="hover:bg-accent text-foreground flex w-full items-center gap-1.5 rounded-lg px-2 py-1 text-left transition-colors"
className="w-full"
>
{expandedBrand}
<ChevronsUpDown className="text-muted-foreground size-4 shrink-0" />
</button>
{/* sidebar-item rows carry no right padding, so the chevron's
margin keeps it clear of the pill's end. */}
<ChevronsUpDown className="text-muted-foreground mr-3 size-4" />
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-62">