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 (
; +} + +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 (
- + - - + +