From f9624107a58adfd8c246ea5c66f7d5464f7d2d70 Mon Sep 17 00:00:00 2001
From: truongthinh1412 <9260650+truongthinh1412@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:12:53 +0700
Subject: [PATCH] refactor(ui): migrate app chrome icons to the shared icon
module (tranche 1)
---
src/App.tsx | 12 +-
src/components/NewTaskPlaceholder.tsx | 5 +-
src/components/Sidebar.tsx | 58 ++-----
src/components/SidebarFooter.tsx | 59 +------
src/components/TaskAITerminal.tsx | 39 ++---
src/components/TaskTitleBar.tsx | 35 ++--
src/components/TilingLayout.tsx | 22 +--
src/components/UpdateButton.tsx | 38 +----
src/components/WindowTitleBar.tsx | 26 +--
src/components/WorkspaceControls.tsx | 17 +-
src/components/icons.client.test.tsx | 94 +++++++++++
src/components/icons.tsx | 228 ++++++++++++++++++++++++++
12 files changed, 381 insertions(+), 252 deletions(-)
create mode 100644 src/components/icons.client.test.tsx
diff --git a/src/App.tsx b/src/App.tsx
index e1afc2e69..fef12a586 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -163,7 +163,7 @@ import { startAgentHookStatusListener } from './store/agentHookStatus';
import { applyPlanContent } from './store/canvas';
import { startBackgroundChecks } from './store/background-checks';
import type { PlanContentMessage } from './store/canvas';
-import { CircleCheckIcon, SyncIcon } from './components/icons';
+import { CircleCheckIcon, GitHubMarkIcon, SyncIcon } from './components/icons';
const ThinkingSpace = lazy(() =>
import('./components/thinking/ThinkingSpace').then((m) => ({ default: m.ThinkingSpace })),
@@ -193,15 +193,7 @@ function DropOverlay() {
>
{/* Drag events hide the link until drop, so both kinds are announced. */}
-
+
diff --git a/src/components/NewTaskPlaceholder.tsx b/src/components/NewTaskPlaceholder.tsx
index d2288f500..9594f5022 100644
--- a/src/components/NewTaskPlaceholder.tsx
+++ b/src/components/NewTaskPlaceholder.tsx
@@ -3,6 +3,7 @@ import { toggleNewTaskPanel, createTerminal, unfocusPlaceholder } from '../store
import { registerFocusFn, unregisterFocusFn } from '../store/focus';
import { theme } from '../lib/theme';
import { mod } from '../lib/platform';
+import { PlusLargeIcon } from './icons';
import { withViewTransition } from '../lib/viewTransition';
/** Quiet ghost surface: a faint fill instead of a dashed wireframe border, so
@@ -63,9 +64,7 @@ export function NewTaskPlaceholder() {
style={{ ...ghostStyle, flex: '1' }}
title={`New task (${mod}+N)`}
>
-
+
{/* Terminal button — same width, fixed height */}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 014d8dd78..fbcb1a49d 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -3,7 +3,7 @@ import { TaskProjectFilter } from './TaskProjectFilter';
import { createProjectFilterTransition } from '../lib/projectFilterTransition';
import { createSignal, createEffect, createMemo, onMount, onCleanup, For, Show } from 'solid-js';
import type { JSX } from 'solid-js';
-import { ClockIcon } from './icons';
+import { BotIcon, ChevronDownAltIcon, ClockIcon, FolderIcon, PlusLargeIcon } from './icons';
import { createTaskContextMenu } from './task-context-menu';
import { errMessage } from '../lib/log';
import {
@@ -119,17 +119,7 @@ function createOffscreenAttentionState(taskId: () => string) {
/** Small bot/coordinator icon (16x16 SVG). */
function CoordinatorIcon() {
- return (
-
- );
+ return ;
}
function DirectBranchBadge(props: { branchName: string }) {
@@ -223,20 +213,14 @@ export function ProjectTaskGroupToggle(props: {
gap: '5px',
}}
>
-
+ />
-
+ />
-
-
- }
+ icon={}
onClick={handleAddProject}
title="Add project"
size="sm"
@@ -835,15 +809,7 @@ export function Sidebar() {
width: '100%',
}}
>
-
+
Add project
}
@@ -867,9 +833,7 @@ export function Sidebar() {
width: '100%',
}}
>
-
+
New Task
diff --git a/src/components/SidebarFooter.tsx b/src/components/SidebarFooter.tsx
index a519051c4..c674b2376 100644
--- a/src/components/SidebarFooter.tsx
+++ b/src/components/SidebarFooter.tsx
@@ -11,6 +11,7 @@ import {
} from '../store/store';
import { theme } from '../lib/theme';
import { alt, mod } from '../lib/platform';
+import { IdeaIcon, KeyboardIcon, PhoneIcon, ScrambleIcon } from './icons';
/** Compact utilities and optional activity totals, shared by every theme. */
export function SidebarFooter(props: { onConnectPhone: () => void }) {
@@ -70,20 +71,7 @@ export function SidebarFooter(props: { onConnectPhone: () => void }) {
phoneConnected() ? 'Phone connected: manage remote access' : 'Connect phone'
}
>
-
+
Phone access
@@ -95,20 +83,7 @@ export function SidebarFooter(props: { onConnectPhone: () => void }) {
type="button"
class="sidebar-footer-action"
>
-
+
Arena
@@ -142,18 +104,7 @@ export function SidebarFooter(props: { onConnectPhone: () => void }) {
title={`Keyboard shortcuts (${mod}+/). Switch panels with ${alt}+Arrows.`}
>
{mod} /
diff --git a/src/components/TaskAITerminal.tsx b/src/components/TaskAITerminal.tsx
index 41e05af56..227017803 100644
--- a/src/components/TaskAITerminal.tsx
+++ b/src/components/TaskAITerminal.tsx
@@ -44,7 +44,16 @@ import { editAsDocument } from '../documents/store';
import { saveState } from '../store/persistence';
import { registerAction, unregisterAction } from '../store/focus';
import { ConfirmDialog } from './ConfirmDialog';
-import { CloseIcon, CommentIcon, PlayIcon, SyncIcon, TerminalIcon } from './icons';
+import {
+ CloseIcon,
+ ColumnsIcon,
+ CommentIcon,
+ PlayIcon,
+ PlusSmallIcon,
+ SyncIcon,
+ TerminalAltIcon,
+ TerminalIcon,
+} from './icons';
import { theme } from '../lib/theme';
import { sf } from '../lib/fontScale';
import { getTaskDockerOverlayLabel } from '../lib/docker';
@@ -414,31 +423,11 @@ export function TaskAITerminal(props: TaskAITerminalProps) {
when={tabsMode()}
fallback={
/* Currently side-by-side → click switches to tabs (one panel). */
-
+
}
>
{/* Currently tabbed → click switches to side-by-side columns. */}
-
+
@@ -647,9 +636,7 @@ function AddAgentMenu(props: { taskId: string }) {
padding: '0',
}}
>
-
+
}
>
-
+
{props.pushing ? 'Pushing…' : props.pushSuccess ? 'Pushed' : 'Finish'}
0 || generating().length > 0)}>
@@ -213,19 +220,7 @@ export function TaskTitleBar(props: TaskTitleBarProps) {
-
-
-
- }
+ icon={}
onClick={() =>
isTaskCanvasVisible(props.task)
? closeTaskCanvas(props.task.id)
@@ -235,11 +230,7 @@ export function TaskTitleBar(props: TaskTitleBarProps) {
pressed={isTaskCanvasVisible(props.task)}
/>
-
-
- }
+ icon={}
onClick={() => {
const taskId = props.task.id;
// The button bypasses the title bar's tap-to-activate; activate before the
diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx
index b18287920..f11f5fcb9 100644
--- a/src/components/TilingLayout.tsx
+++ b/src/components/TilingLayout.tsx
@@ -46,7 +46,7 @@ import { mod } from '../lib/platform';
import { createCtrlShiftWheelResizeHandler } from '../lib/wheelZoom';
import { prefersMotion } from '../lib/reducedMotion';
import { TASK_TILE_DEFAULT_WIDTH, TASK_TILE_MIN_WIDTH } from '../lib/layout-sizes';
-import { SyncIcon } from './icons';
+import { FolderIcon, SyncIcon } from './icons';
// Lazy: the document workspace (CodeMirror, parse5) loads when its panel opens.
const DocumentWorkspacePanel = lazy(() =>
@@ -664,15 +664,7 @@ export function TilingLayout() {
color: theme.fgSubtle,
}}
>
-
+
pickAndAddProject()}
style={emptyStateButtonStyle()}
>
-
+
Add project
>
diff --git a/src/components/UpdateButton.tsx b/src/components/UpdateButton.tsx
index 673e07997..1b3f8a850 100644
--- a/src/components/UpdateButton.tsx
+++ b/src/components/UpdateButton.tsx
@@ -11,41 +11,7 @@
import { Show } from 'solid-js';
import { theme } from '../lib/theme';
import { updateStatus, downloadUpdate, installUpdate } from '../store/store';
-
-const DownloadIcon = () => (
-
-);
-
-const RestartIcon = () => (
-
-);
+import { DownloadAltIcon, RefreshIcon } from './icons';
export function UpdateButton() {
const phase = () => updateStatus().phase;
@@ -94,7 +60,7 @@ export function UpdateButton() {
>
:
}
+ fallback={phase() === 'downloaded' ?
:
}
>
{updateStatus().downloadPercent}%
diff --git a/src/components/WindowTitleBar.tsx b/src/components/WindowTitleBar.tsx
index eca16459b..c094ef084 100644
--- a/src/components/WindowTitleBar.tsx
+++ b/src/components/WindowTitleBar.tsx
@@ -3,7 +3,7 @@ import { WorkspaceControls } from './WorkspaceControls';
import { appWindow } from '../lib/window';
import { FocusModeTaskIndicators } from './FocusModeTaskIndicators';
import { TaskBoardControls } from './TaskBoardControls';
-import { AppLogo } from './icons';
+import { AppLogo, CloseThinIcon, MaximizeIcon, MinimizeIcon, RestoreIcon } from './icons';
/** `bare` keeps only the window's own controls, for the welcome screen. */
export function WindowTitleBar(props: { bare?: boolean }) {
@@ -109,9 +109,7 @@ export function WindowTitleBar(props: { bare?: boolean }) {
aria-label="Minimize window"
title="Minimize"
>
-
+
diff --git a/src/components/WorkspaceControls.tsx b/src/components/WorkspaceControls.tsx
index 27bc2eb6a..2ea8231b4 100644
--- a/src/components/WorkspaceControls.tsx
+++ b/src/components/WorkspaceControls.tsx
@@ -11,6 +11,7 @@ import {
} from '../store/store';
import { mod } from '../lib/platform';
import { withViewTransition } from '../lib/viewTransition';
+import { ChevronLeftAltIcon, GearIcon } from './icons';
import { FeatureHint } from './FeatureHint';
import { IconButton } from './IconButton';
import { ToggleButton, ToggleGroup } from './ToggleGroup';
@@ -132,25 +133,15 @@ export function WorkspaceControls() {
>
-
-
+ />
}
onClick={() => toggleSidebar()}
title={`${store.sidebarVisible ? 'Collapse' : 'Show'} sidebar (${mod}+B)`}
/>
-
-
- }
+ icon={}
onClick={() => toggleSettingsDialog(true)}
title={`Settings (${mod}+,)`}
/>
diff --git a/src/components/icons.client.test.tsx b/src/components/icons.client.test.tsx
new file mode 100644
index 000000000..2db3688cc
--- /dev/null
+++ b/src/components/icons.client.test.tsx
@@ -0,0 +1,94 @@
+import type { JSX } from 'solid-js';
+import { render } from 'solid-js/web';
+import { afterEach, describe, expect, it } from 'vitest';
+import * as icons from './icons';
+
+type IconComponent = (props: { size?: number; title?: string }) => JSX.Element;
+
+const disposers: Array<() => void> = [];
+
+afterEach(() => {
+ while (disposers.length > 0) disposers.pop()?.();
+ document.body.replaceChildren();
+});
+
+/** `EvidenceNoteIcon` draws its own 16x18 box at a 14px default and ignores
+ * `title`, and `AppLogo` is sized by CSS with no size/title props at all, so
+ * neither fits the standard square-icon assertions below. Both still get a
+ * render-smoke check at the bottom of this file. */
+const NONSTANDARD_EXPORTS = new Set(['EvidenceNoteIcon', 'AppLogo']);
+
+/** Every standard exported icon component, so the module stays render-covered. */
+// Match by export key, not `fn.name`: CI's dev transform wraps components and
+// renames them to `[solid-refresh]`, which slipped past the exemption.
+const ALL_ICONS = Object.entries(icons)
+ .filter(([name]) => !NONSTANDARD_EXPORTS.has(name))
+ .map(([, icon]) => icon);
+
+describe('shared icon module', () => {
+ it('renders every icon at its default size of 16 and hides it from the a11y tree', () => {
+ for (const Icon of ALL_ICONS) {
+ const container = document.createElement('div');
+ document.body.append(container);
+ disposers.push(render(() => , container));
+
+ const svg = container.querySelector('svg');
+ expect(svg, Icon.name).not.toBeNull();
+ expect(svg?.getAttribute('width'), Icon.name).toBe('16');
+ expect(svg?.getAttribute('height'), Icon.name).toBe('16');
+ expect(svg?.getAttribute('aria-hidden'), Icon.name).toBe('true');
+ expect(svg?.getAttribute('role'), Icon.name).toBeNull();
+ expect(svg?.querySelector('title'), Icon.name).toBeNull();
+ }
+ });
+
+ it('honors a custom size (12, the small size from issue #208)', () => {
+ for (const Icon of ALL_ICONS) {
+ const container = document.createElement('div');
+ document.body.append(container);
+ disposers.push(render(() => , container));
+
+ const svg = container.querySelector('svg');
+ expect(svg?.getAttribute('width'), Icon.name).toBe('12');
+ expect(svg?.getAttribute('height'), Icon.name).toBe('12');
+ }
+ });
+
+ it('keeps the monochrome currentColor contract', () => {
+ for (const Icon of ALL_ICONS) {
+ const container = document.createElement('div');
+ document.body.append(container);
+ disposers.push(render(() => , container));
+
+ const svg = container.querySelector('svg');
+ const fill = svg?.getAttribute('fill');
+ expect(['currentColor', 'none'], Icon.name).toContain(fill);
+ if (fill === 'none') {
+ expect(svg?.getAttribute('stroke'), Icon.name).toBe('currentColor');
+ }
+ }
+ });
+
+ it('renders a title with role="img" when a title is provided', () => {
+ for (const Icon of ALL_ICONS) {
+ const container = document.createElement('div');
+ document.body.append(container);
+ disposers.push(render(() => , container));
+
+ const svg = container.querySelector('svg');
+ expect(svg?.getAttribute('aria-hidden'), Icon.name).toBeNull();
+ expect(svg?.getAttribute('role'), Icon.name).toBe('img');
+ expect(svg?.querySelector('title')?.textContent, Icon.name).toBe('Test label');
+ }
+ });
+
+ it('still renders the non-standard exports without throwing', () => {
+ for (const name of NONSTANDARD_EXPORTS) {
+ const Icon = (icons as unknown as Record)[name];
+ const container = document.createElement('div');
+ document.body.append(container);
+ disposers.push(render(() => , container));
+ expect(container.querySelector('svg'), name).not.toBeNull();
+ }
+ });
+});
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 1aa9ec219..1f47c1efb 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -716,3 +716,231 @@ export function AppLogo(props: { class?: string }): JSX.Element {
);
}
+interface StrokeSvgIconProps extends IconProps {
+ children: JSX.Element;
+ viewBox?: string;
+ strokeWidth: number | string;
+ roundCaps?: boolean;
+ roundJoins?: boolean;
+}
+
+/** Stroke-drawn glyphs (fill="none"): attributes mirror what the call sites pasted. */
+function StrokeSvgIcon(props: StrokeSvgIconProps): JSX.Element {
+ const size = () => props.size ?? 16;
+
+ return (
+
+ );
+}
+
+/** Thin 10x10 window controls (custom title bar). */
+export function MinimizeIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+export function MaximizeIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+export function RestoreIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+export function CloseThinIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Coordinator/bot marker shown next to agent-driven tasks. */
+export function BotIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Filled chevron for collapsible section headers; see also ChevronDownIcon above. */
+export function ChevronDownAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Chunky plus for "create" affordances; PlusIcon above is the inline-text variant. */
+export function PlusLargeIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Compact plus that stays legible at 13px and below. */
+export function PlusSmallIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Keyboard: the shortcuts cheat-sheet affordance in the sidebar footer. */
+export function KeyboardIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Side panel with a divider: toggles the task canvas. */
+export function PanelRightIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Corners-in brackets: shown while unfocused as the enter-focus affordance in the document workspace, and always on the task title bar. */
+export function CollapseAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Check inside a circle: the Finish (merge or push) button's idle glyph. */
+export function CheckCircleIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Terminal window chrome (panel-layout toggle); see also the prompt-style TerminalIcon above. */
+export function TerminalAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Two side-by-side panels: the split-terminal layout toggle. */
+export function ColumnsIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Phone handset: remote-access affordances. */
+export function PhoneIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Crossing strokes: the Arena (agent-vs-agent) affordance. */
+export function ScrambleIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Stroke-drawn download; see also the filled DownloadIcon above. */
+export function DownloadAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+
+ );
+}
+
+/** Circular arrow: restart to install an update. */
+export function RefreshIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** The GitHub octocat mark; GitHubIcon above is this project's own glyph. */
+export function GitHubMarkIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Gear: project settings — name, folder and agents. */
+export function GearIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Filled octicon chevron for commit-step navigation; see also the stroke ChevronLeftIcon above. */
+export function ChevronLeftAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}