diff --git a/docs/superpowers/plans/2026-05-13-web-tool-group-summary-and-chat-surface-colors.md b/docs/superpowers/plans/2026-05-13-web-tool-group-summary-and-chat-surface-colors.md new file mode 100644 index 00000000..44fcba82 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-web-tool-group-summary-and-chat-surface-colors.md @@ -0,0 +1,445 @@ +# Web Tool Group Summary and Chat Surface Colors Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make grouped tool-use cards read like friendly one-line activity summaries instead of raw paths / shell commands, and add local Web settings to customize grouped-card and user-message backgrounds with preset colors or the native color picker. + +**Architecture:** Keep all changes Web-only. Add a grouped-summary helper dedicated to `ToolGroupCard` so standalone `ToolCard` behavior remains untouched. Add a local-storage-backed `useChatSurfaceColors` hook that resolves `default` / `preset:*` / `custom:#RRGGBB` preferences into CSS variable overrides for `--app-tool-group-bg` and `--app-chat-user-surface-bg`. Follow repo preference: no proactive TDD; implement directly, then add durable focused regression tests. + +**Tech Stack:** React 19, TypeScript, Tailwind utility classes, existing i18n dictionaries, localStorage-backed preference hooks, Vitest, Testing Library. + +--- + +## File Structure + +- New: `web/src/components/ToolCard/groupedPresentation.ts` — grouped-only semantic summary helper for header and row labels +- New: `web/src/components/ToolCard/groupedPresentation.test.ts` — localized summary and command-heuristic regressions +- New: `web/src/hooks/useChatSurfaceColors.ts` — local-storage-backed grouped/user surface color preferences + CSS variable application +- New: `web/src/hooks/useChatSurfaceColors.test.ts` — parsing, fallback, preset, custom, and style-application regressions +- Modify: `web/src/App.tsx` — initialize chat surface colors once at app startup +- Modify: `web/src/components/ToolCard/ToolGroupCard.tsx` — switch grouped header / row rendering to grouped semantic summaries and grouped background variable +- Modify: `web/src/components/ToolCard/ToolGroupCard.test.tsx` — verify friendly header / row labels and grouped background usage +- Modify: `web/src/components/AssistantChat/messages/user-bubble.tsx` — use dedicated user-surface background variable +- Modify: `web/src/routes/settings/index.tsx` — add grouped-card and user-message background controls under Chat settings +- Modify: `web/src/routes/settings/index.test.tsx` — verify new settings labels and selected values render +- Modify: `web/src/index.css` — define default grouped / user-surface CSS variables +- Modify: `web/src/lib/locales/en.ts` — grouped summary and settings color copy +- Modify: `web/src/lib/locales/zh-CN.ts` — grouped summary and settings color copy + +## Task 1: Add grouped-only semantic summary helper + +**Files:** +- Create: `web/src/components/ToolCard/groupedPresentation.ts` +- Create: `web/src/components/ToolCard/groupedPresentation.test.ts` +- Modify: `web/src/components/ToolCard/ToolGroupCard.tsx` + +- [ ] **Step 1: Create grouped-only summary types and command heuristic buckets** + +Create `web/src/components/ToolCard/groupedPresentation.ts` with grouped-only helpers so standalone tool presentation remains unchanged. + +```ts +export type GroupedSummaryIntent = + | 'inspect-files' + | 'search-content' + | 'run-project-command' + | 'modify-files' + | 'open-web' + | 'generic-command' + | 'generic-tool' + +export function inferGroupedSummaryIntent(tool: ToolCallBlock): GroupedSummaryIntent { + const toolName = tool.tool.name + const command = getInputStringAny(tool.tool.input, ['command', 'cmd'])?.toLowerCase() ?? '' + + if (toolName === 'Read' || toolName === 'LS' || /\b(get-childitem|ls|dir|get-content|cat|type)\b/.test(command)) return 'inspect-files' + if (toolName === 'Grep' || toolName === 'Glob' || /\b(rg|grep|select-string|findstr)\b/.test(command)) return 'search-content' + if (toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'Write' || toolName === 'NotebookEdit') return 'modify-files' + if (toolName === 'WebFetch' || toolName === 'WebSearch') return 'open-web' + if (toolName === 'Bash' || toolName === 'CodexBash' || toolName === 'shell_command') return 'run-project-command' + return 'generic-tool' +} +``` + +- [ ] **Step 2: Add localized header / subtitle / row formatter helpers** + +In the same file, add three helpers used only by grouped cards. + +```ts +export function formatGroupedHeaderTitle( + block: ToolGroupBlock, + t: Translator, +): string { + const primaryTool = block.tools[0] + const primaryLabel = intentToFriendlyLabel(inferGroupedSummaryIntent(primaryTool), t) + const extraCount = block.tools.length - 1 + return extraCount > 0 ? `${primaryLabel} +${extraCount}` : primaryLabel +} + +export function formatGroupedHeaderSubtitle( + block: ToolGroupBlock, + t: Translator, +): string | null { + const parts: string[] = [] + if (block.summary.countsByKind.command > 0) parts.push(t('toolGroup.summary.command', { n: block.summary.countsByKind.command })) + if (block.summary.countsByKind.search > 0) parts.push(t('toolGroup.summary.search', { n: block.summary.countsByKind.search })) + if (block.summary.countsByKind.read > 0) parts.push(t('toolGroup.summary.read', { n: block.summary.countsByKind.read })) + if (block.summary.countsByKind.mutation > 0) parts.push(t('toolGroup.summary.mutation', { n: block.summary.countsByKind.mutation })) + if (block.summary.countsByKind.web > 0) parts.push(t('toolGroup.summary.web', { n: block.summary.countsByKind.web })) + return parts.length > 0 ? parts.join(' · ') : t('toolGroup.summary.other', { n: block.tools.length }) +} + +export function formatGroupedRowLabel( + tool: ToolCallBlock, + t: Translator, +): string { + return intentToFriendlyLabel(inferGroupedSummaryIntent(tool), t) +} +``` + +Use existing `block.summary.countsByKind` for aggregate counters. Keep `+n` logic in the header title only. + +- [ ] **Step 3: Cover grouped summary heuristics with durable tests** + +Create `web/src/components/ToolCard/groupedPresentation.test.ts`. + +```ts +it('formats file inspection shell commands as friendly grouped labels', () => { + const tool = makeTool('shell_command', { command: 'Get-ChildItem src -Recurse' }) + expect(formatGroupedRowLabel(tool, t)).toBe('Inspect project files') +}) + +it('does not leak raw shell command text into grouped labels', () => { + const tool = makeTool('Bash', { command: 'bun run build --filter web' }) + expect(formatGroupedRowLabel(tool, t)).not.toContain('bun run build') +}) + +it('formats grouped header title with +n suffix', () => { + expect(formatGroupedHeaderTitle(groupOfFiveTools, t)).toBe('Inspect project files +4') +}) +``` + +- [ ] **Step 4: Replace grouped-card raw summary rendering with the helper** + +Update `web/src/components/ToolCard/ToolGroupCard.tsx`. + +```ts +const headerTitle = formatGroupedHeaderTitle(props.block, t) +const subtitle = formatGroupedHeaderSubtitle(props.block, t) + +// In RowLabel: +
+ {formatGroupedRowLabel(props.block, t)} +
+``` + +Remove row-level raw subtitle rendering for grouped rows. Keep detail dialogs unchanged. + +## Task 2: Add local grouped/user chat surface color hook + +**Files:** +- Create: `web/src/hooks/useChatSurfaceColors.ts` +- Create: `web/src/hooks/useChatSurfaceColors.test.ts` +- Modify: `web/src/App.tsx` +- Modify: `web/src/index.css` +- Modify: `web/src/components/AssistantChat/messages/user-bubble.tsx` +- Modify: `web/src/components/ToolCard/ToolGroupCard.tsx` + +- [ ] **Step 1: Create preference types, storage keys, presets, and parsers** + +Create `web/src/hooks/useChatSurfaceColors.ts`. + +```ts +export type ChatSurfaceColorPreset = 'default' | 'soft-blue' | 'soft-green' | 'soft-yellow' +export type ChatSurfaceColorPreference = 'default' | `preset:${Exclude}` | `custom:#${string}` + +export const DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE: ChatSurfaceColorPreference = 'default' + +export function getChatSurfaceColorPresetOptions() { + return [ + { value: 'default', labelKey: 'settings.chat.surfaceColor.default' }, + { value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' }, + { value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' }, + { value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' }, + ] as const +} +``` + +Use separate storage keys: + +```ts +const TOOL_GROUP_BG_KEY = 'hapi-tool-group-bg' +const USER_MESSAGE_BG_KEY = 'hapi-user-message-bg' +``` + +- [ ] **Step 2: Resolve preferences into softened CSS colors and apply root variables** + +In the same hook file, add a tiny hex-color utility and CSS variable applier. + +```ts +function mixHex(base: string, accent: string, ratio: number): string { + const [br, bg, bb] = hexToRgb(base) + const [ar, ag, ab] = hexToRgb(accent) + return rgbToHex( + Math.round(br + (ar - br) * ratio), + Math.round(bg + (ag - bg) * ratio), + Math.round(bb + (ab - bb) * ratio), + ) +} + +function resolveSurfaceColor(pref: ChatSurfaceColorPreference, theme: 'light' | 'dark', surface: 'tool-group' | 'user-message'): string | null { + if (pref === 'default') return null + const base = surface === 'tool-group' + ? (theme === 'dark' ? '#2b2f34' : '#f2f4f6') + : (theme === 'dark' ? '#2b2f34' : '#f2f4f6') + const preset = pref.startsWith('preset:') ? pref.slice(7) : null + const customHex = pref.startsWith('custom:') ? pref.slice(7) : null + const accent = preset === 'soft-blue' ? '#7db7ff' + : preset === 'soft-green' ? '#8fd19e' + : preset === 'soft-yellow' ? '#f0d77a' + : customHex + return accent ? mixHex(base, accent, theme === 'dark' ? 0.2 : 0.32) : null +} + +function applyChatSurfaceVariables(resolved: { + toolGroupBg: string | null + userMessageBg: string | null +}) { + const rootStyle = document.documentElement.style + resolved.toolGroupBg ? rootStyle.setProperty('--app-tool-group-bg', resolved.toolGroupBg) : rootStyle.removeProperty('--app-tool-group-bg') + resolved.userMessageBg ? rootStyle.setProperty('--app-chat-user-surface-bg', resolved.userMessageBg) : rootStyle.removeProperty('--app-chat-user-surface-bg') +} +``` + +Return a hook API like: + +```ts +export function initializeChatSurfaceColors(): void { + applyStoredChatSurfaceVariables() + window.addEventListener('storage', handleStorageSync) +} + +export function useChatSurfaceColors(): { + toolGroupBackground: ChatSurfaceColorPreference + userMessageBackground: ChatSurfaceColorPreference + setToolGroupBackground: (value: ChatSurfaceColorPreference) => void + setUserMessageBackground: (value: ChatSurfaceColorPreference) => void +} +``` + +- [ ] **Step 3: Define default CSS variables and switch grouped/user surfaces to them** + +Update `web/src/index.css`. + +```css +:root { + --app-tool-group-bg: var(--app-tool-card-bg); + --app-chat-user-surface-bg: var(--app-chat-user-bg); +} + +[data-theme="dark"] { + --app-tool-group-bg: var(--app-tool-card-bg); + --app-chat-user-surface-bg: var(--app-chat-user-bg); +} +``` + +Update `web/src/components/AssistantChat/messages/user-bubble.tsx`: + +```ts +'happy-user-bubble happy-chat-text ml-auto w-fit min-w-0 max-w-[92%] rounded-2xl bg-[var(--app-chat-user-surface-bg)] px-4 py-2.5 text-[var(--app-chat-user-fg)] shadow-none' +``` + +Update `web/src/components/ToolCard/ToolGroupCard.tsx`: + +```ts + +``` + +Update `web/src/App.tsx`: + +```ts +import { initializeChatSurfaceColors } from '@/hooks/useChatSurfaceColors' + +useEffect(() => { + const tg = getTelegramWebApp() + tg?.ready() + tg?.expand() + initializeTheme() + initializeChatSurfaceColors() +}, []) +``` + +- [ ] **Step 4: Add durable hook tests for fallback and CSS variable application** + +Create `web/src/hooks/useChatSurfaceColors.test.ts`. + +```ts +it('falls back to default when storage is missing or invalid', () => { + expect(getInitialToolGroupBackground()).toBe('default') + expect(getInitialUserMessageBackground()).toBe('default') +}) + +it('stores preset and custom preferences using stable string values', () => { + setToolGroupBackground('preset:soft-blue') + setUserMessageBackground('custom:#88cc44') +}) + +it('applies root CSS variables only for non-default preferences', () => { + expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toBe('') + // after setting preset/custom => variable is written +} +``` + +## Task 3: Add Settings > Chat controls for grouped/user surface colors + +**Files:** +- Modify: `web/src/routes/settings/index.tsx` +- Modify: `web/src/routes/settings/index.test.tsx` +- Modify: `web/src/lib/locales/en.ts` +- Modify: `web/src/lib/locales/zh-CN.ts` + +- [ ] **Step 1: Add i18n copy for grouped summaries and color settings** + +Update locale files with grouped-friendly labels and settings copy. + +```ts +// tool group friendly summaries +'toolGroup.friendly.inspectFiles': 'Inspect project files', +'toolGroup.friendly.searchContent': 'Search project content', +'toolGroup.friendly.runCommands': 'Run project commands', +'toolGroup.friendly.editFiles': 'Edit project files', +'toolGroup.friendly.genericCommand': 'Run command', + +// settings +'settings.chat.groupedToolBackground': 'Grouped Tool Use Background', +'settings.chat.userMessageBackground': 'User Message Background', +'settings.chat.surfaceColor.default': 'Default color', +'settings.chat.surfaceColor.softBlue': 'Soft blue', +'settings.chat.surfaceColor.softGreen': 'Soft green', +'settings.chat.surfaceColor.softYellow': 'Soft yellow', +'settings.chat.surfaceColor.custom': 'Custom color', +``` + +Mirror the same keys in `zh-CN.ts`. + +- [ ] **Step 2: Wire the settings page to the new hook** + +Update `web/src/routes/settings/index.tsx` to consume `useChatSurfaceColors()` and render two new controls under the existing Chat section. + +```ts +const { + toolGroupBackground, + userMessageBackground, + setToolGroupBackground, + setUserMessageBackground, +} = useChatSurfaceColors() +``` + +Render each surface as: + +```tsx +
+
{t('settings.chat.groupedToolBackground')}
+
+ {presetOptions.map((opt) => ( + + ))} +
+ setToolGroupBackground(`custom:${event.target.value}`)} + /> +
+``` + +Do the same for user-message background. Keep the current settings page visual language; no new modal or restore button. + +- [ ] **Step 3: Extend settings-page regression coverage** + +Update `web/src/routes/settings/index.test.tsx`. + +```ts +vi.mock('@/hooks/useChatSurfaceColors', () => ({ + useChatSurfaceColors: () => ({ + toolGroupBackground: 'default', + userMessageBackground: 'preset:soft-blue', + setToolGroupBackground: vi.fn(), + setUserMessageBackground: vi.fn(), + }), + getChatSurfaceColorPresetOptions: () => [ + { value: 'default', labelKey: 'settings.chat.surfaceColor.default' }, + { value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' }, + { value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' }, + { value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' }, + ], +})) + +it('renders grouped tool and user message background settings', () => { + expect(screen.getAllByText('Grouped Tool Use Background').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('User Message Background').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Default color').length).toBeGreaterThanOrEqual(1) +}) +``` + +## Task 4: Refresh grouped-card regressions and verify end-to-end behavior + +**Files:** +- Modify: `web/src/components/ToolCard/ToolGroupCard.test.tsx` +- Modify: `web/src/components/ToolCard/groupedPresentation.test.ts` +- Modify: `web/src/hooks/useChatSurfaceColors.test.ts` +- Modify: `web/src/routes/settings/index.test.tsx` + +- [ ] **Step 1: Update grouped-card tests to assert friendly labels instead of raw commands/paths** + +Revise `web/src/components/ToolCard/ToolGroupCard.test.tsx` expectations. + +```ts +expect(screen.getByRole('button', { name: /Inspect project files/i })).toBeInTheDocument() +expect(screen.getByText('Read 1 · Run 1')).toBeInTheDocument() +expect(screen.queryByText('bun test')).not.toBeInTheDocument() +``` + +Also add one row assertion after expand: + +```ts +expect(screen.getByText('Run project commands')).toBeInTheDocument() +expect(screen.queryByText('src/a.ts')).not.toBeInTheDocument() +``` + +Keep dialog assertions that confirm raw detail is still accessible after clicking a row. + +- [ ] **Step 2: Run focused verification commands** + +Run from repo root: + +```bash +cd web +bun run test -- src/components/ToolCard/groupedPresentation.test.ts src/hooks/useChatSurfaceColors.test.ts src/components/ToolCard/ToolGroupCard.test.tsx src/routes/settings/index.test.tsx +bun run typecheck +``` + +Expected: + +- test command exits `0` +- typecheck exits `0` + +- [ ] **Step 3: Manual smoke in the browser** + +Verify: + +```text +1. A grouped shell/file activity card now reads like “检查项目文件 +4” instead of a raw path or full command. +2. Expanded grouped rows stay one-line and semantic. +3. Clicking a row still opens raw tool details. +4. Settings > Chat shows two new color controls. +5. Default color leaves current visuals unchanged. +6. Soft blue / soft green / soft yellow and custom color update immediately and persist after reload. +``` + +## Notes + +- Do not add temporary bug-repro tests that lack long-term regression value. +- Do not change standalone `getToolPresentation` behavior unless a small shared utility extraction is truly required. +- Do not introduce server-backed settings or theme-schema changes outside `web`. diff --git a/docs/superpowers/specs/2026-05-13-web-tool-group-summary-and-chat-surface-colors-design.md b/docs/superpowers/specs/2026-05-13-web-tool-group-summary-and-chat-surface-colors-design.md new file mode 100644 index 00000000..fb6c386c --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-web-tool-group-summary-and-chat-surface-colors-design.md @@ -0,0 +1,206 @@ +# Web Tool Group Summary and Chat Surface Colors Design + +**Date:** 2026-05-13 + +## Goal + +Improve grouped tool-use readability in `web` and add lightweight appearance controls for two chat surfaces: + +1. grouped tool-use cards should show friendly one-line multilingual summaries instead of raw paths / shell commands in collapsed and row-list states +2. users should be able to customize the background colors for grouped tool-use cards and user message bubbles from Settings using presets or the native color picker + +## Scope + +- Change grouped tool-use summary generation used by `ToolGroupCard` +- Keep grouped card detail dialogs unchanged so raw paths / commands remain inspectable on demand +- Add local persisted appearance settings for: + - grouped tool-use background + - user message background +- Expose both settings in `Settings > Chat` +- Add i18n copy in English and Simplified Chinese +- Add focused Web regression tests for grouped summaries and appearance preference helpers / rendering + +## Non-Goals + +- No backend / hub / shared protocol changes +- No change to single `ToolCard` background or summary behavior +- No change to assistant message background +- No server-side synced theme settings +- No separate light / dark mode color palettes +- No restore-default action button; `Default color` preset covers reset behavior + +## Agreed Product Decisions + +- Background customization applies only to: + - grouped tool-use cards + - user message bubbles +- Single `ToolCard` stays visually unchanged +- Default built-in colors stay unchanged unless the user explicitly picks another preset or custom color +- Storage is local only (`localStorage`) +- Preset list is fixed to: + - Default color + - Soft blue + - Soft green + - Soft yellow +- Native color picker remains available for both settings +- Preset selection and custom color selection should apply immediately +- Grouped summaries should be single-line, friendly, and multilingual +- Grouped summaries should avoid directly exposing raw absolute paths / full shell commands in collapsed group UI +- Detailed raw information remains available in the existing detail dialog for each grouped row + +## Grouped Summary Behavior + +### Summary surfaces + +Apply the new friendly summary logic only to grouped-tool UI surfaces: + +- grouped card header title +- grouped card expanded row labels + +Do not apply this logic to: + +- single standalone `ToolCard` +- tool detail dialogs +- trace sections inside tool dialogs + +### Header summary rules + +Collapsed grouped-card title should prefer a semantic activity label over raw target text. + +Priority: + +1. friendly semantic description inferred from grouped tool category and known command pattern +2. localized fallback activity label by tool kind +3. `+n` suffix when the grouped card contains more than one summarized item + +Examples: + +- `Get-ChildItem ...`, `ls`, `dir`, `Get-Content ...` -> `检查项目文件 +4` / `Inspect project files +4` +- `rg ...`, `Select-String ...` -> `搜索项目内容 +2` / `Search project content +2` +- `bun test`, `npm run build` -> `执行项目命令 +1` / `Run project commands +1` +- mixed file edits -> `修改项目文件 +3` / `Edit project files +3` + +The header subtitle should remain a short localized aggregate counter line, e.g.: + +- `执行 5` / `Run 5` +- `搜索 3 · 读取 2` + +All grouped header lines must remain one-line truncating text. + +### Expanded row rules + +Each grouped row should display a one-line friendly label using the same grouped-summary helper style: + +- semantic, localized, concise +- no raw full path / raw shell command preview in the row itself +- status badge behavior unchanged +- click row -> existing detail dialog with full raw input / result + +### Command heuristics + +For command-like grouped tools, use lightweight heuristics only; no heavy parser or backend change. + +Recommended buckets: + +- file inspection commands +- content search commands +- project command execution +- file modification commands +- generic command fallback + +If the heuristic is uncertain, prefer a safe generic localized label rather than leaking the full command. + +## Appearance Settings Behavior + +### Settings location + +Add two new items under `Settings > Chat`: + +- Grouped Tool Use Background +- User Message Background + +Each item should expose: + +- preset chips / options +- native color picker +- current selected state + +### Storage model + +Use two independent local preferences. Each preference represents a selection mode rather than only a raw hex value. + +Suggested shape: + +- `default` +- `preset:` +- `custom:#RRGGBB` + +This keeps `Default color` as a first-class option and avoids special reset UI. + +### Presets + +Fixed preset list for both settings: + +- `default` +- `soft-blue` +- `soft-green` +- `soft-yellow` + +The preset list is shared across light and dark themes. + +### Visual application + +Default state: + +- grouped cards use current `--app-tool-card-bg`-derived grouped background behavior +- user bubbles use current `--app-chat-user-bg` + +Configured state: + +- grouped card background uses a dedicated grouped-surface CSS variable override +- user bubble background uses a dedicated user-surface CSS variable override + +To avoid overly loud colors, the chosen preset / custom color should be softened before final render so the result is slightly eye-catching but still aligned with the current chat palette. + +## Files + +### Grouped summary work + +- Modify: `web/src/chat/toolGroups.ts` +- Modify: `web/src/components/ToolCard/ToolGroupCard.tsx` +- New or Modify helper: grouped summary / presentation helper near `ToolCard` grouped UI +- Modify: `web/src/lib/locales/en.ts` +- Modify: `web/src/lib/locales/zh-CN.ts` + +### Appearance settings work + +- Modify: `web/src/index.css` +- New: `web/src/hooks/useChatSurfaceColors.ts` +- Modify: `web/src/routes/settings/index.tsx` +- Modify: `web/src/components/AssistantChat/messages/user-bubble.tsx` +- Modify: `web/src/components/AssistantChat/messages/ToolMessage.tsx` only if grouped wrapper styling needs variable plumb-through +- Modify: `web/src/components/ToolCard/ToolGroupCard.tsx` +- Modify: `web/src/lib/locales/en.ts` +- Modify: `web/src/lib/locales/zh-CN.ts` + +## Testing + +- Add grouped-summary regression coverage: + - localized friendly label generation + - no raw path / full command in grouped header / row summaries + - `+n` suffix behavior + - single-line truncation-safe rendering expectations +- Add appearance preference helper coverage: + - storage parsing + - invalid value fallback to `default` + - preset selection + - custom hex selection +- Add settings-page rendering coverage for the two new appearance options +- Add component coverage for grouped-card and user-bubble variable application where practical +- Run focused `web` tests and `web` typecheck + +## Notes + +- Keep implementation pragmatic; no new cross-package schema needed +- Reuse current Settings interaction patterns where possible +- Prefer isolated Web-only helpers over changing existing single-tool presentation logic diff --git a/web/src/App.tsx b/web/src/App.tsx index 140f8c42..10c97da1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' +import { initializeChatSurfaceColors } from '@/hooks/useChatSurfaceColors' import { initializeTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' @@ -57,6 +58,7 @@ function AppInner() { tg?.ready() tg?.expand() initializeTheme() + initializeChatSurfaceColors() }, []) // Track visual viewport height for mobile keyboard avoidance (see useViewportHeight.ts) diff --git a/web/src/components/AssistantChat/messages/user-bubble.tsx b/web/src/components/AssistantChat/messages/user-bubble.tsx index 02433a8e..e0995598 100644 --- a/web/src/components/AssistantChat/messages/user-bubble.tsx +++ b/web/src/components/AssistantChat/messages/user-bubble.tsx @@ -7,7 +7,7 @@ const LEADING_DIRECTIVE_REGEX = /^([$\/][a-z0-9][\w-]*)(?=\s|$)/i export function getUserBubbleClassName(status?: MessageStatus) { return cn( - 'happy-user-bubble happy-chat-text ml-auto w-fit min-w-0 max-w-[92%] rounded-2xl bg-[var(--app-chat-user-bg)] px-4 py-2.5 text-[var(--app-chat-user-fg)] shadow-none', + 'happy-user-bubble happy-chat-text ml-auto w-fit min-w-0 max-w-[92%] rounded-2xl bg-[var(--app-chat-user-surface-bg)] px-4 py-2.5 text-[var(--app-chat-user-fg)] shadow-none', status === 'queued' && 'opacity-60' ) } diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx index d5849411..f0f83460 100644 --- a/web/src/components/ToolCard/ToolGroupCard.test.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -96,19 +96,27 @@ describe('ToolGroupCard', () => { }) it('renders a collapsed target-first header', () => { - renderCard(makeGroup()) + const view = renderCard(makeGroup()) - expect(screen.getByRole('button', { name: /src\/a.ts/i })).toBeInTheDocument() - expect(screen.getByText('Read 1 · Run 1')).toBeInTheDocument() - expect(screen.queryByText('2 tool calls')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /inspect project files \+1/i })).toBeInTheDocument() + expect(screen.getByText('Run 1 · Read 1')).toBeInTheDocument() + expect(screen.queryByText('src/a.ts')).not.toBeInTheDocument() + expect(screen.queryByText('bun test')).not.toBeInTheDocument() + expect(screen.queryByText('2 actions')).not.toBeInTheDocument() + + expect(view.container.innerHTML).toContain('bg-[var(--app-tool-group-bg)]') }) it('expands to show compact rows and opens a detail dialog per row', async () => { const view = renderCard(makeGroup()) - const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) + const groupToggle = within(view.container).getByRole('button', { name: /inspect project files \+1/i }) fireEvent.click(groupToggle) - expect(screen.getByText('2 tool calls')).toBeInTheDocument() + expect(screen.getByText('2 actions')).toBeInTheDocument() + expect(screen.getByText('Inspect project files')).toBeInTheDocument() + expect(screen.getByText('Run project commands')).toBeInTheDocument() + expect(screen.queryByText('src/a.ts')).not.toBeInTheDocument() + expect(screen.queryByText('bun test')).not.toBeInTheDocument() const firstRowButton = within(view.container) .getAllByRole('button') @@ -164,7 +172,7 @@ describe('ToolGroupCard', () => { } const view = render() - const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) + const groupToggle = within(view.container).getByRole('button', { name: /inspect project files \+1/i }) fireEvent.click(groupToggle) @@ -224,7 +232,7 @@ describe('ToolGroupCard', () => { } const view = render() - const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) + const groupToggle = within(view.container).getByRole('button', { name: /inspect project files \+1/i }) fireEvent.click(groupToggle) @@ -279,7 +287,7 @@ describe('ToolGroupCard', () => { } const view = render() - const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) + const groupToggle = within(view.container).getByRole('button', { name: /inspect project files \+1/i }) fireEvent.click(groupToggle) diff --git a/web/src/components/ToolCard/ToolGroupCard.tsx b/web/src/components/ToolCard/ToolGroupCard.tsx index 1eae4595..cc483a95 100644 --- a/web/src/components/ToolCard/ToolGroupCard.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.tsx @@ -5,10 +5,9 @@ import type { SessionMetadataSummary } from '@/types/api' import { useHappyChatContext } from '@/components/AssistantChat/context' import { ToolDetailDialogContent, ToolStatusIcon, toolStatusColorClass } from '@/components/ToolCard/ToolCard' import { getToolPresentation } from '@/components/ToolCard/knownTools' +import { formatGroupedHeaderSubtitle, formatGroupedHeaderTitle, formatGroupedRowLabel } from '@/components/ToolCard/groupedPresentation' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { basename, resolveDisplayPath } from '@/utils/path' -import { getInputStringAny, truncate } from '@/lib/toolInputUtils' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' @@ -42,50 +41,6 @@ function RowStatusBadge(props: { block: ToolCallBlock }) { return null } -function formatPrimaryTitle(block: ToolGroupBlock, metadata: SessionMetadataSummary | null, t: (key: string, params?: Record) => string): string { - const fileTargets = block.summary.fileTargets - if (fileTargets.length > 0) { - const display = resolveDisplayPath(fileTargets[0], metadata) - return fileTargets.length === 1 - ? display - : t('toolGroup.primary.fileTargets', { target: display, n: fileTargets.length - 1 }) - } - - const commandTargets = block.summary.commandTargets - if (commandTargets.length > 0) { - const command = truncate(commandTargets[0], 72) - return commandTargets.length === 1 - ? command - : t('toolGroup.primary.commandTargets', { target: command, n: commandTargets.length - 1 }) - } - - const searchTargets = block.summary.searchTargets - if (searchTargets.length > 0) { - const target = truncate(searchTargets[0], 72) - return searchTargets.length === 1 - ? target - : t('toolGroup.primary.searchTargets', { target, n: searchTargets.length - 1 }) - } - - const urlTargets = block.summary.urlTargets - if (urlTargets.length > 0) { - const target = truncate(urlTargets[0], 72) - return urlTargets.length === 1 - ? target - : t('toolGroup.primary.urlTargets', { target, n: urlTargets.length - 1 }) - } - - const otherTargets = block.summary.otherTargets - if (otherTargets.length > 0) { - const target = truncate(otherTargets[0], 72) - return otherTargets.length === 1 - ? target - : t('toolGroup.primary.otherTargets', { target, n: otherTargets.length - 1 }) - } - - return t('toolGroup.title') -} - function formatActionSummary(block: ToolGroupBlock, t: (key: string, params?: Record) => string): string | null { const parts: string[] = [] const { countsByKind } = block.summary @@ -129,15 +84,10 @@ function RowLabel(props: { block: ToolCallBlock; metadata: SessionMetadataSummar
{presentation.icon}
-
- {presentation.title} +
+ {formatGroupedRowLabel(props.block, t)}
- {presentation.subtitle ? ( -
- {truncate(presentation.subtitle, 120)} -
- ) : null} ) } @@ -261,12 +211,12 @@ export function ToolGroupCard(props: { }, t) }, [selectedTool, props.metadata, t]) - const primaryTitle = formatPrimaryTitle(props.block, props.metadata, t) - const subtitle = formatActionSummary(props.block, t) + const primaryTitle = formatGroupedHeaderTitle(props.block, t) + const subtitle = formatGroupedHeaderSubtitle(props.block, t) ?? formatActionSummary(props.block, t) const fileCount = props.block.summary.fileTargets.length return ( - + ) diff --git a/web/src/components/ToolCard/groupedPresentation.test.ts b/web/src/components/ToolCard/groupedPresentation.test.ts new file mode 100644 index 00000000..71bcee6c --- /dev/null +++ b/web/src/components/ToolCard/groupedPresentation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { en, zhCN } from '@/lib/locales' +import type { ToolCallBlock } from '@/chat/types' +import type { ToolGroupBlock } from '@/chat/toolGroups' +import { formatGroupedHeaderSubtitle, formatGroupedHeaderTitle, formatGroupedRowLabel, inferGroupedSummaryIntent } from '@/components/ToolCard/groupedPresentation' + +type Dict = Record + +function makeTranslator(dict: Dict) { + return (key: string, params?: Record) => { + const template = dict[key] ?? key + if (!params) return template + return template.replace(/\{(\w+)\}/g, (match, token) => { + const value = params[token] + return value === undefined ? match : String(value) + }) + } +} + +function makeTool(id: string, name: string, input: unknown = {}): ToolCallBlock { + return { + kind: 'tool-call', + id, + localId: null, + createdAt: 1, + invokedAt: null, + tool: { + id, + name, + state: 'completed', + input, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: null, + permission: undefined, + }, + children: [], + } +} + +function makeGroup(tools: ToolCallBlock[]): ToolGroupBlock { + return { + kind: 'tool-group', + id: 'tool-group:test', + createdAt: 1, + invokedAt: null, + firstToolId: tools[0].id, + lastToolId: tools[tools.length - 1].id, + tools, + defaultOpen: false, + historyState: 'complete', + needsOlderHistory: false, + summary: { + totalTools: tools.length, + countsByKind: { + read: tools.filter((tool) => tool.tool.name === 'Read').length, + search: tools.filter((tool) => tool.tool.name === 'Grep' || tool.tool.name === 'Glob').length, + command: tools.filter((tool) => tool.tool.name === 'Bash' || tool.tool.name === 'CodexBash' || tool.tool.name === 'shell_command').length, + mutation: tools.filter((tool) => tool.tool.name === 'Edit' || tool.tool.name === 'Write' || tool.tool.name === 'MultiEdit').length, + web: tools.filter((tool) => tool.tool.name === 'WebFetch' || tool.tool.name === 'WebSearch').length, + other: 0, + }, + fileTargets: [], + commandTargets: [], + searchTargets: [], + urlTargets: [], + otherTargets: [], + errorCount: 0, + runningCount: 0, + pendingCount: 0, + }, + } +} + +const tEn = makeTranslator(en as Dict) +const tZh = makeTranslator(zhCN as Dict) + +describe('inferGroupedSummaryIntent', () => { + it('treats file inspection shell commands as inspect-files intent', () => { + const tool = makeTool('shell-1', 'shell_command', { command: 'Get-ChildItem src -Recurse' }) + expect(inferGroupedSummaryIntent(tool)).toBe('inspect-files') + }) + + it('treats content search shell commands as search-content intent', () => { + const tool = makeTool('shell-2', 'Bash', { command: 'rg "TodoWrite" web/src' }) + expect(inferGroupedSummaryIntent(tool)).toBe('search-content') + }) +}) + +describe('formatGroupedRowLabel', () => { + it('returns a friendly english label without leaking raw shell command text', () => { + const tool = makeTool('shell-3', 'shell_command', { command: 'Get-ChildItem src -Recurse' }) + const label = formatGroupedRowLabel(tool, tEn) + + expect(label).toBe('Inspect project files') + expect(label).not.toContain('Get-ChildItem') + expect(label).not.toContain('src') + }) + + it('returns a friendly chinese label for command execution', () => { + const tool = makeTool('shell-4', 'Bash', { command: 'bun run build:web' }) + expect(formatGroupedRowLabel(tool, tZh)).toBe('执行项目命令') + }) +}) + +describe('formatGroupedHeaderTitle', () => { + it('adds a +n suffix for grouped file inspection activity', () => { + const group = makeGroup([ + makeTool('shell-1', 'shell_command', { command: 'Get-ChildItem src -Recurse' }), + makeTool('shell-2', 'shell_command', { command: 'Get-Content package.json' }), + makeTool('shell-3', 'shell_command', { command: 'dir web' }), + makeTool('shell-4', 'shell_command', { command: 'ls docs' }), + makeTool('shell-5', 'shell_command', { command: 'cat README.md' }), + ]) + + expect(formatGroupedHeaderTitle(group, tZh)).toBe('检查项目文件 +4') + }) +}) + +describe('formatGroupedHeaderSubtitle', () => { + it('keeps the aggregate counter line short and localized', () => { + const group = makeGroup([ + makeTool('shell-1', 'shell_command', { command: 'bun run build:web' }), + makeTool('shell-2', 'shell_command', { command: 'bun run test' }), + ]) + + expect(formatGroupedHeaderSubtitle(group, tEn)).toBe('Run 2') + expect(formatGroupedHeaderSubtitle(group, tZh)).toBe('执行 2') + }) +}) diff --git a/web/src/components/ToolCard/groupedPresentation.ts b/web/src/components/ToolCard/groupedPresentation.ts new file mode 100644 index 00000000..ceb04a04 --- /dev/null +++ b/web/src/components/ToolCard/groupedPresentation.ts @@ -0,0 +1,139 @@ +import type { ToolGroupBlock } from '@/chat/toolGroups' +import type { ToolCallBlock } from '@/chat/types' +import { getInputStringAny } from '@/lib/toolInputUtils' + +type Translator = (key: string, params?: Record) => string + +export type GroupedSummaryIntent = + | 'inspect-files' + | 'search-content' + | 'run-project-command' + | 'modify-files' + | 'open-web' + | 'generic-command' + | 'generic-tool' + +const FILE_INSPECTION_COMMAND_RE = /\b(get-childitem|ls|dir|get-content|cat|type|tree)\b/i +const CONTENT_SEARCH_COMMAND_RE = /\b(rg|grep|select-string|findstr)\b/i + +function getCommandText(input: unknown): string | null { + const direct = getInputStringAny(input, ['command', 'cmd']) + if (direct) return direct + + if (!input || typeof input !== 'object') return null + const command = (input as { command?: unknown }).command + if (!Array.isArray(command)) return null + + const parts = command.filter((part): part is string => typeof part === 'string' && part.length > 0) + return parts.length > 0 ? parts.join(' ') : null +} + +function getIntentLabel(intent: GroupedSummaryIntent, t: Translator): string { + switch (intent) { + case 'inspect-files': + return t('toolGroup.friendly.inspectFiles') + case 'search-content': + return t('toolGroup.friendly.searchContent') + case 'run-project-command': + return t('toolGroup.friendly.runCommands') + case 'modify-files': + return t('toolGroup.friendly.editFiles') + case 'open-web': + return t('toolGroup.friendly.openWeb') + case 'generic-command': + return t('toolGroup.friendly.genericCommand') + default: + return t('toolGroup.friendly.genericTool') + } +} + +export function inferGroupedSummaryIntent(tool: ToolCallBlock): GroupedSummaryIntent { + const toolName = tool.tool.name + const command = getCommandText(tool.tool.input) + + if (toolName === 'Read' || toolName === 'LS' || toolName === 'NotebookRead') { + return 'inspect-files' + } + if (toolName === 'Grep' || toolName === 'Glob') { + return 'search-content' + } + if (toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'Write' || toolName === 'NotebookEdit' || toolName === 'CodexPatch' || toolName === 'CodexDiff') { + return 'modify-files' + } + if (toolName === 'WebFetch' || toolName === 'WebSearch') { + return 'open-web' + } + + if (toolName === 'Bash' || toolName === 'CodexBash' || toolName === 'shell_command') { + if (command && FILE_INSPECTION_COMMAND_RE.test(command)) { + return 'inspect-files' + } + if (command && CONTENT_SEARCH_COMMAND_RE.test(command)) { + return 'search-content' + } + return 'run-project-command' + } + + return 'generic-tool' +} + +function getPrimaryIntent(block: ToolGroupBlock): GroupedSummaryIntent { + const counts = new Map() + const order: GroupedSummaryIntent[] = [] + + for (const tool of block.tools) { + const intent = inferGroupedSummaryIntent(tool) + if (!counts.has(intent)) { + order.push(intent) + } + counts.set(intent, (counts.get(intent) ?? 0) + 1) + } + + let primary: GroupedSummaryIntent = 'generic-tool' + let maxCount = -1 + + for (const intent of order) { + const count = counts.get(intent) ?? 0 + if (count > maxCount) { + primary = intent + maxCount = count + } + } + + return primary +} + +export function formatGroupedHeaderTitle(block: ToolGroupBlock, t: Translator): string { + const label = getIntentLabel(getPrimaryIntent(block), t) + const extraCount = block.tools.length - 1 + return extraCount > 0 ? `${label} +${extraCount}` : label +} + +export function formatGroupedHeaderSubtitle(block: ToolGroupBlock, t: Translator): string | null { + const parts: string[] = [] + + if (block.summary.countsByKind.command > 0) { + parts.push(t('toolGroup.summary.command', { n: block.summary.countsByKind.command })) + } + if (block.summary.countsByKind.search > 0) { + parts.push(t('toolGroup.summary.search', { n: block.summary.countsByKind.search })) + } + if (block.summary.countsByKind.read > 0) { + parts.push(t('toolGroup.summary.read', { n: block.summary.countsByKind.read })) + } + if (block.summary.countsByKind.mutation > 0) { + parts.push(t('toolGroup.summary.mutation', { n: block.summary.countsByKind.mutation })) + } + if (block.summary.countsByKind.web > 0) { + parts.push(t('toolGroup.summary.web', { n: block.summary.countsByKind.web })) + } + if (block.summary.countsByKind.other > 0) { + parts.push(t('toolGroup.summary.other', { n: block.summary.countsByKind.other })) + } + + return parts.length > 0 ? parts.join(' · ') : null +} + +export function formatGroupedRowLabel(tool: ToolCallBlock, t: Translator): string { + return getIntentLabel(inferGroupedSummaryIntent(tool), t) +} diff --git a/web/src/hooks/useChatSurfaceColors.test.ts b/web/src/hooks/useChatSurfaceColors.test.ts new file mode 100644 index 00000000..43d2ebff --- /dev/null +++ b/web/src/hooks/useChatSurfaceColors.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { + getChatSurfaceColorPickerValue, + getToolGroupBackgroundPreference, + getUserMessageBackgroundPreference, + initializeChatSurfaceColors, + toPresetChatSurfaceColorPreference, + useChatSurfaceColors, +} from '@/hooks/useChatSurfaceColors' + +describe('useChatSurfaceColors', () => { + beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + document.documentElement.style.removeProperty('--app-tool-group-bg') + document.documentElement.style.removeProperty('--app-chat-user-surface-bg') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('falls back to default when storage is missing or invalid', () => { + localStorage.setItem('hapi-tool-group-bg', 'preset:invalid') + localStorage.setItem('hapi-user-message-bg', 'custom:not-a-color') + + expect(getToolGroupBackgroundPreference()).toBe('default') + expect(getUserMessageBackgroundPreference()).toBe('default') + }) + + it('stores preset and custom preferences using stable string values', () => { + const { result } = renderHook(() => useChatSurfaceColors()) + + act(() => { + result.current.setToolGroupBackground(toPresetChatSurfaceColorPreference('soft-blue')) + result.current.setUserMessageBackground('custom:#88cc44') + }) + + expect(localStorage.getItem('hapi-tool-group-bg')).toBe('preset:soft-blue') + expect(localStorage.getItem('hapi-user-message-bg')).toBe('custom:#88cc44') + expect(result.current.toolGroupBackground).toBe('preset:soft-blue') + expect(result.current.userMessageBackground).toBe('custom:#88cc44') + }) + + it('applies root css variables only for non-default preferences', () => { + const { result } = renderHook(() => useChatSurfaceColors()) + + expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toBe('') + expect(document.documentElement.style.getPropertyValue('--app-chat-user-surface-bg')).toBe('') + + act(() => { + result.current.setToolGroupBackground('preset:soft-green') + result.current.setUserMessageBackground('custom:#88cc44') + }) + + expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toMatch(/^#/) + expect(document.documentElement.style.getPropertyValue('--app-chat-user-surface-bg')).toMatch(/^#/) + + act(() => { + result.current.setToolGroupBackground('default') + result.current.setUserMessageBackground('default') + }) + + expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toBe('') + expect(document.documentElement.style.getPropertyValue('--app-chat-user-surface-bg')).toBe('') + }) + + it('reapplies stored values during initialization', () => { + localStorage.setItem('hapi-tool-group-bg', 'preset:soft-yellow') + localStorage.setItem('hapi-user-message-bg', 'custom:#88cc44') + + initializeChatSurfaceColors() + + expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toMatch(/^#/) + expect(document.documentElement.style.getPropertyValue('--app-chat-user-surface-bg')).toMatch(/^#/) + }) + + it('returns a valid picker value for default, preset, and custom preferences', () => { + expect(getChatSurfaceColorPickerValue('default')).toBe('#f2f4f6') + expect(getChatSurfaceColorPickerValue('preset:soft-blue')).toBe('#7db7ff') + expect(getChatSurfaceColorPickerValue('custom:#88cc44')).toBe('#88cc44') + }) +}) diff --git a/web/src/hooks/useChatSurfaceColors.ts b/web/src/hooks/useChatSurfaceColors.ts new file mode 100644 index 00000000..f4402f16 --- /dev/null +++ b/web/src/hooks/useChatSurfaceColors.ts @@ -0,0 +1,263 @@ +import { useCallback, useEffect, useState } from 'react' + +type ThemeMode = 'light' | 'dark' +type SurfaceKey = 'tool-group' | 'user-message' + +export type ChatSurfaceColorPreset = 'default' | 'soft-blue' | 'soft-green' | 'soft-yellow' +export type ChatSurfaceColorPreference = 'default' | 'preset:soft-blue' | 'preset:soft-green' | 'preset:soft-yellow' | `custom:#${string}` + +export const DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE: ChatSurfaceColorPreference = 'default' + +const TOOL_GROUP_BG_STORAGE_KEY = 'hapi-tool-group-bg' +const USER_MESSAGE_BG_STORAGE_KEY = 'hapi-user-message-bg' +const TOOL_GROUP_BG_CSS_VAR = '--app-tool-group-bg' +const USER_MESSAGE_BG_CSS_VAR = '--app-chat-user-surface-bg' +const DEFAULT_PICKER_COLOR = '#f2f4f6' + +const PRESET_ACCENTS: Record, string> = { + 'soft-blue': '#7db7ff', + 'soft-green': '#8fd19e', + 'soft-yellow': '#f0d77a', +} + +const THEME_BASES: Record> = { + light: { + 'tool-group': '#f2f4f6', + 'user-message': '#f2f4f6', + }, + dark: { + 'tool-group': '#2b2f34', + 'user-message': '#2b2f34', + }, +} + +let initialized = false + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(key: string): string | null { + if (!isBrowser()) return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) return + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) return + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +function isHexColor(value: string): boolean { + return /^#[0-9a-f]{6}$/i.test(value) +} + +function normalizeHexColor(value: string): string | null { + const normalized = value.trim().toLowerCase() + return isHexColor(normalized) ? normalized : null +} + +function parseChatSurfaceColorPreference(raw: string | null): ChatSurfaceColorPreference { + if (raw === 'default' || raw === 'preset:soft-blue' || raw === 'preset:soft-green' || raw === 'preset:soft-yellow') { + return raw + } + + if (typeof raw === 'string' && raw.startsWith('custom:')) { + const normalized = normalizeHexColor(raw.slice('custom:'.length)) + if (normalized) { + return `custom:${normalized}` as ChatSurfaceColorPreference + } + } + + return DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE +} + +function getThemeMode(): ThemeMode { + if (!isBrowser()) return 'light' + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' +} + +function hexToRgb(hex: string): [number, number, number] { + const normalized = hex.replace('#', '') + return [ + Number.parseInt(normalized.slice(0, 2), 16), + Number.parseInt(normalized.slice(2, 4), 16), + Number.parseInt(normalized.slice(4, 6), 16), + ] +} + +function clampChannel(value: number): number { + return Math.max(0, Math.min(255, value)) +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${[r, g, b] + .map((channel) => clampChannel(channel).toString(16).padStart(2, '0')) + .join('')}` +} + +function mixHex(base: string, accent: string, ratio: number): string { + const [br, bg, bb] = hexToRgb(base) + const [ar, ag, ab] = hexToRgb(accent) + return rgbToHex( + Math.round(br + (ar - br) * ratio), + Math.round(bg + (ag - bg) * ratio), + Math.round(bb + (ab - bb) * ratio), + ) +} + +function getAccentColor(pref: ChatSurfaceColorPreference): string | null { + if (pref === 'default') return null + if (pref.startsWith('preset:')) { + return PRESET_ACCENTS[pref.slice('preset:'.length) as keyof typeof PRESET_ACCENTS] ?? null + } + return normalizeHexColor(pref.slice('custom:'.length)) +} + +function resolveSurfaceColor(pref: ChatSurfaceColorPreference, theme: ThemeMode, surface: SurfaceKey): string | null { + const accent = getAccentColor(pref) + if (!accent) return null + + const base = THEME_BASES[theme][surface] + const ratio = pref.startsWith('custom:') ? (theme === 'dark' ? 0.22 : 0.34) : (theme === 'dark' ? 0.2 : 0.3) + return mixHex(base, accent, ratio) +} + +function readStoredToolGroupBackground(): ChatSurfaceColorPreference { + return parseChatSurfaceColorPreference(safeGetItem(TOOL_GROUP_BG_STORAGE_KEY)) +} + +function readStoredUserMessageBackground(): ChatSurfaceColorPreference { + return parseChatSurfaceColorPreference(safeGetItem(USER_MESSAGE_BG_STORAGE_KEY)) +} + +function applyChatSurfaceVariables(): void { + if (!isBrowser()) return + + const theme = getThemeMode() + const rootStyle = document.documentElement.style + const toolGroupColor = resolveSurfaceColor(readStoredToolGroupBackground(), theme, 'tool-group') + const userMessageColor = resolveSurfaceColor(readStoredUserMessageBackground(), theme, 'user-message') + + if (toolGroupColor) rootStyle.setProperty(TOOL_GROUP_BG_CSS_VAR, toolGroupColor) + else rootStyle.removeProperty(TOOL_GROUP_BG_CSS_VAR) + + if (userMessageColor) rootStyle.setProperty(USER_MESSAGE_BG_CSS_VAR, userMessageColor) + else rootStyle.removeProperty(USER_MESSAGE_BG_CSS_VAR) +} + +function writePreference(key: string, value: ChatSurfaceColorPreference): void { + if (value === DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE) { + safeRemoveItem(key) + } else { + safeSetItem(key, value) + } + applyChatSurfaceVariables() +} + +export function getChatSurfaceColorPresetOptions(): ReadonlyArray<{ value: ChatSurfaceColorPreset; labelKey: string }> { + return [ + { value: 'default', labelKey: 'settings.chat.surfaceColor.default' }, + { value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' }, + { value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' }, + { value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' }, + ] +} + +export function toPresetChatSurfaceColorPreference(preset: ChatSurfaceColorPreset): ChatSurfaceColorPreference { + return preset === 'default' ? 'default' : (`preset:${preset}` as ChatSurfaceColorPreference) +} + +export function toCustomChatSurfaceColorPreference(value: string): ChatSurfaceColorPreference { + const normalized = normalizeHexColor(value) ?? DEFAULT_PICKER_COLOR + return `custom:${normalized}` as ChatSurfaceColorPreference +} + +export function getToolGroupBackgroundPreference(): ChatSurfaceColorPreference { + return readStoredToolGroupBackground() +} + +export function getUserMessageBackgroundPreference(): ChatSurfaceColorPreference { + return readStoredUserMessageBackground() +} + +export function getChatSurfaceColorPickerValue(pref: ChatSurfaceColorPreference): string { + return getAccentColor(pref) ?? DEFAULT_PICKER_COLOR +} + +export function initializeChatSurfaceColors(): void { + if (!isBrowser()) return + + applyChatSurfaceVariables() + + if (initialized) return + initialized = true + + window.addEventListener('storage', applyChatSurfaceVariables) + + const themeObserver = new MutationObserver(() => { + applyChatSurfaceVariables() + }) + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) +} + +export function useChatSurfaceColors(): { + toolGroupBackground: ChatSurfaceColorPreference + userMessageBackground: ChatSurfaceColorPreference + setToolGroupBackground: (value: ChatSurfaceColorPreference) => void + setUserMessageBackground: (value: ChatSurfaceColorPreference) => void +} { + const [toolGroupBackground, setToolGroupBackgroundState] = useState(getToolGroupBackgroundPreference) + const [userMessageBackground, setUserMessageBackgroundState] = useState(getUserMessageBackgroundPreference) + + useEffect(() => { + if (!isBrowser()) return + + const onStorage = (event: StorageEvent) => { + if (event.key !== TOOL_GROUP_BG_STORAGE_KEY && event.key !== USER_MESSAGE_BG_STORAGE_KEY) { + return + } + setToolGroupBackgroundState(readStoredToolGroupBackground()) + setUserMessageBackgroundState(readStoredUserMessageBackground()) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setToolGroupBackground = useCallback((value: ChatSurfaceColorPreference) => { + setToolGroupBackgroundState(value) + writePreference(TOOL_GROUP_BG_STORAGE_KEY, value) + }, []) + + const setUserMessageBackground = useCallback((value: ChatSurfaceColorPreference) => { + setUserMessageBackgroundState(value) + writePreference(USER_MESSAGE_BG_STORAGE_KEY, value) + }, []) + + return { + toolGroupBackground, + userMessageBackground, + setToolGroupBackground, + setUserMessageBackground, + } +} diff --git a/web/src/index.css b/web/src/index.css index ac15c345..d874750a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -15,10 +15,12 @@ --app-dialog-bg: #ffffff; --app-chat-user-bg: #f2f4f6; + --app-chat-user-surface-bg: var(--app-chat-user-bg); --app-chat-user-fg: #24292f; --app-chat-user-chip-bg: #DBEAFE; --app-chat-user-chip-fg: #1447E6; --app-tool-card-bg: #f2f4f6; + --app-tool-group-bg: var(--app-tool-card-bg); --app-tool-card-hover-bg: #e9edf2; --app-tool-card-accent: #7b8491; --app-tool-card-muted-action-fg: #aeb4bd; @@ -93,10 +95,12 @@ --app-dialog-bg: #202226; --app-chat-user-bg: #2b2f34; + --app-chat-user-surface-bg: var(--app-chat-user-bg); --app-chat-user-fg: #f5f7fa; --app-chat-user-chip-bg: rgba(37, 99, 235, 0.22); --app-chat-user-chip-fg: #93c5fd; --app-tool-card-bg: #2b2f34; + --app-tool-group-bg: var(--app-tool-card-bg); --app-tool-card-hover-bg: #343a41; --app-tool-card-accent: #b8c0cb; --app-tool-card-muted-action-fg: #7f8792; diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7dbb870b..64fad028 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -284,11 +284,18 @@ export default { 'toolGroup.summary.search': 'Search {n}', 'toolGroup.summary.web': 'Web {n}', 'toolGroup.summary.other': 'Tool {n}', + 'toolGroup.friendly.inspectFiles': 'Inspect project files', + 'toolGroup.friendly.searchContent': 'Search project content', + 'toolGroup.friendly.runCommands': 'Run project commands', + 'toolGroup.friendly.editFiles': 'Edit project files', + 'toolGroup.friendly.openWeb': 'Browse web content', + 'toolGroup.friendly.genericCommand': 'Run command', + 'toolGroup.friendly.genericTool': 'Use tool', 'toolGroup.badge.running': '{n} running', 'toolGroup.badge.pending': '{n} pending', 'toolGroup.badge.error': '{n} error', 'toolGroup.badge.fileTargets': '{n} files', - 'toolGroup.toolCount': '{n} tool calls', + 'toolGroup.toolCount': '{n} actions', 'toolGroup.loadingOlderHistory': 'Loading earlier tool activity…', 'toolGroup.historyUnavailable': 'Earlier tool activity is unavailable.', 'toolGroup.rowStatus.running': 'Running', @@ -372,6 +379,13 @@ export default { 'settings.chat.terminalToolDisplay': 'Terminal Tool Cards', 'settings.chat.terminalToolDisplay.compact': 'Compact (command only)', 'settings.chat.terminalToolDisplay.detailed': 'Detailed (show output preview)', + 'settings.chat.groupedToolBackground': 'Grouped Tool Use Background', + 'settings.chat.userMessageBackground': 'User Message Background', + 'settings.chat.surfaceColor.default': 'Default color', + 'settings.chat.surfaceColor.softBlue': 'Soft blue', + 'settings.chat.surfaceColor.softGreen': 'Soft green', + 'settings.chat.surfaceColor.softYellow': 'Soft yellow', + 'settings.chat.surfaceColor.custom': 'Custom color', 'settings.voice.title': 'Voice Assistant', 'settings.voice.language': 'Voice Language', 'settings.voice.autoDetect': 'Auto-detect', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 47313c84..1141a9d6 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -286,11 +286,18 @@ export default { 'toolGroup.summary.search': '搜索 {n}', 'toolGroup.summary.web': '访问 {n}', 'toolGroup.summary.other': '工具 {n}', + 'toolGroup.friendly.inspectFiles': '检查项目文件', + 'toolGroup.friendly.searchContent': '搜索项目内容', + 'toolGroup.friendly.runCommands': '执行项目命令', + 'toolGroup.friendly.editFiles': '修改项目文件', + 'toolGroup.friendly.openWeb': '浏览网页内容', + 'toolGroup.friendly.genericCommand': '执行命令', + 'toolGroup.friendly.genericTool': '使用工具', 'toolGroup.badge.running': '运行中 {n}', 'toolGroup.badge.pending': '等待中 {n}', 'toolGroup.badge.error': '错误 {n}', 'toolGroup.badge.fileTargets': '{n} 文件', - 'toolGroup.toolCount': '{n} 次 tool use', + 'toolGroup.toolCount': '{n} 次操作', 'toolGroup.loadingOlderHistory': '正在补加载更早的工具活动…', 'toolGroup.historyUnavailable': '更早的工具活动已不可用。', 'toolGroup.rowStatus.running': '运行中', @@ -374,6 +381,13 @@ export default { 'settings.chat.terminalToolDisplay': '终端工具卡片', 'settings.chat.terminalToolDisplay.compact': '简洁(仅命令)', 'settings.chat.terminalToolDisplay.detailed': '详细(显示输出预览)', + 'settings.chat.groupedToolBackground': '聚合 Tool Use 背景', + 'settings.chat.userMessageBackground': '用户消息背景', + 'settings.chat.surfaceColor.default': '默认颜色', + 'settings.chat.surfaceColor.softBlue': '柔和蓝', + 'settings.chat.surfaceColor.softGreen': '柔和绿', + 'settings.chat.surfaceColor.softYellow': '柔和黄', + 'settings.chat.surfaceColor.custom': '自定义颜色', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', 'settings.voice.autoDetect': '自动检测', diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index 96758832..c3d2a000 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -51,6 +51,24 @@ vi.mock('@/hooks/useTerminalToolDisplayMode', () => ({ ], })) +vi.mock('@/hooks/useChatSurfaceColors', () => ({ + useChatSurfaceColors: () => ({ + toolGroupBackground: 'default', + userMessageBackground: 'preset:soft-blue', + setToolGroupBackground: vi.fn(), + setUserMessageBackground: vi.fn(), + }), + getChatSurfaceColorPresetOptions: () => [ + { value: 'default', labelKey: 'settings.chat.surfaceColor.default' }, + { value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' }, + { value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' }, + { value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' }, + ], + getChatSurfaceColorPickerValue: () => '#7db7ff', + toPresetChatSurfaceColorPreference: (value: string) => value === 'default' ? 'default' : `preset:${value}`, + toCustomChatSurfaceColorPreference: (value: string) => `custom:${value}`, +})) + // Mock useTheme hook vi.mock('@/hooks/useTheme', () => ({ useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }), @@ -172,6 +190,17 @@ describe('SettingsPage', () => { expect(screen.getAllByText('Compact (command only)').length).toBeGreaterThanOrEqual(1) }) + it('renders grouped tool and user message background settings', () => { + renderWithProviders() + expect(screen.getAllByText('Grouped Tool Use Background').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('User Message Background').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Default color').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Soft blue').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Soft green').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Soft yellow').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByLabelText('Custom color').length).toBeGreaterThanOrEqual(2) + }) + it('uses correct i18n keys for the Enter Key setting', () => { const spyT = renderWithSpyT() const calledKeys = spyT.mock.calls.map((call) => call[0]) @@ -180,5 +209,8 @@ describe('SettingsPage', () => { expect(calledKeys).toContain('settings.chat.enterBehavior.send') expect(calledKeys).toContain('settings.chat.terminalToolDisplay') expect(calledKeys).toContain('settings.chat.terminalToolDisplay.compact') + expect(calledKeys).toContain('settings.chat.groupedToolBackground') + expect(calledKeys).toContain('settings.chat.userMessageBackground') + expect(calledKeys).toContain('settings.chat.surfaceColor.default') }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 1dced931..a3e6d00a 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -6,6 +6,15 @@ import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFo import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize' import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode, type TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' +import { + getChatSurfaceColorPickerValue, + getChatSurfaceColorPresetOptions, + toCustomChatSurfaceColorPreference, + toPresetChatSurfaceColorPreference, + useChatSurfaceColors, + type ChatSurfaceColorPreference, + type ChatSurfaceColorPreset, +} from '@/hooks/useChatSurfaceColors' import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' import { PROTOCOL_VERSION } from '@hapi/protocol' @@ -73,6 +82,63 @@ function ChevronDownIcon(props: { className?: string }) { ) } +function ChatSurfaceColorControl(props: { + label: string + preference: ChatSurfaceColorPreference + onPresetChange: (preset: ChatSurfaceColorPreset) => void + onCustomChange: (value: string) => void + t: (key: string) => string +}) { + const presetOptions = getChatSurfaceColorPresetOptions() + const pickerValue = getChatSurfaceColorPickerValue(props.preference) + const isCustomSelected = props.preference.startsWith('custom:') + + return ( +
+
{props.label}
+
+ {presetOptions.map((option) => { + const selected = props.preference === toPresetChatSurfaceColorPreference(option.value) + const swatchColor = getChatSurfaceColorPickerValue(toPresetChatSurfaceColorPreference(option.value)) + return ( + + ) + })} +
+
+ {props.t('settings.chat.surfaceColor.custom')} + +
+
+ ) +} + export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() const goBack = useAppGoBack() @@ -94,6 +160,12 @@ export default function SettingsPage() { const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() + const { + toolGroupBackground, + userMessageBackground, + setToolGroupBackground, + setUserMessageBackground, + } = useChatSurfaceColors() const { appearance, setAppearance } = useAppearance() // Voice language state - read from localStorage @@ -530,6 +602,20 @@ export default function SettingsPage() { )} + setToolGroupBackground(toPresetChatSurfaceColorPreference(preset))} + onCustomChange={(value) => setToolGroupBackground(toCustomChatSurfaceColorPreference(value))} + t={t} + /> + setUserMessageBackground(toPresetChatSurfaceColorPreference(preset))} + onCustomChange={(value) => setUserMessageBackground(toCustomChatSurfaceColorPreference(value))} + t={t} + /> {/* Voice Assistant section */}