Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 15 additions & 12 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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. |
5 changes: 3 additions & 2 deletions docs/plans/frontend-refactor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions server/gjc-session-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions server/gjc-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/OAuthLoginDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
</a>
Expand Down
4 changes: 2 additions & 2 deletions src/components/chat/tools/components/BashCommandDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function CommandRow({ command, copied, hasOutput, isRunning, lineCount, onCopy,
/>
)}
<span className="shrink-0 font-mono text-xs text-muted-foreground select-none">$</span>
<code className={cn('min-w-0 flex-1 font-mono text-xs text-foreground', open ? 'break-all whitespace-pre-wrap' : 'truncate')}>
<code className={cn('min-w-0 flex-1 font-mono text-xs text-foreground', open ? 'wrap-anywhere whitespace-pre-wrap' : 'truncate')}>
{command}
</code>
{!open && hasOutput && !isRunning && (
Expand Down Expand Up @@ -145,7 +145,7 @@ export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
<span className="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">Output</span>
<div className="h-px flex-1 bg-border" />
</div>
<pre className={cn('max-h-80 overflow-auto px-3 py-2 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap', isError ? 'text-destructive' : 'text-muted-foreground')}>
<pre className={cn('max-h-80 overflow-auto px-3 py-2 font-mono text-xs leading-relaxed wrap-anywhere whitespace-pre-wrap', isError ? 'text-destructive' : 'text-muted-foreground')}>
{text}
</pre>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/chat/tools/components/OneLineDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
}) => {
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;
Expand All @@ -78,7 +78,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const copyButton = action === 'copy' ? <CopyButton copied={copied} onClick={runAction} /> : 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 (
<div className="group my-1">
<div className="flex items-start gap-1.5">
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/tools/components/ToolCallRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const ToolCallRow: React.FC<ToolCallRowProps> = ({
)}
>
<pre
className={`max-h-80 overflow-auto font-mono text-xs leading-relaxed break-all whitespace-pre-wrap ${
className={`max-h-80 overflow-auto font-mono text-xs leading-relaxed wrap-anywhere whitespace-pre-wrap ${
isError ? 'text-destructive' : 'text-muted-foreground'
}`}
>
Expand Down
19 changes: 18 additions & 1 deletion src/components/chat/tools/configs/toolConfigs.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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');
});
Loading