mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
Real UI fixes
This commit is contained in:
1 parent
f66dc50eed
commit
c4b1b2767f
27 files changed
+524
-210
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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を編集",
|
||||
|
||||
@@ -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": "Вики отредактирована",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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|$)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user