diff --git a/AGENTS.md b/AGENTS.md
index b66a1990..18b34db6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -122,10 +122,10 @@ React 19.2 + TypeScript 5.9 on Vite 7 with the React Compiler enabled
(babel-plugin-react-compiler via @vitejs/plugin-react - do not add manual
memoization for performance; the compiler owns it), function components and
hooks throughout.
-Two legacy `.jsx` files remain (`src/main.jsx`, `src/contexts/ThemeContext.jsx`); everything else
+One legacy `.jsx` file remains (`src/main.jsx`); everything else
is `.ts`/`.tsx`. Routing is react-router-dom 7.
-- **The UI primitives are owned, not installed.** `src/shared/view/ui/` holds 19
+- **The UI primitives are owned, not installed.** `src/shared/view/ui/` holds 18
shadcn-shaped components (Button, Dialog, Collapsible, Command, Tooltip,
ScrollArea, ActionMenu, ...) written in this repo. **There is no Radix
dependency.** Reaching for one to get a primitive that already exists here is
diff --git a/DESIGN.md b/DESIGN.md
index 38a7ac1f..de8cd863 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -27,19 +27,19 @@ All new product colors must route through semantic CSS variables in `src/index.c
| Card text | `--card-foreground` | `36 25% 4%` | `40 8% 93%` | Text on card surfaces |
| Popover surface | `--popover` / `bg-popover` | `0 0% 100%` | `0 0% 12%` | Dialogs, command result modal, popovers |
| Popover text | `--popover-foreground` | `36 25% 4%` | `40 8% 93%` | Text on popover surfaces |
-| Primary action | `--primary` / `bg-primary` | `14 89% 52%` | `16 90% 57%` | Primary buttons, send button, focus ring, selected accents |
-| Primary action text | `--primary-foreground` | `210 40% 98%` | `0 0% 8%` | Text/icons on primary action surfaces |
+| Primary action | `--primary` / `bg-primary` | `14 89% 41%` | `16 90% 57%` | Primary buttons, send button, focus ring, selected accents. Light is the deep orange: at 52% neither orange text on the page nor white text on an orange button reached 4.5:1 |
+| Primary action text | `--primary-foreground` | `0 0% 100%` | `0 0% 8%` | Text/icons on primary action surfaces |
| Secondary surface | `--secondary` / `bg-secondary` | `44 15% 91%` | `0 0% 17%` | Secondary buttons, muted controls |
| Secondary text | `--secondary-foreground` | `36 15% 18%` | `40 8% 93%` | Text on secondary surfaces |
| Muted surface | `--muted` / `bg-muted` | `44 15% 91%` | `0 0% 17%` | Empty states, pills, rows, placeholder surfaces |
-| Muted text | `--muted-foreground` | `40 5% 44%` | `0 0% 60%` | Captions, timestamps, hints, secondary metadata |
+| Muted text | `--muted-foreground` | `40 5% 40%` | `0 0% 60%` | Captions, timestamps, hints, secondary metadata |
| Accent surface | `--accent` / `bg-accent` | `44 15% 91%` | `0 0% 17%` | Hover rows, ghost-button hover, selected command item |
| Accent text | `--accent-foreground` | `36 15% 18%` | `40 8% 93%` | Text on accent surfaces |
-| Destructive | `--destructive` / `text-destructive` | `0 84.2% 60.2%` | `354 100% 65%` | Error text, destructive alerts, delete affordances when tokenized. Dark is the GJC TUI's `dangerRed` (`#ff4d5e`): the shadcn maroon read as text on a dark surface failed contrast |
-| Destructive text | `--destructive-foreground` | `210 40% 98%` | `0 0% 8%` | Text/icons on destructive surfaces; dark on the light dark-mode red, as primary does |
+| Destructive | `--destructive` / `text-destructive` | `0 72% 45%` | `354 100% 65%` | Error text, destructive alerts, delete affordances when tokenized. Dark is the GJC TUI's `dangerRed` (`#ff4d5e`): the shadcn maroon read as text on a dark surface failed contrast |
+| Destructive text | `--destructive-foreground` | `0 0% 100%` | `0 0% 8%` | Text/icons on destructive surfaces; dark on the light dark-mode red, as primary does |
| Border | `--border` / `border-border` | `44 14% 87%` | `0 0% 17%` | Dividers, card outlines, default borders |
| Input border | `--input` / `border-input` | `44 14% 87%` | `0 0% 23%` | Inputs and outline buttons |
-| Focus ring | `--ring` / `ring-ring` | `14 89% 52%` | `16 90% 57%` | Focus rings and checkbox focus outline |
+| Focus ring | `--ring` / `ring-ring` | `14 89% 41%` | `16 90% 57%` | Focus rings and checkbox focus outline |
### Navigation and Mobile Tokens
@@ -55,6 +55,8 @@ All new product colors must route through semantic CSS variables in `src/index.c
### Rules
- Prefer semantic HSL variables through Tailwind classes: `bg-background`, `text-muted-foreground`, `border-border`, `bg-primary`, `text-destructive`.
+- Theme is `System` (default, follows `prefers-color-scheme` live), `Light` or `Dark`; only an explicit choice is stored (`localStorage.theme`).
+- Native controls use `accent-color: hsl(var(--primary))`; placeholders use `--muted-foreground` in both themes.
- Use raw Tailwind status colors only for existing status semantics that have not been tokenized yet: emerald/green running, amber/yellow attention, and red destructive rows.
- New persistent colors require a named token in this section before use.
@@ -75,7 +77,7 @@ All new product colors must route through semantic CSS variables in `src/index.c
| Section/card title | `text-base` or `text-sm` | `font-medium` / `font-semibold` | default or tight | Empty states and card titles |
| Body | `text-sm` | `font-normal` | default / `leading-6` in composer | Main row labels, chat input, messages |
| Secondary | `text-xs` | regular/medium/semibold by context | relaxed for snippets, uppercase tracking for labels | Metadata, counts, timestamps |
-| Micro | `text-[0.6875rem]` (11px), `text-[10px]`, `text-[9px]`, `text-[8px]`, `text-[7px]` | regular to semibold | uppercase/tracking on provider/status micro-labels | Dense sidebar badges, command hints, running counts |
+| Micro | `text-[0.6875rem]` (11px), `text-[10px]` | regular to semibold | tabular numerals for counts | Dense sidebar badges, command hints, running counts. 10px is the floor: nothing renders smaller |
| Code/content | `font-mono text-xs`, prose code | regular | wraps aggressively in chat | Paths, JSON, code, and terminal output |
### Rules
@@ -84,6 +86,8 @@ All new product colors must route through semantic CSS variables in `src/index.c
- Mobile form fields use at least 16px when needed to avoid iOS zoom, as seen in mobile edit/select overrides.
- Preserve Pretendard loading before `index.css` in `src/main.jsx`.
- Use `truncate`, `break-words`, `whitespace-pre-wrap`, and `overflow-wrap` patterns for long paths, prompts, URLs, and chat messages.
+- Never hyphenate or `break-all`: prose wraps at word boundaries, and code, commands and ids use `wrap-anywhere` so a token breaks only when it alone is wider than the line.
+- A status-line separator (` · `) that opens a flex item uses `StatusSeparator` (`whitespace-pre`); plain leading spaces are dropped there.
## 4. Spacing & Layout
@@ -193,7 +197,7 @@ The system uses Tailwind's 4px spacing scale. Existing values like `p-2`, `gap-2
- **Structure**: desktop and mobile share the same Codex-aligned hierarchy: product wordmark and global search, one `New task` action, an inline filter field (`h-8`, `bg-muted/60`, `type="search"`; `/` focuses it from outside any text field, Escape clears it) that narrows the tree by conversation title and message body while force-expanding matching projects, then independently collapsible `Projects` and `Work` sections. Project rows are not duplicated as session containers; Work owns the latest-first session list and identifies each row's project in secondary text.
- **Surface**: `bg-background`, borderless list rows, and restrained tonal hover/selected states.
-- **States**: selected `bg-primary/5 border-primary/20`, starred yellow tint, destructive red actions. Each session row carries one derived status (`src/stores/sessionStatusModel.ts`): `running` shows the muted spinner in the age slot; `needs_input` a pulsing `bg-primary` leading dot plus a `text-primary` alert glyph; `blocked` a `bg-destructive` dot plus a `text-destructive` warning glyph; `ready` (finished, not yet opened) a solid `bg-primary` dot with the age left in place. Every indicator has `role="status"` and a translated `aria-label`. The Work heading shows non-zero per-state counts and project rows a `bg-primary/10` (or `bg-destructive/10` when a run failed) count of sessions that need a look; zero counts are never rendered.
+- **States**: selected `bg-primary/5 border-primary/20`, starred yellow tint, destructive red actions. Each session row carries one derived status (`src/stores/sessionStatusModel.ts`): `running` shows the muted spinner in the age slot; `needs_input` a pulsing `bg-primary` leading dot (6px, inside the row's leading padding, never hung off the edge) plus a `text-primary` alert glyph; `blocked` a `bg-destructive` dot plus a `text-destructive` warning glyph; `ready` (finished, not yet opened) a solid `bg-primary` dot with the age left in place. Every indicator has `role="status"` and a translated `aria-label`. The Work heading shows non-zero per-state counts and project rows a `bg-primary/10` (or `bg-destructive/10` when a run failed) count of sessions that need a look; zero counts are never rendered, and project rows carry no total count.
- **Layout**: scroll ownership stays in `ScrollArea`; the wordmark/search header, primary action, and utility footer remain fixed. Archive recovery, refresh, issue reporting, community, and version remain compact footer utilities.
### Sidebar Primary Navigation
@@ -239,11 +243,10 @@ The system uses Tailwind's 4px spacing scale. Existing values like `p-2`, `gap-2
| Type | Duration / Easing | Usage |
|------|-------------------|-------|
-| Instant reset | `transition: none` base on `*` before scoped rules | Prevent inherited accidental transitions |
+| Instant reset | `transition: none` base on `*` before scoped rules | Structure (rows, panes, theme changes) repaints at once, as a native app does |
| Micro hover | 100ms | Hover shortening for buttons, anchors, role buttons |
| Active press | 50ms | Active-state tap feedback |
-| Standard controls | 150ms `cubic-bezier(0.4, 0, 0.2, 1)` | Buttons, transforms, focus outline/ring, modal show |
-| Theme color | 200ms ease-in-out | Background, border, color transitions for non-interactive structural elements |
+| Standard controls | 150ms `cubic-bezier(0.4, 0, 0.2, 1)` | Colour, border, shadow and opacity of buttons, links and fields; modal show |
| Modal/dropdown | 200ms ease-in-out / standard bezier | Modal opacity/transform transitions |
| Sidebar overlay | 150ms ease-out mobile, 300ms sidebar utility | Mobile drawer and sidebar transitions |
| Message | 300ms | Chat message affordances |
@@ -298,7 +301,7 @@ The current strategy is mixed but restrained: borders and tonal shifts for defau
| Item | Location | Why accepted | Owner / Exit |
|------|----------|--------------|--------------|
-| Legacy raw RGB/Tailwind colors for dark form controls, placeholders, status badges, and sidebar semantic states | `src/index.css`, sidebar rows | Existing product UI already relies on these colors; this task documents the system without changing visible styling. | Tokenize status/form colors in a dedicated visual consolidation pass. |
+| Raw Tailwind status colors (emerald success, amber star) | `CommandResultModal`, `OAuthLoginDialog`, sidebar project rows | Form controls, placeholders and tool rows are tokenized; success and favourite states have no token yet. | Add `--success`/`--warning` tokens and route these through them. |
| Tooltip lacks full keyboard/focus tooltip semantics | `src/shared/view/ui/Tooltip.tsx` | Current tooltip supports hover, touch long press, and outside dismissal; changing behavior would alter product interaction. | Add focus-triggered display and ARIA association in a focused accessibility pass. |
| Some dense sidebar action controls are smaller than 44px | `src/components/sidebar/view/subcomponents/*` | Desktop density is central to the command-center feel; mobile rows provide larger surrounding hit areas for primary actions. | Audit primary mobile controls during visual QA and expand hit areas where actions are frequent or destructive. |
| No standalone primitive showcase exists yet | Project root / shared UI docs | Focused render harnesses and real-app breakpoint QA cover the sidebar tab primitive, but the project does not yet have a shared Storybook-style surface. | Expand the existing state harness into a reusable shared-primitive showcase during the next design-system consolidation pass. |
diff --git a/docs/plans/frontend-refactor.md b/docs/plans/frontend-refactor.md
index 0e9aec94..3a8341d2 100644
--- a/docs/plans/frontend-refactor.md
+++ b/docs/plans/frontend-refactor.md
@@ -341,8 +341,9 @@ the full gate green at each commit.
- ~~`dompurify` and `rehype-raw` are declared in `package.json` and imported
nowhere.~~ Removed, together with the equally unimported `chokidar`.
-- Two `.jsx` files remain: `src/main.jsx`, `src/contexts/ThemeContext.jsx`,
- plus `src/i18n/*.js`. (`src/hooks/useLocalStorage.jsx` was removed as dead code.)
+- One `.jsx` file remains: `src/main.jsx`, plus `src/i18n/*.js`.
+ (`src/hooks/useLocalStorage.jsx` was removed as dead code;
+ `src/contexts/ThemeContext` is now `.tsx`.)
## Acceptance criteria
diff --git a/server/gjc-session-state.test.ts b/server/gjc-session-state.test.ts
index e3b0b3ee..33a46b9e 100644
--- a/server/gjc-session-state.test.ts
+++ b/server/gjc-session-state.test.ts
@@ -38,6 +38,17 @@ test('reads model, reasoning level, cwd and context in one pass', () => {
});
});
+test('the model is reported as provider/model, the key the app catalog uses', () => {
+ const qualified = readSessionSnapshot(session({ model: { id: 'claude-opus-5-5', provider: 'anthropic' } }), manager());
+ assert.equal(qualified?.modelId, 'anthropic/claude-opus-5-5');
+
+ // Already qualified, or no provider to qualify with: left as reported.
+ const already = readSessionSnapshot(session({ model: { id: 'anthropic/claude-opus-5-5', provider: 'anthropic' } }), manager());
+ assert.equal(already?.modelId, 'anthropic/claude-opus-5-5');
+ const bare = readSessionSnapshot(session({ model: { id: 'gpt-test', provider: '' } }), manager());
+ assert.equal(bare?.modelId, 'gpt-test');
+});
+
test('the percentage the session reports is preferred over recomputing it', () => {
// The session applies its own reserve accounting; second-guessing it here
// would put a different number in the footer than /context reports.
diff --git a/server/gjc-session-state.ts b/server/gjc-session-state.ts
index a39d6010..bf6d15bb 100644
--- a/server/gjc-session-state.ts
+++ b/server/gjc-session-state.ts
@@ -16,6 +16,11 @@ import { normalizeGjcGoalState, type GjcGoalSnapshot } from '../shared/gjc-goal.
export type GjcSessionSnapshot = {
goal?: GjcGoalSnapshot;
+ /**
+ * `provider/model`, the same selector the app's model catalog is keyed by.
+ * The bare SDK id matched no catalog entry, so the composer fell back to
+ * showing it raw (`claude-opus-5-5` where the picker says `Opus 5.5`).
+ */
modelId?: string;
/** Reasoning effort, as the session reports it (`off`, `low`, `high`, ...). */
thinkingLevel?: string;
@@ -40,7 +45,7 @@ export type GjcSessionSnapshot = {
};
type SessionLike = {
- model?: { id?: unknown } | null;
+ model?: { id?: unknown; provider?: unknown } | null;
thinkingLevel?: unknown;
serviceTier?: unknown;
getContextUsage?: () => unknown;
@@ -84,7 +89,8 @@ export function readSessionSnapshot(
try {
const live = session as SessionLike | null;
const modelId = text(live?.model?.id);
- if (modelId) snapshot.modelId = modelId;
+ const provider = text(live?.model?.provider);
+ if (modelId) snapshot.modelId = provider && !modelId.startsWith(`${provider}/`) ? `${provider}/${modelId}` : modelId;
const thinkingLevel = text(live?.thinkingLevel);
if (thinkingLevel) snapshot.thinkingLevel = thinkingLevel;
diff --git a/src/components/chat/OAuthLoginDialog.tsx b/src/components/chat/OAuthLoginDialog.tsx
index 8d5aa72c..f42f340e 100644
--- a/src/components/chat/OAuthLoginDialog.tsx
+++ b/src/components/chat/OAuthLoginDialog.tsx
@@ -241,7 +241,7 @@ function OAuthLoginDialog({
href={authorizationUrl}
target="_blank"
rel="noreferrer noopener"
- className="block text-xs break-all text-primary underline underline-offset-4"
+ className="block text-xs wrap-anywhere text-primary underline underline-offset-4"
>
{authorizationUrl}
diff --git a/src/components/chat/tools/components/BashCommandDisplay.tsx b/src/components/chat/tools/components/BashCommandDisplay.tsx
index 27289caf..a08bed0c 100644
--- a/src/components/chat/tools/components/BashCommandDisplay.tsx
+++ b/src/components/chat/tools/components/BashCommandDisplay.tsx
@@ -60,7 +60,7 @@ function CommandRow({ command, copied, hasOutput, isRunning, lineCount, onCopy,
/>
)}
$
-
+
{command}
{!open && hasOutput && !isRunning && (
@@ -145,7 +145,7 @@ export const BashCommandDisplay: React.FC = ({
Output
-
+
{text}
diff --git a/src/components/chat/tools/components/OneLineDisplay.tsx b/src/components/chat/tools/components/OneLineDisplay.tsx
index 289a83a3..38d9dc53 100644
--- a/src/components/chat/tools/components/OneLineDisplay.tsx
+++ b/src/components/chat/tools/components/OneLineDisplay.tsx
@@ -65,7 +65,7 @@ export const OneLineDisplay: React.FC = ({
}) => {
const [copied, setCopied] = useState(false);
const displayLabel = label || toolName;
- const valueClass = wrapText ? 'break-all whitespace-pre-wrap' : 'truncate';
+ const valueClass = wrapText ? 'wrap-anywhere whitespace-pre-wrap' : 'truncate';
const runAction = async () => {
if (action === 'copy' && value) {
if (!await copyTextToClipboard(value)) return;
@@ -78,7 +78,7 @@ export const OneLineDisplay: React.FC = ({
const copyButton = action === 'copy' ? : null;
if (style === 'terminal') {
- const commandClass = wrapText ? 'break-all whitespace-pre-wrap' : 'block truncate';
+ const commandClass = wrapText ? 'wrap-anywhere whitespace-pre-wrap' : 'block truncate';
return (
diff --git a/src/components/chat/tools/components/ToolCallRow.tsx b/src/components/chat/tools/components/ToolCallRow.tsx
index 319535f4..4c453a69 100644
--- a/src/components/chat/tools/components/ToolCallRow.tsx
+++ b/src/components/chat/tools/components/ToolCallRow.tsx
@@ -66,7 +66,7 @@ export const ToolCallRow: React.FC
= ({
)}
>
diff --git a/src/components/chat/tools/configs/toolConfigs.test.ts b/src/components/chat/tools/configs/toolConfigs.test.ts
index 46ea8d07..fb42ab24 100644
--- a/src/components/chat/tools/configs/toolConfigs.test.ts
+++ b/src/components/chat/tools/configs/toolConfigs.test.ts
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { TOOL_CONFIGS, getToolConfig, getToolResultConfig, rendersCommandRow, rendersResultInline, shouldHideToolResult } from './toolConfigs';
+import { TOOL_CONFIGS, getToolConfig, getToolResultConfig, rendersCommandRow, rendersResultInline, shouldHideToolResult, toolDisplayName } from './toolConfigs';
/*
* These configs are the app's half of a contract with the runtime: the keys are
@@ -176,3 +176,20 @@ test('an edit whose result carries no details keeps the replace-mode fallback',
assert.equal(props.oldContent, 'x');
assert.equal(titleOf('edit', input), 'a.ts', 'the input path names the card whatever the result says');
});
+
+test('a read row drops the raw mode flag but keeps the line range it read', () => {
+ const value = (path: string) => TOOL_CONFIGS.read.input.getValue?.({ path });
+ assert.equal(value('tidepool/cli.py:raw'), 'tidepool/cli.py');
+ assert.equal(value('tides.csv:1-3'), 'tides.csv:1-3');
+ assert.equal(value('cli.py:2-4:raw'), 'cli.py:2-4');
+ assert.equal(value('cli.py:raw:2-4'), 'cli.py:2-4');
+ // Only the whole `raw` selector goes; a file named like it stays intact.
+ assert.equal(value('notes/rawdata.txt'), 'notes/rawdata.txt');
+ assert.equal(value('a:rawer'), 'a:rawer');
+});
+
+test('group labels read as words, not wire names', () => {
+ assert.equal(toolDisplayName('edit'), 'Edit');
+ assert.equal(toolDisplayName('apply_patch'), 'Apply patch');
+ assert.equal(toolDisplayName('Bash'), 'Bash');
+});
diff --git a/src/components/chat/tools/configs/toolConfigs.ts b/src/components/chat/tools/configs/toolConfigs.ts
index 1e26f4f4..4db37efa 100644
--- a/src/components/chat/tools/configs/toolConfigs.ts
+++ b/src/components/chat/tools/configs/toolConfigs.ts
@@ -12,9 +12,24 @@ export interface ToolDisplayConfig {
type TodoOp = { op?: string; list?: { phase?: string; items?: string[] }[]; task?: string; phase?: string; items?: string[]; text?: string };
-const neutralColors = { primary: 'text-gray-700 dark:text-gray-300', secondary: 'text-gray-500 dark:text-gray-400', background: '', border: 'border-gray-400 dark:border-gray-500', icon: 'text-gray-500 dark:text-gray-400' };
-const readColors = { primary: 'text-gray-700 dark:text-gray-300', background: '', border: 'border-gray-300 dark:border-gray-600', icon: 'text-gray-500 dark:text-gray-400' };
-const skillColors = { primary: 'text-blue-600 dark:text-blue-400 font-medium', secondary: 'text-gray-500 dark:text-gray-400', background: '', border: 'border-blue-400 dark:border-blue-500', icon: 'text-blue-500 dark:text-blue-400' };
+// Tool rows draw from the app palette; raw gray and blue read as a second,
+// foreign palette beside the tokenized transcript.
+const neutralColors = { primary: 'text-foreground/80', secondary: 'text-muted-foreground', background: '', border: 'border-border', icon: 'text-muted-foreground' };
+const readColors = { primary: 'text-foreground/80', background: '', border: 'border-border', icon: 'text-muted-foreground' };
+const skillColors = { primary: 'text-primary font-medium', secondary: 'text-muted-foreground', background: '', border: 'border-primary/40', icon: 'text-primary' };
+
+/**
+ * The read tool's path carries an optional selector (`file:raw`, `file:2-4`,
+ * `file:2-4:raw`). Line ranges say what was read and stay; `raw` is a mode
+ * flag for the model, not something the person reading the transcript needs.
+ */
+export const readTarget = (path: unknown): string => String(path || '').replace(/:raw(?=$|:)/, '');
+
+/** `edit` -> `Edit`, `apply_patch` -> `Apply patch`: a label, never a wire name. */
+export function toolDisplayName(toolName: string): string {
+ const words = toolName.replace(/_/g, ' ').trim();
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : toolName;
+}
const planInput = { type: 'plan' as const, title: 'Implementation plan', defaultOpen: true, contentType: 'markdown' as const, getContentProps: (input: any) => ({ content: input.plan?.replace(/\\n/g, '\n') || input.plan }) };
function todoEntries(ops: unknown): TodoOp[] { return Array.isArray(ops) ? ops as TodoOp[] : []; }
@@ -112,7 +127,7 @@ const questions: ToolDisplayConfig = {
export const TOOL_CONFIGS: Record = {
bash: { input: { type: 'hidden' }, result: { type: 'collapsible', contentType: 'text', getContentProps: outputAsCode } },
- read: { input: { type: 'one-line', label: 'Read', getValue: (input) => input.path || '', action: 'open-file', colorScheme: readColors }, result: { hidden: true } },
+ read: { input: { type: 'one-line', label: 'Read', getValue: (input) => readTarget(input.path), action: 'open-file', colorScheme: readColors }, result: { hidden: true } },
write: { input: { type: 'collapsible', title: leafName, defaultOpen: false, contentType: 'text', actionButton: 'file-button', getContentProps: (input) => ({ content: input.content ?? '', format: 'code' }) }, result: { hideOnSuccess: true } },
search: { input: { type: 'one-line', label: 'Search', getValue: (input) => input.pattern || '', getSecondary: (input) => { const paths = Array.isArray(input.paths) ? input.paths : []; return paths.length ? `in ${paths.join(', ')}` : undefined; }, action: 'none', colorScheme: neutralColors } },
find: callOnly('Find', (input) => Array.isArray(input.paths) ? input.paths.join(', ') : ''),
diff --git a/src/components/chat/tools/index.ts b/src/components/chat/tools/index.ts
index c2030518..c73187a3 100644
--- a/src/components/chat/tools/index.ts
+++ b/src/components/chat/tools/index.ts
@@ -1,3 +1,3 @@
export * from './components';
-export { getToolConfig, getToolResultConfig, rendersCommandRow, rendersResultInline, shouldHideToolResult } from './configs/toolConfigs';
+export { getToolConfig, getToolResultConfig, rendersCommandRow, rendersResultInline, shouldHideToolResult, toolDisplayName } from './configs/toolConfigs';
export { ToolRenderer } from './ToolRenderer';
diff --git a/src/components/chat/utils/permissionMode.ts b/src/components/chat/utils/permissionMode.ts
index d4a6f7be..ecaf2a99 100644
--- a/src/components/chat/utils/permissionMode.ts
+++ b/src/components/chat/utils/permissionMode.ts
@@ -1,6 +1,7 @@
import { ShieldHalf, ShieldOff, ShieldQuestion, type LucideIcon } from 'lucide-react';
import { PERMISSION_MODES, type PermissionMode } from '../../../hooks/useProjectPermissions';
+import { modShortcutLabel } from '../../../utils/shortcutLabel';
export { PERMISSION_MODES };
export type { PermissionMode };
@@ -12,11 +13,8 @@ export const PERMISSION_MODE_ICONS: Record = {
bypass: ShieldOff,
};
-const isApplePlatform = () =>
- typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || '');
-
/** The chord as the keyboard in front of the user labels it. */
-export const permissionModeShortcutLabel = (): string => (isApplePlatform() ? '⌘⇧P' : 'Ctrl+Shift+P');
+export const permissionModeShortcutLabel = (): string => modShortcutLabel('P', { shift: true });
/**
* The composer shortcut: Cmd/Ctrl+Shift+P ("permissions"). It opens the
diff --git a/src/components/chat/view/CommandResultModal.tsx b/src/components/chat/view/CommandResultModal.tsx
index 06093772..ef6d5b10 100644
--- a/src/components/chat/view/CommandResultModal.tsx
+++ b/src/components/chat/view/CommandResultModal.tsx
@@ -120,7 +120,7 @@ function MetricCard({
{label}
-
{value}
+
{value}
);
}
@@ -308,7 +308,7 @@ function ModelsContent({
Active model · {providerLabel}
- {currentModel}
+ {currentModel}
{pendingSessionModel && pendingSessionModel !== currentModel && (
→ {pendingSessionModel} next
@@ -358,7 +358,7 @@ function ModelsContent({
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
>
- {option.value}
+ {option.value}
{isCurrent ? (
) : isChanging ? (
@@ -470,7 +470,7 @@ function CostContent({ data }: { data: CostCommandData }) {
Model
-
{model}
+
{model}
diff --git a/src/components/chat/view/PermissionModePicker.tsx b/src/components/chat/view/PermissionModePicker.tsx
index ec9bfa3d..ed80fa9f 100644
--- a/src/components/chat/view/PermissionModePicker.tsx
+++ b/src/components/chat/view/PermissionModePicker.tsx
@@ -120,14 +120,14 @@ export default function PermissionModePicker({ permissions, onSelectMode, busy =
onClick={() => setOpen((current) => !current)}
disabled={unavailable || isBusy}
data-mode={mode}
- className="flex h-8 w-full max-w-40 min-w-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
+ className="flex h-8 max-w-full min-w-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
aria-label={t('permissionMode.label')}
aria-expanded={open}
aria-haspopup="listbox"
title={t('permissionMode.tooltip', { mode: label, shortcut })}
>
{isBusy ? : }
- {label}
+ {label}
diff --git a/src/components/chat/view/RunningActivityRow.tsx b/src/components/chat/view/RunningActivityRow.tsx
index d12ca069..6b30e026 100644
--- a/src/components/chat/view/RunningActivityRow.tsx
+++ b/src/components/chat/view/RunningActivityRow.tsx
@@ -7,6 +7,8 @@ import { formatElapsed } from '../utils/elapsed';
import { formatLiveActivity, phaseActivity } from '../utils/toolActivity';
import type { LiveActivity } from '../utils/toolActivity';
+import StatusSeparator from './StatusSeparator';
+
interface RunningActivityRowProps {
/** What the run is doing now; `Thinking` when nothing is derived yet. */
liveActivity?: LiveActivity | null;
@@ -16,8 +18,6 @@ interface RunningActivityRowProps {
variant?: 'pending-block' | 'inline';
}
-const SEPARATOR = ' · ';
-
/**
* One line for a run that has no work block to speak for it: the live turn
* before its first tool call (`Thinking… · 3s`), and every live turn at
@@ -40,21 +40,24 @@ export default function RunningActivityRow({ liveActivity, runStartedAt = null,
-
- {`${label}…`}
- {callLabel && (
-
- {SEPARATOR}
- {callLabel}
+ {/* One gapless group, so every separator is spaced by its own text. */}
+
+
+ {`${label}…`}
+ {callLabel && (
+
+
+ {callLabel}
+
+ )}
+
+ {runStartedAt !== null && (
+
+
+ {formatElapsed(elapsedSeconds, t)}
)}
- {runStartedAt !== null && (
-
- {SEPARATOR}
- {formatElapsed(elapsedSeconds, t)}
-
- )}
);
diff --git a/src/components/chat/view/SkillPicker.dom.bun.test.tsx b/src/components/chat/view/SkillPicker.dom.bun.test.tsx
new file mode 100644
index 00000000..17ff990b
--- /dev/null
+++ b/src/components/chat/view/SkillPicker.dom.bun.test.tsx
@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+
+import { createElement } from 'react';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+
+import '../../../i18n/config';
+
+import SkillPicker from './SkillPicker';
+
+const skills = [
+ { name: '/skill:ralplan', description: 'Plan with consensus' },
+ { name: '/skill:ultragoal', description: 'Track a durable goal' },
+];
+
+function mount() {
+ const picked: string[] = [];
+ render(createElement(SkillPicker, { skills, onSelect: (skill) => { picked.push(skill.name); } }));
+ return { picked, trigger: screen.getByRole('button', { name: 'Choose a skill' }) };
+}
+
+afterEach(cleanup);
+
+test('Escape closes the open popup and returns focus to its trigger', () => {
+ const { trigger } = mount();
+ fireEvent.click(trigger);
+ assert.ok(screen.getByRole('textbox', { name: 'Search skills' }));
+ assert.equal(trigger.getAttribute('aria-expanded'), 'true');
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ assert.equal(screen.queryByRole('textbox', { name: 'Search skills' }), null);
+ assert.equal(trigger.getAttribute('aria-expanded'), 'false');
+ assert.equal(document.activeElement, trigger);
+});
+
+test('an Escape another handler already consumed leaves the popup open', () => {
+ const { trigger } = mount();
+ fireEvent.click(trigger);
+ const consume = (event: KeyboardEvent) => event.preventDefault();
+ document.addEventListener('keydown', consume, { capture: true });
+ try {
+ fireEvent.keyDown(document, { key: 'Escape' });
+ } finally {
+ document.removeEventListener('keydown', consume, { capture: true });
+ }
+ assert.ok(screen.getByRole('textbox', { name: 'Search skills' }));
+});
+
+test('the count on the trigger is the number of available skills', () => {
+ const { trigger } = mount();
+ assert.equal(trigger.textContent, '2');
+});
diff --git a/src/components/chat/view/SkillPicker.tsx b/src/components/chat/view/SkillPicker.tsx
index 0db47494..e2861cd7 100644
--- a/src/components/chat/view/SkillPicker.tsx
+++ b/src/components/chat/view/SkillPicker.tsx
@@ -28,6 +28,7 @@ export default function SkillPicker({ skills, onSelect }: SkillPickerProps) {
const rootRef = useRef(null);
const popupRef = useRef(null);
const searchRef = useRef(null);
+ const triggerRef = useRef(null);
const [popupPosition, setPopupPosition] = useState<{ bottom: number; left: number; maxHeight?: number }>({ bottom: 0, left: 0 });
// The composer form clips its children (overflow-hidden rounded corners), so
@@ -52,10 +53,20 @@ export default function SkillPicker({ skills, onSelect }: SkillPickerProps) {
const target = event.target as Node;
if (!rootRef.current?.contains(target) && !popupRef.current?.contains(target)) setOpen(false);
};
+ // Escape dismisses the popup and hands focus back to its trigger, the
+ // way the model and permission pickers beside it already do.
+ const closeForEscape = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
+ event.preventDefault();
+ setOpen(false);
+ triggerRef.current?.focus();
+ };
document.addEventListener('mousedown', close);
+ document.addEventListener('keydown', closeForEscape);
return () => {
window.removeEventListener('resize', updatePosition);
document.removeEventListener('mousedown', close);
+ document.removeEventListener('keydown', closeForEscape);
};
}, [open]);
@@ -70,6 +81,7 @@ export default function SkillPicker({ skills, onSelect }: SkillPickerProps) {
return (
setOpen((current) => !current)}
disabled={skills.length === 0}
@@ -79,8 +91,10 @@ export default function SkillPicker({ skills, onSelect }: SkillPickerProps) {
title={t('input.skills.label')}
>
+ {/* How many skills there are, not something waiting to be read: a
+ quiet count, not the primary-colored unread badge it used to be. */}
{skills.length > 0 && (
-
+
{skills.length}
)}
diff --git a/src/components/chat/view/StatusSeparator.tsx b/src/components/chat/view/StatusSeparator.tsx
new file mode 100644
index 00000000..c39bce2c
--- /dev/null
+++ b/src/components/chat/view/StatusSeparator.tsx
@@ -0,0 +1,8 @@
+/**
+ * The ` · ` between the parts of a status line. `whitespace-pre` keeps its
+ * spaces when it opens a flex item, where ordinary leading whitespace is
+ * dropped and the line read "Thinking…· Reading".
+ */
+export default function StatusSeparator() {
+ return {' · '} ;
+}
diff --git a/src/components/chat/view/ToolGroupContainer.tsx b/src/components/chat/view/ToolGroupContainer.tsx
index 1da0b817..20b41209 100644
--- a/src/components/chat/view/ToolGroupContainer.tsx
+++ b/src/components/chat/view/ToolGroupContainer.tsx
@@ -9,7 +9,7 @@ import { hasFailedResult } from '../utils/toolGrouping';
import type { ToolGroupItem } from '../utils/toolGrouping';
import { toolOutputDensityRules } from '../utils/toolOutputDensity';
import type { ToolOutputDensity } from '../utils/toolOutputDensity';
-import { getToolConfig, rendersCommandRow } from '../tools';
+import { getToolConfig, rendersCommandRow, toolDisplayName } from '../tools';
import MessageComponent from './MessageComponent';
@@ -57,12 +57,14 @@ function getToolInputPreview(message: ChatMessage): string {
return String(value || command || title || message.displayText || message.content || '').trim();
}
-function getToolGroupIcon(icon: string | undefined, toolName: string): string {
- if (icon === 'terminal' || rendersCommandRow(toolName)) {
- return '$';
- }
-
- return icon || toolName.slice(0, 1).toUpperCase();
+/**
+ * Command groups keep the prompt sigil their single rows show. Other tools get
+ * no glyph: the first letter of the label ("R Read", "E Edit") only repeated
+ * the word beside it.
+ */
+function getToolGroupIcon(icon: string | undefined, toolName: string): string | null {
+ if (icon === 'terminal' || rendersCommandRow(toolName)) return '$';
+ return icon || null;
}
export default function ToolGroupContainer({
@@ -88,7 +90,7 @@ export default function ToolGroupContainer({
}, [opensForFailure]);
const { t } = useTranslation('chat');
const config = getToolConfig(group.toolName).input;
- const label = config.label || group.toolName;
+ const label = config.label || toolDisplayName(group.toolName);
const iconClass = config.colorScheme?.icon || 'text-muted-foreground';
const icon = getToolGroupIcon(config.icon, group.toolName);
@@ -100,13 +102,11 @@ export default function ToolGroupContainer({
const extraCount = group.messages.length - visiblePreviews.length;
const previewText = visiblePreviews.join(', ');
+ const more = extraCount > 0 ? t('tools.more', { count: extraCount }) : '';
- if (!previewText) {
- return extraCount > 0 ? `+${extraCount} more` : '';
- }
-
- return extraCount > 0 ? `${previewText}, +${extraCount} more` : previewText;
- }, [group.messages]);
+ if (!previewText) return more;
+ return more ? `${previewText}, ${more}` : previewText;
+ }, [group.messages, t]);
return (
@@ -123,7 +123,7 @@ export default function ToolGroupContainer({
className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-90' : ''}`}
aria-hidden
/>
-
{icon}
+ {icon &&
{icon} }
{label}
{group.messages.length > 1 && (
diff --git a/src/components/chat/view/ToolOutputDensityToggle.tsx b/src/components/chat/view/ToolOutputDensityToggle.tsx
index 94a16874..2bf00fdc 100644
--- a/src/components/chat/view/ToolOutputDensityToggle.tsx
+++ b/src/components/chat/view/ToolOutputDensityToggle.tsx
@@ -3,15 +3,13 @@ import { useTranslation } from 'react-i18next';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { Tooltip } from '../../../shared/view/ui';
+import { modShortcutLabel } from '../../../utils/shortcutLabel';
import { cyclesToolOutputDensity, nextToolOutputDensity } from '../utils/toolOutputDensity';
import { TOOL_OUTPUT_DENSITY_ICONS } from './ToolOutputDensityPicker';
-const isApplePlatform = () =>
- typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || '');
-
/** The chord as the keyboard in front of the user labels it. */
-export const toolOutputDensityShortcutLabel = (): string => (isApplePlatform() ? '⌘⇧D' : 'Ctrl+Shift+D');
+export const toolOutputDensityShortcutLabel = (): string => modShortcutLabel('D', { shift: true });
/**
* One icon button in the chat header that walks compact -> balanced ->
diff --git a/src/components/chat/view/TurnWorkBlock.tsx b/src/components/chat/view/TurnWorkBlock.tsx
index 1c0b11be..ec790014 100644
--- a/src/components/chat/view/TurnWorkBlock.tsx
+++ b/src/components/chat/view/TurnWorkBlock.tsx
@@ -16,6 +16,7 @@ import type { TurnWorkBlockItem } from '../utils/turnWork';
import GroupedMessageList from './GroupedMessageList';
import type { MessageRenderProps } from './GroupedMessageList';
import RunningActivityRow from './RunningActivityRow';
+import StatusSeparator from './StatusSeparator';
interface TurnWorkBlockProps extends MessageRenderProps {
block: TurnWorkBlockItem;
@@ -115,37 +116,40 @@ function FoldedTurnWork({ block, prevMessage, running = false, liveActivity, run
{running ? (
<>
-
- {`${runningLabel}…`}
- {callLabel && (
-
- {SEPARATOR}
- {callLabel}
+ {/* One gapless group, so every separator is spaced by its own text. */}
+
+
+ {`${runningLabel}…`}
+ {callLabel && (
+
+
+ {callLabel}
+
+ )}
+
+ {runStartedAt !== null && (
+
+
+ {formatElapsed(elapsedSeconds, t)}
)}
- {runStartedAt !== null && (
-
- {SEPARATOR}
- {formatElapsed(elapsedSeconds, t)}
-
- )}
>
) : (
- <>
+
{finishedLeading}
{finishedDetail.length > 0 && (
- {SEPARATOR}
+
{finishedDetail.join(SEPARATOR)}
)}
- >
+
)}
{summary.failed > 0 && (
{t('tools.error')}
- {SEPARATOR}
+
{t('workBlock.failed', { count: summary.failed, defaultValue: '{{count}} failed' })}
)}
diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts
index a3d44fa9..8ee2c0db 100644
--- a/src/components/main-content/types/types.ts
+++ b/src/components/main-content/types/types.ts
@@ -13,7 +13,7 @@ export interface MainContentProps {
export interface MainContentHeaderProps {
activeTab: AppTab; isMobile: boolean; selectedProject: Project; selectedSession: ProjectSession | null; sidebarOpen: boolean;
- onMenuClick: () => void; onToggleSidebar: () => void; setActiveTab: TabSetter;
+ onMenuClick: () => void; onToggleSidebar: () => void;
}
export interface MainContentStateViewProps { isMobile: boolean; mode: 'loading' | 'empty'; onMenuClick: () => void; onNewSession: (project: Project) => void; }
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx
index 66323e65..24ea8b97 100644
--- a/src/components/main-content/view/MainContent.tsx
+++ b/src/components/main-content/view/MainContent.tsx
@@ -128,7 +128,6 @@ function MainContent({
(null);
- const [canScrollLeft, setCanScrollLeft] = useState(false);
- const [canScrollRight, setCanScrollRight] = useState(false);
-
- const updateScrollState = useCallback(() => {
- const el = scrollRef.current;
- if (!el) return;
- setCanScrollLeft(el.scrollLeft > 2);
- setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 2);
- }, []);
-
- useEffect(() => {
- const el = scrollRef.current;
- if (!el) return;
- updateScrollState();
- const observer = new ResizeObserver(updateScrollState);
- observer.observe(el);
- return () => observer.disconnect();
- }, [updateScrollState]);
return (
// The bar spans the full row, but its title lines up with the conversation
// below it, which the chat lane insets by `pl-2` to clear the sidebar rail.
@@ -54,25 +32,9 @@ export default function MainContentHeader({
/>
-
-
- {canScrollLeft && (
-
- )}
-
-
-
- {canScrollRight && (
-
- )}
-
+ {/* Chat is the only view, so there is no view switcher: a one-tab
+ segmented control read as a mode the user could leave. */}
+
{activeTab === 'chat' &&
}
undefined,
selectedProject,
selectedSession: null,
isMobile: false,
diff --git a/src/components/main-content/view/MainContentTabSwitcher.tsx b/src/components/main-content/view/MainContentTabSwitcher.tsx
deleted file mode 100644
index 80a53d77..00000000
--- a/src/components/main-content/view/MainContentTabSwitcher.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { MessageSquare, type LucideIcon } from 'lucide-react';
-import type { Dispatch, SetStateAction } from 'react';
-import { useTranslation } from 'react-i18next';
-
-import { Tooltip, PillBar, Pill } from '../../../shared/view/ui';
-import type { AppTab } from '../../../types/app';
-
-type MainContentTabSwitcherProps = {
- activeTab: AppTab;
- setActiveTab: Dispatch>;
-};
-
-type BuiltInTab = {
- kind: 'builtin';
- id: AppTab;
- labelKey: string;
- icon: LucideIcon;
-};
-
-type TabDefinition = BuiltInTab;
-
-// Shell/Git/Files tabs were removed on purpose: app-pty CLI spawning duplicated
-// the external CLI terminal lane (and tied CLIs to the server lifecycle — one
-// died on service restart), and a manual git UI clashes with the agent-driven
-// branch-flow discipline. File browsing, the git panel and the embedded editor
-// were retired with them; files now open in the user's own editor. Persisted
-// 'shell'/'git'/'files' tabs are reset to 'chat' in MainContent.
-const BASE_TABS: BuiltInTab[] = [
- { kind: 'builtin', id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
-];
-
-export default function MainContentTabSwitcher({
- activeTab,
- setActiveTab,
-}: MainContentTabSwitcherProps) {
- const { t } = useTranslation();
-
- const tabs: TabDefinition[] = [...BASE_TABS];
-
- return (
-
- {tabs.map((tab) => {
- const isActive = tab.id === activeTab;
- const displayLabel = t(tab.labelKey);
-
- return (
-
- setActiveTab(tab.id)}
- className="px-2.5 py-[5px]"
- >
-
- {displayLabel}
-
-
- );
- })}
-
- );
-}
diff --git a/src/components/settings/view/ThemePicker.dom.bun.test.tsx b/src/components/settings/view/ThemePicker.dom.bun.test.tsx
new file mode 100644
index 00000000..78368528
--- /dev/null
+++ b/src/components/settings/view/ThemePicker.dom.bun.test.tsx
@@ -0,0 +1,96 @@
+import assert from 'node:assert/strict';
+import { afterEach, beforeEach, test } from 'node:test';
+
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { createInstance } from 'i18next';
+import { I18nextProvider } from 'react-i18next';
+
+import { ThemeProvider, useTheme } from '../../../contexts/ThemeContext';
+import english from '../../../i18n/locales/en/settings.json';
+
+import ThemePicker from './ThemePicker';
+
+const i18n = createInstance();
+await i18n.init({ lng: 'en', resources: { en: { settings: english } } });
+
+/** A controllable `prefers-color-scheme: dark` query standing in for the OS setting. */
+function fakeSystemAppearance(initiallyDark: boolean) {
+ const listeners = new Set<(event: { matches: boolean }) => void>();
+ const query = {
+ matches: initiallyDark,
+ addEventListener: (_type: string, listener: (event: { matches: boolean }) => void) => listeners.add(listener),
+ removeEventListener: (_type: string, listener: (event: { matches: boolean }) => void) => listeners.delete(listener),
+ };
+ window.matchMedia = (() => query) as unknown as typeof window.matchMedia;
+ return (dark: boolean) => act(() => {
+ query.matches = dark;
+ for (const listener of listeners) listener({ matches: dark });
+ });
+}
+
+const originalMatchMedia = window.matchMedia;
+
+function Toggle() {
+ const { toggleDarkMode } = useTheme();
+ return Toggle theme ;
+}
+
+function mount() {
+ render( );
+}
+
+const isDark = () => document.documentElement.classList.contains('dark');
+const checked = () => screen.getAllByRole('radio').filter((radio) => radio.getAttribute('aria-checked') === 'true').map((radio) => radio.textContent);
+
+beforeEach(() => localStorage.clear());
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ window.matchMedia = originalMatchMedia;
+ document.documentElement.classList.remove('dark');
+});
+
+test('with no choice made, the app follows the system appearance live and stores nothing', () => {
+ const setSystemDark = fakeSystemAppearance(true);
+ mount();
+ assert.deepEqual(checked(), ['System']);
+ assert.equal(isDark(), true);
+
+ setSystemDark(false);
+ assert.equal(isDark(), false);
+ setSystemDark(true);
+ assert.equal(isDark(), true);
+ assert.equal(localStorage.getItem('theme'), null, 'a derived theme must never be persisted as a choice');
+});
+
+test('choosing Light pins it against the system; choosing System again follows the system', () => {
+ const setSystemDark = fakeSystemAppearance(true);
+ mount();
+
+ fireEvent.click(screen.getByRole('radio', { name: 'Light' }));
+ assert.equal(isDark(), false);
+ assert.equal(localStorage.getItem('theme'), 'light');
+ setSystemDark(true);
+ assert.equal(isDark(), false, 'an explicit choice ignores the system');
+
+ fireEvent.click(screen.getByRole('radio', { name: 'System' }));
+ assert.equal(localStorage.getItem('theme'), null);
+ assert.equal(isDark(), true);
+});
+
+test('a stored choice is restored on launch', () => {
+ fakeSystemAppearance(false);
+ localStorage.setItem('theme', 'dark');
+ mount();
+ assert.deepEqual(checked(), ['Dark']);
+ assert.equal(isDark(), true);
+});
+
+test('the quick toggle from System pins the opposite of what is on screen', () => {
+ fakeSystemAppearance(true);
+ mount();
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle theme' }));
+ assert.deepEqual(checked(), ['Light']);
+ assert.equal(localStorage.getItem('theme'), 'light');
+ assert.equal(isDark(), false);
+});
diff --git a/src/components/settings/view/ThemePicker.tsx b/src/components/settings/view/ThemePicker.tsx
new file mode 100644
index 00000000..c6c5c6d3
--- /dev/null
+++ b/src/components/settings/view/ThemePicker.tsx
@@ -0,0 +1,38 @@
+import { Monitor, Moon, Sun, type LucideIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import { THEME_PREFERENCES, useTheme, type ThemePreference } from '../../../contexts/ThemeContext';
+import { Pill, PillBar } from '../../../shared/view/ui';
+
+const THEME_ICONS: Record = { system: Monitor, light: Sun, dark: Moon };
+
+/**
+ * System / Light / Dark, the way the OS offers it. A light-dark switch had no
+ * way back to following the system once flipped.
+ */
+export default function ThemePicker() {
+ const { t } = useTranslation('settings');
+ const { preference, setPreference } = useTheme();
+
+ return (
+
+ {THEME_PREFERENCES.map((option) => {
+ const Icon = THEME_ICONS[option];
+ const isActive = option === preference;
+ return (
+ setPreference(option)}
+ className="px-2.5 py-1.5 text-xs"
+ >
+
+ {t(`appearanceSettings.theme.${option}`)}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx
index ee799031..9197fdf9 100644
--- a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx
+++ b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useUiPreferences } from '../../../../hooks/useUiPreferences';
-import { DarkModeToggle } from '../../../../shared/view/ui';
import LanguageSelector from '../../../../shared/view/ui/LanguageSelector';
import ToolOutputDensityPicker from '../../../chat/view/ToolOutputDensityPicker';
import type { InterfaceFontSize, ProjectSortOrder } from '../../types/types';
@@ -9,6 +8,7 @@ import SettingsCard from '../SettingsCard';
import SettingsRow from '../SettingsRow';
import SettingsSection from '../SettingsSection';
import SettingsToggle from '../SettingsToggle';
+import ThemePicker from '../ThemePicker';
type AppearanceSettingsTabProps = {
projectSortOrder: ProjectSortOrder;
@@ -35,10 +35,14 @@ export default function AppearanceSettingsTab(input: AppearanceSettingsTabProps)
return (
-
+
-
-
+
+
-
+
diff --git a/src/components/sidebar/view/ProviderQuotaRing.tsx b/src/components/sidebar/view/ProviderQuotaRing.tsx
index 9817e347..c6045f46 100644
--- a/src/components/sidebar/view/ProviderQuotaRing.tsx
+++ b/src/components/sidebar/view/ProviderQuotaRing.tsx
@@ -17,10 +17,10 @@ const STROKE = 2;
const RADIUS = (SIZE - STROKE) / 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
/**
- * A dashed track separates "this provider reports no quota" from "this quota is
- * spent". Both draw no arc, so without a difference in the track itself an
- * unsupported provider would read as an empty one — and the difference has to
- * be in the shape, because colour alone cannot carry it.
+ * A dashed track separates "no reading right now" (error, re-auth) from "this
+ * quota is spent". Both draw no arc, so without a difference in the track
+ * itself a failed read would look like an empty quota - and the difference has
+ * to be in the shape, because colour alone cannot carry it.
*/
const TRACK_DASH = `${(CIRCUMFERENCE / 16).toFixed(2)} ${(CIRCUMFERENCE / 16).toFixed(2)}`;
@@ -35,9 +35,11 @@ type ProviderQuotaRingProps = {
function ProviderGlyph({ entry }: { entry: ProviderQuotaEntry }) {
const logoId = quotaProviderLogoId(entry.provider);
if (logoId) return ;
+ // One initial at a legible size; two letters at 8px did not fit the ring
+ // at any size a person can read.
return (
-
- {entry.providerName.slice(0, 2).toUpperCase()}
+
+ {entry.providerName.slice(0, 1).toUpperCase()}
);
}
@@ -45,7 +47,6 @@ function ProviderGlyph({ entry }: { entry: ProviderQuotaEntry }) {
function StatusNote({ entry, t }: { entry: ProviderQuotaEntry; t: TFunction }) {
if (entry.status === 'reauth') return {t('providerQuota.reauth')} ;
if (entry.status === 'error') return {t('providerQuota.error')} ;
- if (entry.status === 'unsupported') return {t('providerQuota.unsupported')} ;
if (entry.stale) return {t('providerQuota.stale')} ;
return null;
}
@@ -55,7 +56,7 @@ function StatusNote({ entry, t }: { entry: ProviderQuotaEntry; t: TFunction }) {
* whose arc is the remaining share of the window that runs out first.
*
* The arc length carries the meaning; colour only emphasises an almost-spent
- * quota. Loading, unsupported, re-auth and error all render at the same size
+ * quota. Loading, re-auth and error all render at the same size
* with no arc, so the footer never shifts and no percentage is invented.
*/
export default function ProviderQuotaRing({ entry, now = Date.now(), tooltipPosition = 'top', t }: ProviderQuotaRingProps) {
diff --git a/src/components/sidebar/view/SidebarArchiveContent.tsx b/src/components/sidebar/view/SidebarArchiveContent.tsx
index 6cabe539..da73a400 100644
--- a/src/components/sidebar/view/SidebarArchiveContent.tsx
+++ b/src/components/sidebar/view/SidebarArchiveContent.tsx
@@ -297,7 +297,7 @@ export default function SidebarArchiveContent({
{project.displayName}
-
+
{t('archived.projectArchived', 'Project archived')}
@@ -322,7 +322,7 @@ export default function SidebarArchiveContent({
{group.projectDisplayName}
{group.isProjectArchived && (
-
+
{t('archived.projectArchived', 'Project archived')}
)}
diff --git a/src/components/sidebar/view/SidebarHeader.tsx b/src/components/sidebar/view/SidebarHeader.tsx
index 385a3e2e..248a8e5a 100644
--- a/src/components/sidebar/view/SidebarHeader.tsx
+++ b/src/components/sidebar/view/SidebarHeader.tsx
@@ -3,6 +3,7 @@ import type { TFunction } from 'i18next';
import { BRAND_NAME } from '../../../constants/branding';
import { Button } from '../../../shared/view/ui';
+import { modShortcutLabel } from '../../../utils/shortcutLabel';
type SidebarHeaderProps = {
isPWA: boolean;
@@ -50,7 +51,7 @@ export default function SidebarHeader({
className="size-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/70 hover:text-foreground"
onClick={onSearch}
aria-label={searchLabel}
- title={`${searchLabel} (Ctrl+K)`}
+ title={`${searchLabel} (${modShortcutLabel('K')})`}
>
diff --git a/src/components/sidebar/view/SidebarProjectItem.tsx b/src/components/sidebar/view/SidebarProjectItem.tsx
index 55c06977..111e5e95 100644
--- a/src/components/sidebar/view/SidebarProjectItem.tsx
+++ b/src/components/sidebar/view/SidebarProjectItem.tsx
@@ -97,7 +97,6 @@ export default function SidebarProjectItem({
}: SidebarProjectItemProps) {
const isSelected = selectedProject?.projectId === project.projectId;
const isEditing = editingProject === project.projectId;
- const sessionCount = Number(project.sessionMeta?.total ?? sessions.length);
const statuses = sessions.map((session) => getSessionStatus(session.id));
const attentionCount = statuses.filter(needsAttention).length;
const hasBlocked = statuses.includes('blocked');
@@ -161,7 +160,6 @@ export default function SidebarProjectItem({
{attentionCount}
)}
- {sessionCount}
{showSessions && (
)}
diff --git a/src/components/sidebar/view/SidebarProviderQuota.dom.bun.test.tsx b/src/components/sidebar/view/SidebarProviderQuota.dom.bun.test.tsx
index b8058b86..b386f40b 100644
--- a/src/components/sidebar/view/SidebarProviderQuota.dom.bun.test.tsx
+++ b/src/components/sidebar/view/SidebarProviderQuota.dom.bun.test.tsx
@@ -2,8 +2,8 @@
* Rendering contract for the sidebar quota row.
*
* These assertions are about what a person can actually see: the arc length is
- * the lowest remaining window, a provider that cannot report a quota never
- * draws one, one provider's failure never blanks its peers, and the footer does
+ * the lowest remaining window, a provider that cannot report a quota gets no
+ * indicator, one provider's failure never blanks its peers, and the footer does
* not gain a section when there is nothing to show.
*/
@@ -123,17 +123,34 @@ test('the accessible name still states the quota for assistive technology', asyn
await waitFor(() => assert.ok(screen.getByLabelText('Claude: 41% of quota left')));
});
-test('an unsupported provider draws no arc and never a fabricated percentage', async () => {
+test('a provider that reports no quota gets no indicator at all', async () => {
+ stubQuota([
+ entry({ provider: 'zai', providerName: 'Z.ai', status: 'unsupported', reason: 'usage_unsupported', windows: [] }),
+ entry(),
+ ]);
+ renderRow();
+
+ await waitFor(() => assert.equal(remainingOf('anthropic'), 41));
+ assert.equal(screen.queryByLabelText('Z.ai'), null, 'an empty ring for a provider with nothing to report is noise');
+ assert.equal(rings().length, 1);
+});
+
+test('when no provider reports quota, the row is not rendered', async () => {
stubQuota([entry({ provider: 'zai', providerName: 'Z.ai', status: 'unsupported', reason: 'usage_unsupported', windows: [] })]);
+ const view = renderRow();
+
+ // The first-fetch placeholder goes away and nothing takes its place.
+ await waitFor(() => assert.equal(requestedUrls.length, 1));
+ await waitFor(() => assert.equal(view.container.innerHTML, ''));
+});
+
+test('a failed read draws a dashed track, so it cannot be read as a spent quota', async () => {
+ stubQuota([entry({ status: 'error', reason: 'fetch_failed', windows: [] })]);
renderRow();
- await waitFor(() => assert.ok(screen.getByLabelText('Z.ai')));
- assert.equal(arc('zai'), null, 'an unsupported provider must not draw a quota it does not have');
- assert.ok(
- track('zai')?.getAttribute('stroke-dasharray'),
- 'a dashed track separates "no quota reported" from "quota spent", which also draws no arc',
- );
- assert.equal(rings().length, 1, 'the indicator still occupies its slot');
+ await waitFor(() => assert.ok(track('anthropic')));
+ assert.equal(arc('anthropic'), null, 'a failed read must not draw a quota it does not have');
+ assert.ok(track('anthropic')?.getAttribute('stroke-dasharray'));
});
test('a spent quota keeps a solid track, so it cannot be read as "no data"', async () => {
diff --git a/src/components/sidebar/view/SidebarProviderQuota.tsx b/src/components/sidebar/view/SidebarProviderQuota.tsx
index 52012905..8b414ee5 100644
--- a/src/components/sidebar/view/SidebarProviderQuota.tsx
+++ b/src/components/sidebar/view/SidebarProviderQuota.tsx
@@ -21,13 +21,17 @@ type SidebarProviderQuotaProps = {
* rather than growing the footer.
*/
function ProviderQuotaRow({ collapsed, t }: Required> & { t: TFunction }) {
- const { providers, isLoading, hasFailed } = useProviderQuota();
+ const { providers: reported, isLoading, hasFailed } = useProviderQuota();
+ // A provider that does not report quota has nothing to show here. A row of
+ // empty dashed rings with two-letter initials read as noise, not as "no
+ // data"; error and re-auth stay, because the user can act on them.
+ const providers = reported.filter((entry) => entry.status !== 'unsupported');
if (providers.length === 0) {
- // Nothing connected, or the snapshot could not be read: render no section
- // at all rather than an empty one. The first fetch keeps a fixed-size
+ // Nothing connected or reporting, or the snapshot could not be read:
+ // render no section at all rather than an empty one. The first fetch keeps a fixed-size
// placeholder so the footer does not shift when the rings arrive.
- if (!isLoading || hasFailed) return null;
+ if (!isLoading || hasFailed || reported.length > 0) return null;
return (
diff --git a/src/components/sidebar/view/SidebarSessionItem.tsx b/src/components/sidebar/view/SidebarSessionItem.tsx
index 0b096b79..99ce03fb 100644
--- a/src/components/sidebar/view/SidebarSessionItem.tsx
+++ b/src/components/sidebar/view/SidebarSessionItem.tsx
@@ -272,7 +272,6 @@ export default function SidebarSessionItem({
if (isEditing) {
return (
-
+
{
- const storedPreference = localStorage.getItem('theme');
- if (storedPreference) return storedPreference === 'dark';
- return Boolean(window.matchMedia?.(mediaQuery).matches);
-};
-
-const updateThemeMetadata = (dark) => {
- const root = document.documentElement;
- root.classList.toggle('dark', dark);
- localStorage.setItem('theme', dark ? 'dark' : 'light');
-
- const statusBar = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
- if (statusBar) statusBar.setAttribute('content', dark ? 'black-translucent' : 'default');
-
- const themeColor = document.querySelector('meta[name="theme-color"]');
- if (themeColor) themeColor.setAttribute('content', dark ? '#141414' : '#f6f4ef');
-};
-
-export const useTheme = () => {
- const theme = useContext(ThemeContext);
- if (!theme) {
- throw new Error('useTheme must be used within a ThemeProvider');
- }
- return theme;
-};
-
-export const ThemeProvider = ({ children }) => {
- const [isDarkMode, setIsDarkMode] = useState(initialDarkMode);
-
- useEffect(() => {
- updateThemeMetadata(isDarkMode);
- }, [isDarkMode]);
-
- useEffect(() => {
- if (!window.matchMedia) return;
- const preference = window.matchMedia(mediaQuery);
- const followSystemPreference = ({ matches }) => {
- if (!localStorage.getItem('theme')) setIsDarkMode(matches);
- };
- preference.addEventListener('change', followSystemPreference);
- return () => preference.removeEventListener('change', followSystemPreference);
- }, []);
-
- const toggleDarkMode = () => setIsDarkMode((current) => !current);
-
- return (
-
- {children}
-
- );
-};
diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx
new file mode 100644
index 00000000..069ec0a4
--- /dev/null
+++ b/src/contexts/ThemeContext.tsx
@@ -0,0 +1,81 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
+
+const mediaQuery = '(prefers-color-scheme: dark)';
+const STORAGE_KEY = 'theme';
+
+export const THEME_PREFERENCES = ['system', 'light', 'dark'] as const;
+export type ThemePreference = (typeof THEME_PREFERENCES)[number];
+
+type ThemeContextValue = {
+ /** What is on screen right now. */
+ isDarkMode: boolean;
+ /** What the user chose; `system` follows the OS appearance live. */
+ preference: ThemePreference;
+ setPreference: (next: ThemePreference) => void;
+ /** Flips what is on screen; from System that pins the opposite theme. */
+ toggleDarkMode: () => void;
+};
+
+const ThemeContext = createContext(null);
+
+/**
+ * An explicit choice is stored; following the system is the absence of one.
+ * Only a user's choice writes the key - the provider never persists what it
+ * merely derived, or the first launch would pin the system theme forever.
+ */
+const readPreference = (): ThemePreference => {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ return stored === 'light' || stored === 'dark' ? stored : 'system';
+};
+
+const systemPrefersDark = (): boolean => Boolean(window.matchMedia?.(mediaQuery).matches);
+
+const applyTheme = (dark: boolean) => {
+ document.documentElement.classList.toggle('dark', dark);
+
+ const statusBar = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
+ if (statusBar) statusBar.setAttribute('content', dark ? 'black-translucent' : 'default');
+
+ const themeColor = document.querySelector('meta[name="theme-color"]');
+ if (themeColor) themeColor.setAttribute('content', dark ? '#141414' : '#f6f4ef');
+};
+
+export const useTheme = (): ThemeContextValue => {
+ const theme = useContext(ThemeContext);
+ if (!theme) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return theme;
+};
+
+export const ThemeProvider = ({ children }: { children: ReactNode }) => {
+ const [preference, setPreferenceState] = useState(readPreference);
+ const [systemIsDark, setSystemIsDark] = useState(systemPrefersDark);
+ const isDarkMode = preference === 'system' ? systemIsDark : preference === 'dark';
+
+ useEffect(() => {
+ applyTheme(isDarkMode);
+ }, [isDarkMode]);
+
+ useEffect(() => {
+ if (!window.matchMedia) return undefined;
+ const query = window.matchMedia(mediaQuery);
+ const follow = ({ matches }: MediaQueryListEvent) => setSystemIsDark(matches);
+ query.addEventListener('change', follow);
+ return () => query.removeEventListener('change', follow);
+ }, []);
+
+ const setPreference = (next: ThemePreference) => {
+ if (next === 'system') localStorage.removeItem(STORAGE_KEY);
+ else localStorage.setItem(STORAGE_KEY, next);
+ setPreferenceState(next);
+ };
+
+ const toggleDarkMode = () => setPreference(isDarkMode ? 'light' : 'dark');
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json
index 873577be..94586351 100644
--- a/src/i18n/locales/de/chat.json
+++ b/src/i18n/locales/de/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "Werkzeugproblem",
- "rawParams": "Rohparameter"
+ "rawParams": "Rohparameter",
+ "more": "+{{count}} weitere"
},
"search": {
"in": "in:"
diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json
index 65a70894..4284827a 100644
--- a/src/i18n/locales/de/settings.json
+++ b/src/i18n/locales/de/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "Dunkles Thema",
- "description": "Wechseln Sie zwischen hellen und dunklen Themen"
+ "theme": {
+ "label": "Design",
+ "description": "Dem Erscheinungsbild des Systems folgen oder ein Design festlegen",
+ "system": "System",
+ "light": "Hell",
+ "dark": "Dunkel"
},
"interfaceFontSize": {
"label": "Textgröße der Benutzeroberfläche",
diff --git a/src/i18n/locales/de/sidebar.json b/src/i18n/locales/de/sidebar.json
index 47024774..39f5fe0f 100644
--- a/src/i18n/locales/de/sidebar.json
+++ b/src/i18n/locales/de/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "Seitenleiste einklappen",
"createProject": "Fügen Sie ein Projekt hinzu",
"createSession": "Beginnen Sie ein Gespräch",
- "refresh": "Projekte und Konversationen neu laden (Ctrl+R)",
- "renameProject": "Arbeitsbereich umbenennen (F2)",
+ "refresh": "Projekte und Konversationen neu laden",
+ "renameProject": "Arbeitsbereich umbenennen",
"archiveProject": "Dieses Projekt ins Archiv verschieben (nichts wird gelöscht)",
"addToFavorites": "Als Favorit markieren",
"removeFromFavorites": "Favoritenmarkierung entfernen",
@@ -104,7 +104,6 @@
"accounts_one": "{{count}} Konto",
"accounts_other": "{{count}} Konten",
"noWindowData": "Keine Werte gemeldet",
- "unsupported": "Verbrauch für diesen Anbieter nicht verfügbar",
"reauth": "Erneut anmelden, um den Verbrauch zu lesen",
"error": "Verbrauch konnte nicht aktualisiert werden",
"stale": "Zuletzt bekannte Werte"
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json
index 85801e7d..06145700 100644
--- a/src/i18n/locales/en/chat.json
+++ b/src/i18n/locales/en/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "Tool problem",
- "rawParams": "raw params"
+ "rawParams": "raw params",
+ "more": "+{{count}} more"
},
"search": {
"in": "in:"
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 5b673d72..fdb04efe 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -225,9 +225,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "Dark theme",
- "description": "Switch between light and dark themes"
+ "theme": {
+ "label": "Theme",
+ "description": "Follow the system appearance or keep one theme",
+ "system": "System",
+ "light": "Light",
+ "dark": "Dark"
},
"interfaceFontSize": {
"label": "Interface text size",
diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json
index d459abd1..51155888 100644
--- a/src/i18n/locales/en/sidebar.json
+++ b/src/i18n/locales/en/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "Collapse sidebar",
"createProject": "Add a project",
"createSession": "Start a conversation",
- "refresh": "Reload projects and conversations (Ctrl+R)",
- "renameProject": "Rename workspace (F2)",
+ "refresh": "Reload projects and conversations",
+ "renameProject": "Rename workspace",
"archiveProject": "Move this project to the archive (nothing is deleted)",
"addToFavorites": "Mark as favourite",
"removeFromFavorites": "Remove favourite mark",
@@ -104,7 +104,6 @@
"accounts_one": "{{count}} account",
"accounts_other": "{{count}} accounts",
"noWindowData": "No figures reported",
- "unsupported": "Usage unavailable for this provider",
"reauth": "Sign in again to read usage",
"error": "Usage could not be refreshed",
"stale": "Last known figures"
diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json
index 325ec78a..463ffb2b 100644
--- a/src/i18n/locales/fr/chat.json
+++ b/src/i18n/locales/fr/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "Problème d'outil",
- "rawParams": "paramètres bruts"
+ "rawParams": "paramètres bruts",
+ "more": "+{{count}} de plus"
},
"search": {
"in": "dans :"
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index ae36ec44..36a17cbb 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "Thème sombre",
- "description": "Basculer entre les thèmes clairs et sombres"
+ "theme": {
+ "label": "Thème",
+ "description": "Suivre l'apparence du système ou conserver un thème",
+ "system": "Système",
+ "light": "Clair",
+ "dark": "Sombre"
},
"interfaceFontSize": {
"label": "Taille du texte de l'interface",
diff --git a/src/i18n/locales/fr/sidebar.json b/src/i18n/locales/fr/sidebar.json
index 8d43657a..b585d8a4 100644
--- a/src/i18n/locales/fr/sidebar.json
+++ b/src/i18n/locales/fr/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "Réduire la barre latérale",
"createProject": "Ajouter un projet",
"createSession": "Démarrer une conversation",
- "refresh": "Recharger les projets et les conversations (Ctrl+R)",
- "renameProject": "Renommer l'espace de travail (F2)",
+ "refresh": "Recharger les projets et les conversations",
+ "renameProject": "Renommer l'espace de travail",
"archiveProject": "Déplacer ce projet vers les archives (rien n'est supprimé)",
"addToFavorites": "Marquer comme favori",
"removeFromFavorites": "Supprimer la marque favorite",
@@ -104,7 +104,6 @@
"accounts_one": "{{count}} compte",
"accounts_other": "{{count}} comptes",
"noWindowData": "Aucune valeur communiquée",
- "unsupported": "Consommation indisponible pour ce fournisseur",
"reauth": "Reconnectez-vous pour lire la consommation",
"error": "Impossible d’actualiser la consommation",
"stale": "Dernières valeurs connues"
diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json
index 84bb0dd4..0f6b94eb 100644
--- a/src/i18n/locales/it/chat.json
+++ b/src/i18n/locales/it/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "Problema con lo strumento",
- "rawParams": "parametri grezzi"
+ "rawParams": "parametri grezzi",
+ "more": "+{{count}} altri"
},
"search": {
"in": "in:"
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index dd669518..ae72b1f6 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "Tema oscuro",
- "description": "Passa dal tema chiaro a quello scuro e viceversa"
+ "theme": {
+ "label": "Tema",
+ "description": "Segui l'aspetto del sistema o mantieni un tema",
+ "system": "Sistema",
+ "light": "Chiaro",
+ "dark": "Scuro"
},
"interfaceFontSize": {
"label": "Dimensione del testo dell'interfaccia",
diff --git a/src/i18n/locales/it/sidebar.json b/src/i18n/locales/it/sidebar.json
index 403a5c10..ed7dd1fd 100644
--- a/src/i18n/locales/it/sidebar.json
+++ b/src/i18n/locales/it/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "Comprimi la barra laterale",
"createProject": "Aggiungi un progetto",
"createSession": "Inizia una conversazione",
- "refresh": "Ricarica progetti e conversazioni (Ctrl+R)",
- "renameProject": "Rinomina area di lavoro (F2)",
+ "refresh": "Ricarica progetti e conversazioni",
+ "renameProject": "Rinomina area di lavoro",
"archiveProject": "Sposta questo progetto nell'archivio (non viene eliminato nulla)",
"addToFavorites": "Segna come preferito",
"removeFromFavorites": "Rimuovi il contrassegno preferito",
@@ -104,7 +104,6 @@
"accounts_one": "{{count}} account",
"accounts_other": "{{count}} account",
"noWindowData": "Nessun valore riportato",
- "unsupported": "Utilizzo non disponibile per questo provider",
"reauth": "Accedi di nuovo per leggere l’utilizzo",
"error": "Impossibile aggiornare l’utilizzo",
"stale": "Ultimi valori noti"
diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json
index db4abe18..8e47230b 100644
--- a/src/i18n/locales/ja/chat.json
+++ b/src/i18n/locales/ja/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "ツールエラー",
- "rawParams": "生パラメータ"
+ "rawParams": "生パラメータ",
+ "more": "ほか{{count}}件"
},
"search": {
"in": "場所:"
diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json
index 96b5a22b..918149e0 100644
--- a/src/i18n/locales/ja/settings.json
+++ b/src/i18n/locales/ja/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "ダークモード",
- "description": "ライトテーマとダークテーマを切り替えます"
+ "theme": {
+ "label": "テーマ",
+ "description": "システムの外観に合わせるか、テーマを固定します",
+ "system": "システム",
+ "light": "ライト",
+ "dark": "ダーク"
},
"interfaceFontSize": {
"label": "インターフェースの文字サイズ",
diff --git a/src/i18n/locales/ja/sidebar.json b/src/i18n/locales/ja/sidebar.json
index cc44c92f..95d47961 100644
--- a/src/i18n/locales/ja/sidebar.json
+++ b/src/i18n/locales/ja/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "サイドバーを隠す",
"createProject": "プロジェクトを追加",
"createSession": "会話を開始",
- "refresh": "プロジェクトと会話を再読み込み (Ctrl+R)",
- "renameProject": "プロジェクト名を変更 (F2)",
+ "refresh": "プロジェクトと会話を再読み込み",
+ "renameProject": "プロジェクト名を変更",
"archiveProject": "このプロジェクトをアーカイブへ移動 (削除はされません)",
"addToFavorites": "お気に入りに追加",
"removeFromFavorites": "お気に入りから削除",
@@ -104,7 +104,6 @@
"accounts_one": "{{count}} アカウント",
"accounts_other": "{{count}} アカウント",
"noWindowData": "数値の報告なし",
- "unsupported": "このプロバイダーの使用量は取得できません",
"reauth": "使用量を読むには再度サインインしてください",
"error": "使用量を更新できませんでした",
"stale": "最後に取得した数値"
diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json
index 11f0902c..7d14f221 100644
--- a/src/i18n/locales/ko/chat.json
+++ b/src/i18n/locales/ko/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "도구 문제",
- "rawParams": "원본 매개변수"
+ "rawParams": "원본 매개변수",
+ "more": "외 {{count}}개"
},
"search": {
"in": "에서:"
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index acbc52d7..51a281c2 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "다크 테마",
- "description": "라이트와 다크 테마를 변경합니다"
+ "theme": {
+ "label": "테마",
+ "description": "시스템 설정을 따르거나 하나의 테마로 고정합니다",
+ "system": "시스템",
+ "light": "라이트",
+ "dark": "다크"
},
"interfaceFontSize": {
"label": "인터페이스 글자 크기",
diff --git a/src/i18n/locales/ko/sidebar.json b/src/i18n/locales/ko/sidebar.json
index 0eda4d37..3da88397 100644
--- a/src/i18n/locales/ko/sidebar.json
+++ b/src/i18n/locales/ko/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "사이드바 접기",
"createProject": "프로젝트 추가",
"createSession": "대화 시작",
- "refresh": "프로젝트와 대화 새로고침 (Ctrl+R)",
- "renameProject": "워크스페이스 이름 변경 (F2)",
+ "refresh": "프로젝트와 대화 새로고침",
+ "renameProject": "워크스페이스 이름 변경",
"archiveProject": "이 프로젝트를 보관함으로 이동 (삭제되지 않음)",
"addToFavorites": "즐겨찾기 등록",
"removeFromFavorites": "즐겨찾기 해제",
@@ -104,7 +104,6 @@
"accounts_one": "계정 {{count}}개",
"accounts_other": "계정 {{count}}개",
"noWindowData": "보고된 수치 없음",
- "unsupported": "이 제공자는 사용량을 제공하지 않습니다",
"reauth": "사용량을 읽으려면 다시 로그인하세요",
"error": "사용량을 새로 고치지 못했습니다",
"stale": "마지막으로 확인된 수치"
diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json
index 56cf5c36..f6623bfc 100644
--- a/src/i18n/locales/ru/chat.json
+++ b/src/i18n/locales/ru/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "Проблема с инструментом",
- "rawParams": "исходные параметры"
+ "rawParams": "исходные параметры",
+ "more": "ещё {{count}}"
},
"search": {
"in": "в разделе:"
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index 3f004122..2c630c61 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "Темная тема",
- "description": "Переключение между светлой и темной темами"
+ "theme": {
+ "label": "Тема",
+ "description": "Следовать оформлению системы или закрепить одну тему",
+ "system": "Системная",
+ "light": "Светлая",
+ "dark": "Тёмная"
},
"interfaceFontSize": {
"label": "Размер текста интерфейса",
diff --git a/src/i18n/locales/ru/sidebar.json b/src/i18n/locales/ru/sidebar.json
index 8bb9763d..cf6312cc 100644
--- a/src/i18n/locales/ru/sidebar.json
+++ b/src/i18n/locales/ru/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "Свернуть боковую панель",
"createProject": "Добавить проект",
"createSession": "Начать разговор",
- "refresh": "Перезагрузить проекты и обсуждения (Ctrl+R).",
- "renameProject": "Переименовать рабочую область (F2)",
+ "refresh": "Перезагрузить проекты и обсуждения.",
+ "renameProject": "Переименовать рабочую область",
"archiveProject": "Переместить проект в архив (ничего не удаляется)",
"addToFavorites": "Отметить как избранное",
"removeFromFavorites": "Удалить отметку избранного",
@@ -116,7 +116,6 @@
"accounts_many": "{{count}} аккаунтов",
"accounts_other": "{{count}} аккаунтов",
"noWindowData": "Значения не переданы",
- "unsupported": "Данные о расходе для этого провайдера недоступны",
"reauth": "Войдите снова, чтобы прочитать расход",
"error": "Не удалось обновить данные о расходе",
"stale": "Последние известные значения"
diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json
index 70a6758f..5f01bab9 100644
--- a/src/i18n/locales/tr/chat.json
+++ b/src/i18n/locales/tr/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "Takım sorunu",
- "rawParams": "ham parametreler"
+ "rawParams": "ham parametreler",
+ "more": "+{{count}} tane daha"
},
"search": {
"in": "konum:"
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index c88cc04f..5f542ba0 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "Koyu tema",
- "description": "Açık ve koyu temalar arasında geçiş yapın"
+ "theme": {
+ "label": "Tema",
+ "description": "Sistem görünümünü izleyin veya tek bir temada kalın",
+ "system": "Sistem",
+ "light": "Açık",
+ "dark": "Koyu"
},
"interfaceFontSize": {
"label": "Arayüz metin boyutu",
diff --git a/src/i18n/locales/tr/sidebar.json b/src/i18n/locales/tr/sidebar.json
index 6e953eb7..0cd91c96 100644
--- a/src/i18n/locales/tr/sidebar.json
+++ b/src/i18n/locales/tr/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "Kenar çubuğunu daralt",
"createProject": "Proje ekle",
"createSession": "Konuşma başlat",
- "refresh": "Projeleri ve konuşmaları yeniden yükle (Ctrl+R)",
- "renameProject": "Çalışma alanını yeniden adlandırın (F2)",
+ "refresh": "Projeleri ve konuşmaları yeniden yükle",
+ "renameProject": "Çalışma alanını yeniden adlandırın",
"archiveProject": "Bu projeyi arşive taşı (hiçbir şey silinmez)",
"addToFavorites": "Favori olarak işaretle",
"removeFromFavorites": "Favori işaretini kaldır",
@@ -104,7 +104,6 @@
"accounts_one": "{{count}} hesap",
"accounts_other": "{{count}} hesap",
"noWindowData": "Bildirilen değer yok",
- "unsupported": "Bu sağlayıcı için kullanım bilgisi yok",
"reauth": "Kullanımı okumak için yeniden oturum açın",
"error": "Kullanım yenilenemedi",
"stale": "Son bilinen değerler"
diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json
index a19663f3..d1c210b6 100644
--- a/src/i18n/locales/zh-CN/chat.json
+++ b/src/i18n/locales/zh-CN/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "工具问题",
- "rawParams": "原始参数"
+ "rawParams": "原始参数",
+ "more": "另外 {{count}} 个"
},
"search": {
"in": "于:"
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 9f86107f..f5d23982 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "深色主题",
- "description": "在亮色和暗色主题之间切换"
+ "theme": {
+ "label": "主题",
+ "description": "跟随系统外观,或固定使用一种主题",
+ "system": "跟随系统",
+ "light": "浅色",
+ "dark": "深色"
},
"interfaceFontSize": {
"label": "界面文字大小",
diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json
index 291c2b31..bb214bc4 100644
--- a/src/i18n/locales/zh-CN/sidebar.json
+++ b/src/i18n/locales/zh-CN/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "折叠侧边栏",
"createProject": "添加一个项目",
"createSession": "开始对话",
- "refresh": "重新加载项目和对话 (Ctrl+R)",
- "renameProject": "重命名工作区 (F2)",
+ "refresh": "重新加载项目和对话",
+ "renameProject": "重命名工作区",
"archiveProject": "将该项目移入归档(不会删除任何内容)",
"addToFavorites": "标记为收藏",
"removeFromFavorites": "取消收藏标记",
@@ -104,7 +104,6 @@
"accounts_one": "{{count}} 个账户",
"accounts_other": "{{count}} 个账户",
"noWindowData": "未报告数值",
- "unsupported": "该提供方不提供用量数据",
"reauth": "重新登录后才能读取用量",
"error": "无法刷新用量",
"stale": "最后一次已知数值"
diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json
index 08044e5d..e0158e6a 100644
--- a/src/i18n/locales/zh-TW/chat.json
+++ b/src/i18n/locales/zh-TW/chat.json
@@ -44,7 +44,8 @@
},
"tools": {
"error": "工具問題",
- "rawParams": "原始參數"
+ "rawParams": "原始參數",
+ "more": "另外 {{count}} 個"
},
"search": {
"in": "於:"
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 61c34859..244a5eb7 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -210,9 +210,12 @@
}
},
"appearanceSettings": {
- "darkMode": {
- "label": "深色主題",
- "description": "切換明暗主題"
+ "theme": {
+ "label": "主題",
+ "description": "跟隨系統外觀,或固定使用一種主題",
+ "system": "跟隨系統",
+ "light": "淺色",
+ "dark": "深色"
},
"interfaceFontSize": {
"label": "介面文字大小",
diff --git a/src/i18n/locales/zh-TW/sidebar.json b/src/i18n/locales/zh-TW/sidebar.json
index 7ada28b3..81699a48 100644
--- a/src/i18n/locales/zh-TW/sidebar.json
+++ b/src/i18n/locales/zh-TW/sidebar.json
@@ -34,8 +34,8 @@
"hideSidebar": "收合側欄",
"createProject": "新增專案",
"createSession": "開始對話",
- "refresh": "重新載入專案和對話 (Ctrl+R)",
- "renameProject": "重命名工作區 (F2)",
+ "refresh": "重新載入專案和對話",
+ "renameProject": "重命名工作區",
"archiveProject": "將此專案移至封存(不會刪除任何內容)",
"addToFavorites": "標記為最愛",
"removeFromFavorites": "移除最愛標記",
@@ -104,7 +104,6 @@
"accounts_one": "{{count}} 個帳戶",
"accounts_other": "{{count}} 個帳戶",
"noWindowData": "未回報數值",
- "unsupported": "此供應方不提供用量資料",
"reauth": "請重新登入才能讀取用量",
"error": "無法重新整理用量",
"stale": "最後一次已知數值"
diff --git a/src/index.css b/src/index.css
index 190a48ee..e1a84b8b 100644
--- a/src/index.css
+++ b/src/index.css
@@ -124,15 +124,18 @@
--sidebar: 44 20% 93%;
--card: 0 0% 100%; --card-foreground: 36 25% 4%;
--popover: 0 0% 100%; --popover-foreground: 36 25% 4%;
- --primary: 14 89% 52%; --primary-foreground: 210 40% 98%;
+ /* Light primary is the deep orange: at 52% neither orange text on the
+ page (3.4:1) nor white text on an orange button (3.5:1) reached AA. At
+ 41% both clear 4.5:1, on the rail as well as the stage. */
+ --primary: 14 89% 41%; --primary-foreground: 0 0% 100%;
--chat-bubble: 14 89% 42%; --chat-bubble-foreground: 0 0% 100%;
--secondary: 44 15% 91%; --secondary-foreground: 36 15% 18%;
- --muted: 44 15% 91%; --muted-foreground: 40 5% 44%;
+ --muted: 44 15% 91%; --muted-foreground: 40 5% 40%;
--accent: 44 15% 91%; --accent-foreground: 36 15% 18%;
- --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%;
+ --destructive: 0 72% 45%; --destructive-foreground: 0 0% 100%;
--diff-added: 142 45% 92%; --diff-added-foreground: 142 60% 25%;
--diff-removed: 0 60% 94%; --diff-removed-foreground: 0 65% 38%;
- --border: 44 14% 87%; --input: 44 14% 87%; --ring: 14 89% 52%;
+ --border: 44 14% 87%; --input: 44 14% 87%; --ring: 14 89% 41%;
--radius: 0.5rem; --nav-divider-color: 44 14% 87% / 0.5;
--safe-area-inset-top: env(safe-area-inset-top); --safe-area-inset-right: env(safe-area-inset-right);
--safe-area-inset-bottom: env(safe-area-inset-bottom); --safe-area-inset-left: env(safe-area-inset-left);
@@ -194,46 +197,13 @@
body.pwa-mode .fixed.inset-0 { inset: var(--header-total-padding) var(--safe-area-inset-right) 0 var(--safe-area-inset-left); }
- button, a, input, textarea, select, [role="button"], .transition-all { transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); }
-
- body,
- div,
- section,
- article,
- aside,
- header,
- footer,
- nav,
- main,
- h1,
- h2,
- h3,
- h4,
- h5,
- h6,
- p,
- span,
- blockquote,
- ul,
- ol,
- li,
- dl,
- dt,
- dd,
- table,
- thead,
- tbody,
- tfoot,
- tr,
- td,
- th,
- form,
- fieldset,
- legend,
- label {
- transition: background-color 200ms ease-in-out,
- border-color 200ms ease-in-out,
- color 200ms ease-in-out;
+ /* Controls ease their colours; nothing else transitions by default. A
+ 200ms fade on every div and list item made selection, hover and theme
+ changes lag behind the click, where a native app repaints at once. */
+ button, a, input, textarea, select, [role="button"] {
+ transition-property: color, background-color, border-color, box-shadow, opacity;
+ transition-duration: 150ms;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.transition-transform { transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); }
@@ -301,49 +271,22 @@
scrollbar-width: thin;
scrollbar-color: rgba(115, 115, 115, 0.5) rgba(38, 38, 38, 0.5);
- & input[type='checkbox'] { color-scheme: dark; color: rgb(37 99 235); border-color: rgb(75 85 99); background-color: rgb(31 41 55); }
+ /* Native controls take the app palette: the accent is the primary colour,
+ not the browser's default blue, and text and placeholders use tokens. */
+ & input[type='checkbox'],
+ & input[type='radio'] { color-scheme: dark; accent-color: hsl(var(--primary)); }
- & input[type='checkbox']:checked {
- background-color: rgb(37 99 235);
- border-color: rgb(37 99 235);
- }
-
- & input[type='checkbox']:focus {
- --tw-ring-color: rgb(59 130 246);
- --tw-ring-offset-color: rgb(31 41 55);
- }
-
- & input[type='radio'] { color-scheme: dark; color: rgb(37 99 235); border-color: rgb(75 85 99); background-color: rgb(31 41 55); }
-
- & input[type='radio']:checked {
- background-color: rgb(37 99 235);
- border-color: rgb(37 99 235);
- }
-
- & input[type='radio']:focus {
- --tw-ring-color: rgb(59 130 246);
- --tw-ring-offset-color: rgb(31 41 55);
- }
-
- & textarea { caret-color: rgb(243 244 246) !important; -webkit-text-fill-color: rgb(243 244 246) !important; color: rgb(243 244 246) !important; }
-
- & textarea:focus { -webkit-text-fill-color: rgb(243 244 246) !important; color: rgb(243 244 246) !important; }
+ & textarea { caret-color: hsl(var(--foreground)) !important; -webkit-text-fill-color: hsl(var(--foreground)) !important; color: hsl(var(--foreground)) !important; }
@supports (-webkit-touch-callout: none) {
& textarea,
- & textarea:focus { -webkit-text-fill-color: rgb(243 244 246) !important; color: rgb(243 244 246) !important; background-color: transparent !important; }
+ & textarea:focus { background-color: transparent !important; }
}
- & textarea.bg-transparent { -webkit-text-fill-color: rgb(243 244 246) !important; color: rgb(243 244 246) !important; background-color: transparent !important; }
+ & textarea.bg-transparent { background-color: transparent !important; }
& textarea::placeholder,
- & textarea::-webkit-input-placeholder,
- & textarea::-moz-placeholder,
- & textarea:-ms-input-placeholder { opacity: 1 !important; color: rgb(75 85 99) !important; }
-
- & textarea.bg-transparent::placeholder,
- & .chat-input-placeholder::placeholder,
- & .chat-input-placeholder::-webkit-input-placeholder { -webkit-text-fill-color: rgb(75 85 99) !important; opacity: 1 !important; color: rgb(75 85 99) !important; }
+ & .chat-input-placeholder::placeholder { -webkit-text-fill-color: hsl(var(--muted-foreground)) !important; opacity: 1 !important; color: hsl(var(--muted-foreground)) !important; }
& .chat-input-placeholder {
scrollbar-color: rgba(107, 114, 128, 0.3) transparent;
@@ -364,7 +307,7 @@
& [class*='ring-offset'],
& button:focus {
- --tw-ring-offset-color: rgb(20 20 20);
+ --tw-ring-offset-color: hsl(var(--background));
}
& select {
@@ -379,8 +322,8 @@
}
& select option {
- background-color: rgb(31 31 31) !important;
- color: rgb(237 235 230) !important;
+ background-color: hsl(var(--popover)) !important;
+ color: hsl(var(--popover-foreground)) !important;
}
}
@@ -389,8 +332,7 @@
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
- &::placeholder, &::-webkit-input-placeholder { opacity: 1 !important; color: rgb(156 163 175) !important; }
- .dark &::placeholder, .dark &::-webkit-input-placeholder { -webkit-text-fill-color: rgb(75 85 99) !important; opacity: 1 !important; color: rgb(75 85 99) !important; }
+ &::placeholder { opacity: 1 !important; color: hsl(var(--muted-foreground)) !important; }
&:not(:focus) {
height: auto;
@@ -503,10 +445,6 @@
/* Utility-scale overrides and shell dimensions. */
@layer utilities {
- .text-\[9px\] {
- font-size: calc(9px * var(--interface-font-scale, 1));
- }
-
.text-\[10px\] {
font-size: calc(10px * var(--interface-font-scale, 1));
}
@@ -540,11 +478,12 @@
button:active, a:active, [role="button"]:active { transition-duration: 50ms; }
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { transition: outline-offset 150ms ease-out, box-shadow 150ms ease-out; }
- input[type="checkbox"] { opacity: 1; @apply accent-blue-600; }
+ input[type="checkbox"], input[type="radio"] { opacity: 1; accent-color: hsl(var(--primary)); }
input[type="checkbox"]:focus { outline-offset: 2px; outline: 2px solid hsl(var(--ring)); opacity: 1; }
textarea { color-scheme: light dark; }
- textarea::placeholder, textarea::-webkit-input-placeholder, textarea::-moz-placeholder, textarea:-ms-input-placeholder { opacity: 1 !important; color: rgb(156 163 175) !important; }
+ /* One placeholder colour in both themes, at the muted-text contrast. */
+ textarea::placeholder { opacity: 1 !important; color: hsl(var(--muted-foreground)) !important; }
@supports (-webkit-touch-callout: none) {
select { -webkit-appearance: none; font-size: 16px !important; }
@@ -559,18 +498,22 @@
.chat-messages-pane { contain: layout style paint; }
.chat-composer-shell { contain: layout style; }
+ /* No automatic hyphenation: it split prose ("be-fore") and, worse,
+ command names typed into a message ("tidepool to-day"). A long token
+ still wraps rather than overflowing. */
.chat-message {
- hyphens: auto; overflow-wrap: break-word; word-wrap: break-word;
+ overflow-wrap: break-word;
/* Pagination anchors use actual geometry. Estimated offscreen heights
changed the scroll range a second time after every prepend. */
contain: layout style paint;
}
+ /* Code wraps at spaces first and breaks inside a token only when the token
+ alone is wider than the line; break-all cut "today" into "toda y". */
.chat-message pre,
.chat-message code {
white-space: pre-wrap !important;
- word-break: break-all;
- overflow-wrap: break-word;
+ overflow-wrap: anywhere;
}
.chat-message pre,
diff --git a/src/shared/view/ui/DarkModeToggle.tsx b/src/shared/view/ui/DarkModeToggle.tsx
deleted file mode 100644
index 061cd877..00000000
--- a/src/shared/view/ui/DarkModeToggle.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Moon, Sun } from 'lucide-react';
-
-import { useTheme } from '../../../contexts/ThemeContext';
-import { cn } from '../../../utils/cn';
-
-type DarkModeToggleProps = { checked?: boolean; onToggle?: (nextValue: boolean) => void; ariaLabel?: string };
-
-function DarkModeToggle(props: DarkModeToggleProps) {
- const theme = useTheme();
- const { ariaLabel = 'Toggle dark mode', checked, onToggle } = props;
- const isControlled = checked !== undefined && onToggle !== undefined;
- const enabled = isControlled ? checked : theme.isDarkMode;
-
- function flip() {
- if (checked !== undefined && onToggle !== undefined) {
- onToggle(!enabled);
- return;
- }
-
- theme.toggleDarkMode();
- }
-
- return (
-
- {ariaLabel}
-
- {enabled ? (
-
- ) : (
-
- )}
-
-
- );
-}
-
-export default DarkModeToggle;
diff --git a/src/shared/view/ui/index.ts b/src/shared/view/ui/index.ts
index ff106d72..ffcd5e0b 100644
--- a/src/shared/view/ui/index.ts
+++ b/src/shared/view/ui/index.ts
@@ -7,7 +7,6 @@ export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationActio
export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
export { Collapsible, CollapsibleTrigger, CollapsibleContent } from './Collapsible';
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from './Command';
-export { default as DarkModeToggle } from './DarkModeToggle';
export { Dialog, DialogTrigger, DialogContent, DialogTitle } from './Dialog';
export { Input } from './Input';
export { ScrollArea } from './ScrollArea';
diff --git a/src/utils/shortcutLabel.test.ts b/src/utils/shortcutLabel.test.ts
new file mode 100644
index 00000000..52a36a5f
--- /dev/null
+++ b/src/utils/shortcutLabel.test.ts
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+
+import { modShortcutLabel } from './shortcutLabel';
+
+const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
+
+const onPlatform = (platform: string) => {
+ Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { platform, userAgent: '' } });
+};
+
+afterEach(() => {
+ if (original) Object.defineProperty(globalThis, 'navigator', original);
+ else Reflect.deleteProperty(globalThis, 'navigator');
+});
+
+test('a Mac shows the command-key glyphs', () => {
+ onPlatform('MacIntel');
+ assert.equal(modShortcutLabel('K'), '⌘K');
+ assert.equal(modShortcutLabel('D', { shift: true }), '⌘⇧D');
+});
+
+test('other platforms spell the chord out', () => {
+ onPlatform('Win32');
+ assert.equal(modShortcutLabel('K'), 'Ctrl+K');
+ assert.equal(modShortcutLabel('P', { shift: true }), 'Ctrl+Shift+P');
+});
diff --git a/src/utils/shortcutLabel.ts b/src/utils/shortcutLabel.ts
new file mode 100644
index 00000000..32312a12
--- /dev/null
+++ b/src/utils/shortcutLabel.ts
@@ -0,0 +1,11 @@
+export const isApplePlatform = (): boolean =>
+ typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || '');
+
+/**
+ * A Cmd/Ctrl chord as the keyboard in front of the user labels it: `⌘⇧D` on a
+ * Mac, `Ctrl+Shift+D` elsewhere. `key` is the printed key cap.
+ */
+export const modShortcutLabel = (key: string, { shift = false } = {}): string =>
+ isApplePlatform()
+ ? `⌘${shift ? '⇧' : ''}${key}`
+ : `Ctrl+${shift ? 'Shift+' : ''}${key}`;