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( + + + , + ); + 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. */}
@@ -410,10 +425,18 @@ const ConversationBubble = forwardRef< )} {type === 'ERROR' ? ( message && ( -
-
- -
+ // 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. +
+ + + {t('conversation.failedTitle')} + +

+ {message} +

+
+
) ) : ( @@ -451,9 +474,14 @@ const ConversationBubble = forwardRef< // ml-6 text column.
{type === 'ERROR' ? ( -
-
{retryBtn}
-
+ <> +
+ {retryBtn} +
+
+ +
+ ) : ( <> {onOpenArtifact && diff --git a/frontend/src/conversation/ConversationMessages.test.tsx b/frontend/src/conversation/ConversationMessages.test.tsx index ad431b99..ee37a712 100644 --- a/frontend/src/conversation/ConversationMessages.test.tsx +++ b/frontend/src/conversation/ConversationMessages.test.tsx @@ -6,7 +6,18 @@ vi.mock('react-i18next', () => ({ })); vi.mock('./ConversationBubble', () => ({ - default: ({ message }: { message?: string }) =>
{message}
, + default: ({ + message, + retryBtn, + }: { + message?: string; + retryBtn?: React.ReactNode; + }) => ( +
+ {message} + {retryBtn} +
+ ), })); 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( + '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(); + }); }); diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index 2da8aa25..3686df8c 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -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({ { const questionToRetry = queries[index].prompt; @@ -176,7 +174,7 @@ export default function ConversationMessages({ }); }} > - ); return ( diff --git a/frontend/src/conversation/conversationSlice.test.ts b/frontend/src/conversation/conversationSlice.test.ts index f4fc9feb..7ceeb0d4 100644 --- a/frontend/src/conversation/conversationSlice.test.ts +++ b/frontend/src/conversation/conversationSlice.test.ts @@ -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(); + } + }); +}); diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index a46e2978..f0823b9d 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -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, diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 79795d12..8e741b3f 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -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", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index fc0a2b62..e40886e1 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -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", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index caa0b1f2..6fab7ccd 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -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", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index d430df6f..bbac57ef 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -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を編集", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index c3514481..01f8d7ef 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -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": "Вики отредактирована", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 5450a837..4df98e2e 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -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", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 128614d5..8e16bd23 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -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", diff --git a/frontend/src/models/misc.ts b/frontend/src/models/misc.ts index c0c5efb2..4beef408 100644 --- a/frontend/src/models/misc.ts +++ b/frontend/src/models/misc.ts @@ -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'; diff --git a/frontend/src/models/types.ts b/frontend/src/models/types.ts index a68829e5..9dba98d3 100644 --- a/frontend/src/models/types.ts +++ b/frontend/src/models/types.ts @@ -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; diff --git a/frontend/src/settings/General.tsx b/frontend/src/settings/General.tsx index 0c0514be..0ceb244e 100644 --- a/frontend/src/settings/General.tsx +++ b/frontend/src/settings/General.tsx @@ -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 ( -
-
- - dispatch(setPrompt({ name: name, id: id, type: type })) - } - setPrompts={(newPrompts) => dispatch(setPrompts(newPrompts))} - /> -
- - - - - - - -
- +
+ +
+
+ + + + + + + + + +
+
+ + + + dispatch(setPrompt({ name: name, id: id, type: type })) + } + setPrompts={(newPrompts) => dispatch(setPrompts(newPrompts))} + /> + +
+
+ + + + + + +
); diff --git a/frontend/src/settings/Prompts.test.tsx b/frontend/src/settings/Prompts.test.tsx index 9357c720..276d589d 100644 --- a/frontend/src/settings/Prompts.test.tsx +++ b/frontend/src/settings/Prompts.test.tsx @@ -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( 'button[role="combobox"]', )!; - const label = container.querySelector( - '[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('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('[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|$)/); }); diff --git a/frontend/src/settings/Prompts.tsx b/frontend/src/settings/Prompts.tsx index 1b43456c..a012b498 100644 --- a/frontend/src/settings/Prompts.tsx +++ b/frontend/src/settings/Prompts.tsx @@ -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', + )} > {selectedPrompt?.name || t('settings.general.promptActions.select')} @@ -396,13 +402,55 @@ export default function Prompts({ ); + const editButton = selectedPrompt?.id && selectedPrompt.type !== 'public' && ( + 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 && ( + + ); + return ( <> -
+ {titleAs === 'row' ? ( + +
+
+ {picker} + {editButton} +
+ {addButton} +
+
+ ) : (
- {titleAs === 'heading' ? ( - - ) : null} +
- {titleAs === 'label' ? ( - - {picker} - - ) : ( - picker - )} - {selectedPrompt?.id && selectedPrompt.type !== 'public' && ( - openEditModal(selectedPrompt)} - label={t('settings.general.promptActions.edit')} - icon={Pencil} - /> - )} + {picker} + {editButton}
- {showAddButton && ( - - )} + {addButton}
-
+ )} {triggerIcon} @@ -117,7 +118,7 @@ export default function TeamSwitcher({ ) : ( DocsGPT @@ -150,19 +151,22 @@ export default function TeamSwitcher({ ) : ( - + {/* sidebar-item rows carry no right padding, so the chevron's + margin keeps it clear of the pill's end. */} + + )}