-
-
-
- }
+ icon={}
onClick={() =>
isTaskCanvasVisible(props.task)
? closeTaskCanvas(props.task.id)
@@ -219,11 +213,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 85019d0f5..1568833fe 100644
--- a/src/components/TilingLayout.tsx
+++ b/src/components/TilingLayout.tsx
@@ -45,7 +45,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(() =>
@@ -653,15 +653,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..d6ad32004
--- /dev/null
+++ b/src/components/icons.client.test.tsx
@@ -0,0 +1,90 @@
+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. */
+const ALL_ICONS = Object.values(icons).filter((icon) => !NONSTANDARD_EXPORTS.has(icon.name));
+
+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 7844ba168..c182a2821 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -680,3 +680,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 (
+
+
+
+ );
+}
From 043e7b19a1179d72ccc6113a0e9ed277d95af0e1 Mon Sep 17 00:00:00 2001
From: truongthinh1412 <9260650+truongthinh1412@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:19:47 +0700
Subject: [PATCH 2/4] refactor(ui): migrate documents and arena icons to the
shared module (tranche 2)
---
src/arena/ArenaOverlay.tsx | 15 +-
src/arena/BattleScreen.tsx | 5 +-
src/arena/ConfigScreen.tsx | 17 +-
src/arena/HistoryScreen.tsx | 27 +--
src/arena/ResultsScreen.tsx | 113 ++--------
src/components/CanvasTabStrip.tsx | 10 +-
src/components/ChangeTourButton.tsx | 38 +---
src/components/MarkdownViewerDialog.tsx | 3 +-
src/components/NewTaskPanel.tsx | 22 +-
src/components/Sidebar.tsx | 10 +-
src/components/TaskAITerminal.tsx | 3 +-
src/components/TerminalSelectionMenu.tsx | 3 +-
src/components/file-link-menu.tsx | 3 +-
src/components/icons.tsx | 212 ++++++++++++++++++
src/components/project-menu.tsx | 12 +-
src/documents/AnnotationMarker.tsx | 20 +-
src/documents/BlockActions.tsx | 33 +--
src/documents/DocumentIcon.tsx | 19 --
src/documents/DocumentWorkspacePanel.tsx | 91 +++-----
src/documents/FileTreePanel.tsx | 16 +-
.../RunComposer.draft.client.test.tsx | 31 +++
src/documents/RunComposer.tsx | 6 +-
22 files changed, 373 insertions(+), 336 deletions(-)
delete mode 100644 src/documents/DocumentIcon.tsx
diff --git a/src/arena/ArenaOverlay.tsx b/src/arena/ArenaOverlay.tsx
index f8553b5cf..964a8d05f 100644
--- a/src/arena/ArenaOverlay.tsx
+++ b/src/arena/ArenaOverlay.tsx
@@ -5,6 +5,7 @@ import './arena-battle.css';
import './arena-results.css';
import './arena-history.css';
import { Show, onMount } from 'solid-js';
+import { ScrambleIcon } from '../components/icons';
import { arenaStore } from './store';
import { loadArenaPresets, loadArenaHistory } from './persistence';
import { ConfigScreen } from './ConfigScreen';
@@ -54,19 +55,7 @@ export function ArenaOverlay(props: ArenaOverlayProps) {
>
-
+
AI Arena
diff --git a/src/arena/ConfigScreen.tsx b/src/arena/ConfigScreen.tsx
index 06f6f254c..b47a192a3 100644
--- a/src/arena/ConfigScreen.tsx
+++ b/src/arena/ConfigScreen.tsx
@@ -19,7 +19,7 @@ import { invoke } from '../lib/ipc';
import { IPC } from '../../electron/ipc/channels';
import { saveArenaPresets } from './persistence';
import { ProjectSelect } from '../components/ProjectSelect';
-import { CloseIcon, HistoryIcon, PlusIcon } from '../components/icons';
+import { CloseIcon, HistoryIcon, PlusIcon, ScrambleIcon } from '../components/icons';
import { MAX_COMPETITORS, MIN_COMPETITORS } from './store';
import type { BattleCompetitor } from './types';
@@ -220,20 +220,7 @@ export function ConfigScreen() {
{/* Actions */}
diff --git a/src/arena/HistoryScreen.tsx b/src/arena/HistoryScreen.tsx
index f0bac0db5..a23c656c5 100644
--- a/src/arena/HistoryScreen.tsx
+++ b/src/arena/HistoryScreen.tsx
@@ -4,6 +4,7 @@ import { saveArenaHistory } from './persistence';
import { formatDuration } from './utils';
import { confirm } from '../lib/dialog';
import { invoke } from '../lib/ipc';
+import { ChevronLeftThinIcon, TrashAltIcon } from '../components/icons';
import { IPC } from '../../electron/ipc/channels';
function formatDate(iso: string): string {
@@ -82,18 +83,7 @@ export function HistoryScreen() {
class="arena-close-btn"
onClick={() => setPhase(arenaStore.previousPhase ?? 'config')}
>
-
+
Back
@@ -123,18 +113,7 @@ export function HistoryScreen() {
title="Delete match and clean up worktrees"
>
...}>
-
+
diff --git a/src/arena/ResultsScreen.tsx b/src/arena/ResultsScreen.tsx
index b6b14cc30..37d506920 100644
--- a/src/arena/ResultsScreen.tsx
+++ b/src/arena/ResultsScreen.tsx
@@ -1,4 +1,14 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
+import {
+ ChevronLeftThinIcon,
+ ClockAltIcon,
+ CompareIcon,
+ MergeIcon,
+ PlusThinIcon,
+ BoxIcon,
+ StarIcon,
+ SyncAltIcon,
+} from '../components/icons';
import { ChangedFilesList } from '../components/ChangedFilesList';
import { DiffViewerDialog } from '../components/DiffViewerDialog';
import { CommitDialog } from './CommitDialog';
@@ -259,9 +269,7 @@ export function ResultsScreen() {
onClick={() => setRating(competitor.id, star)}
title={`${star} star${star > 1 ? 's' : ''}`}
>
-
+
)}
@@ -280,21 +288,7 @@ export function ResultsScreen() {
disabled={merge.merging() || merge.mergedId() !== null}
onClick={() => merge.handleMergeClick(competitor)}
>
-
+
{merge.merging() ? 'Merging...' : 'Merge'}
@@ -312,104 +306,33 @@ export function ResultsScreen() {
-
+
{projectLabel()}
diff --git a/src/components/CanvasTabStrip.tsx b/src/components/CanvasTabStrip.tsx
index ea54b7a44..1af72f88a 100644
--- a/src/components/CanvasTabStrip.tsx
+++ b/src/components/CanvasTabStrip.tsx
@@ -4,13 +4,19 @@ import { sf } from '../lib/fontScale';
import { canvasTabKey } from '../lib/canvas-tabs';
import type { CanvasTab, CanvasTabKind } from '../store/types';
import { IconButton } from './IconButton';
-import { CloseIcon, ExpandIcon, ExternalLinkIcon, PlusIcon, ScreenNormalIcon } from './icons';
+import {
+ CloseIcon,
+ DocumentIcon,
+ ExpandIcon,
+ ExternalLinkIcon,
+ PlusIcon,
+ ScreenNormalIcon,
+} from './icons';
import { UnderstandButton, tourButtonStyle } from './understanding/UnderstandButton';
import type { UnderstandingTourController } from '../lib/create-understanding-tour';
import { isTourFilePath } from '../lib/tour-file';
import { isEditableDocumentPath } from '../lib/document-path';
import { focusFirstMenuItem, handleMenuKeyDown } from '../lib/menu-keys';
-import { DocumentIcon } from '../documents/DocumentIcon';
interface CanvasTabStripProps {
tabs: CanvasTab[];
diff --git a/src/components/ChangeTourButton.tsx b/src/components/ChangeTourButton.tsx
index 13656f0bf..21fcfd182 100644
--- a/src/components/ChangeTourButton.tsx
+++ b/src/components/ChangeTourButton.tsx
@@ -14,6 +14,7 @@ import { activeFeatureHint } from '../store/onboarding';
import { CloseIcon } from './icons';
import { askCodeLabel } from './understanding/ask-code-label';
import { TourModelMenu } from './understanding/TourModelMenu';
+import { ArrowRightThinIcon, CycleIcon } from './icons';
/** Hover help waits this long, so a pointer passing over the button does not flash it. */
const HELP_HOVER_DELAY_MS = 500;
@@ -149,26 +150,7 @@ export function ChangeTourButton(props: {
-
-
-
-
- }
- >
+ }>
{/* Hidden, not removed, so the label keeps its width while the tour generates. */}
-
+ />
{props.children}
diff --git a/src/components/MarkdownViewerDialog.tsx b/src/components/MarkdownViewerDialog.tsx
index 76975f8f4..5e5964461 100644
--- a/src/components/MarkdownViewerDialog.tsx
+++ b/src/components/MarkdownViewerDialog.tsx
@@ -1,7 +1,8 @@
+import { DocumentIcon } from './icons';
import { Show, createSignal, onCleanup } from 'solid-js';
import type { JSX } from 'solid-js';
import { IPC } from '../../electron/ipc/channels';
-import { DocumentIcon } from '../documents/DocumentIcon';
+
import { editAsDocument } from '../documents/store';
import { isEditableDocumentPath } from '../lib/document-path';
import { sf } from '../lib/fontScale';
diff --git a/src/components/NewTaskPanel.tsx b/src/components/NewTaskPanel.tsx
index 1ac9c5b44..67720946a 100644
--- a/src/components/NewTaskPanel.tsx
+++ b/src/components/NewTaskPanel.tsx
@@ -24,7 +24,15 @@ import { createFocusRestore } from '../lib/focus-restore';
import { topDialog } from '../lib/dialog-stack';
import { registerFocusFn, unregisterFocusFn } from '../store/focused-panel';
import { setStore } from '../store/core';
-import { ContainerIcon, FolderIcon, GitBranchIcon, GitHubIcon, PlusIcon, SyncIcon } from './icons';
+import {
+ ChevronRightThinIcon,
+ ContainerIcon,
+ FolderIcon,
+ GitBranchIcon,
+ GitHubIcon,
+ PlusIcon,
+ SyncIcon,
+} from './icons';
import { ConfirmDialog } from './ConfirmDialog';
import { errMessage } from '../lib/log';
import { withViewTransition } from '../lib/viewTransition';
@@ -1584,16 +1592,10 @@ export function NewTaskPanel(props: NewTaskPanelProps) {
cursor: 'pointer',
}}
>
-
+ />
Advanced options
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 90e938b96..7bca8921d 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -3,7 +3,14 @@ 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 { BotIcon, ChevronDownAltIcon, ClockIcon, FolderIcon, PlusLargeIcon } from './icons';
+import {
+ BotIcon,
+ ChevronDownAltIcon,
+ ClockIcon,
+ DocumentIcon,
+ FolderIcon,
+ PlusLargeIcon,
+} from './icons';
import { createTaskContextMenu } from './task-context-menu';
import { errMessage } from '../lib/log';
import {
@@ -47,7 +54,6 @@ import { EditProjectDialog } from './EditProjectDialog';
import { AddProjectDialog } from '../documents/AddProjectDialog';
import { openDocumentWorkspace } from '../documents/store';
import { codeProjects, isDocumentProject } from '../store/projects';
-import { DocumentIcon } from '../documents/DocumentIcon';
import { ImportWorktreesDialog } from './ImportWorktreesDialog';
import { SidebarFooter } from './SidebarFooter';
import { IconButton } from './IconButton';
diff --git a/src/components/TaskAITerminal.tsx b/src/components/TaskAITerminal.tsx
index 5e9594bf1..292b88283 100644
--- a/src/components/TaskAITerminal.tsx
+++ b/src/components/TaskAITerminal.tsx
@@ -1,4 +1,5 @@
-import { Show, For, createSignal, createEffect, onMount, onCleanup, untrack } from 'solid-js';
+import {
+ Show, For, createSignal, createEffect, onMount, onCleanup, untrack } from 'solid-js';
import type { TranscriptMarks } from '../investigation/transcript';
import {
diff --git a/src/components/TerminalSelectionMenu.tsx b/src/components/TerminalSelectionMenu.tsx
index 5b65fdcd8..0d7ebf158 100644
--- a/src/components/TerminalSelectionMenu.tsx
+++ b/src/components/TerminalSelectionMenu.tsx
@@ -1,8 +1,7 @@
import { createSignal, For, onMount, Show, untrack } from 'solid-js';
import type { JSX } from 'solid-js';
import { Portal } from 'solid-js/web';
-import { CopyIcon, EyeIcon, SendIcon } from './icons';
-import { DocumentIcon } from '../documents/DocumentIcon';
+import { CopyIcon, DocumentIcon, EyeIcon, SendIcon } from './icons';
/** An entry of a clicked link's menu; the menu closes after `run`. */
export interface TerminalLinkAction {
diff --git a/src/components/file-link-menu.tsx b/src/components/file-link-menu.tsx
index dc794e722..14def0fa6 100644
--- a/src/components/file-link-menu.tsx
+++ b/src/components/file-link-menu.tsx
@@ -1,7 +1,8 @@
+import { DocumentIcon } from './icons';
import { IPC } from '../../electron/ipc/channels';
import { isExecutablePath } from '../../electron/shared/executable-path';
import { editAsDocument } from '../documents/store';
-import { DocumentIcon } from '../documents/DocumentIcon';
+
import { isMarkdownPath } from '../lib/canvas-tabs';
import { invoke } from '../lib/ipc';
import { errMessage } from '../lib/log';
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index c182a2821..d59a75bd2 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -908,3 +908,215 @@ export function ChevronLeftAltIcon(props: IconProps): JSX.Element {
);
}
+/** Stroke-drawn bar: minimize the document workspace back to the sidebar. */
+export function MinimizeBarIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Filled octicon chevron for commit-step navigation; see also the stroke ChevronRightIcon above. */
+export function ChevronRightAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Sharp stroke chevron on the Advanced options disclosure; see also ChevronRightIcon above. */
+export function ChevronRightThinIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Rounded stroke chevron for Arena back buttons; see also ChevronLeftIcon above. */
+export function ChevronLeftThinIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Corners-out brackets: shown while focus mode is active, as the exit-focus affordance; pairs with CollapseAltIcon. */
+export function ExpandAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Margins with in/out arrows: the reading-width toggle in the document toolbar. */
+export function FullWidthIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Outline folder: reveal the project folder in the file manager; FolderIcon above is filled. */
+export function FolderOpenIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** The document-project glyph: sidebar rows, the workspace header and the file tree. */
+export function DocumentIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Stroke-drawn note card in the annotation margin; CommentIcon above is the speech bubble. */
+export function CommentAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Note card with lines: the composer's note mode. */
+export function NoteIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Circled question mark: the composer's ask mode. */
+export function QuestionIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Filled square: stop a running arena competitor; StopIcon above is the octagon. */
+export function StopAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Stroke-drawn trash bin: delete a history match; TrashIcon above is the filled one. */
+export function TrashAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Five-pointed star: rate an arena competitor's approach. */
+export function StarIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Cradle joining two nodes: merge a worktree into the project's main branch. */
+export function MergeIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** Box with a center seam: the arena project's container; ShieldIcon above is the filled badge. */
+export function BoxIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Two ruled columns: compare every arena approach side by side. */
+export function CompareIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Chasing arrows: run the arena match again; SyncIcon above is the filled one. */
+export function SyncAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** Stroke-drawn plus: start a fresh arena match. */
+export function PlusThinIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Stroke-drawn clock face: arena match history; ClockIcon above is the filled one. */
+export function ClockAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** Two nodes joined by a loop: generate a tour of the changes. */
+export function CycleIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+
+ );
+}
+
+
+/** Stroke-drawn arrow: the tour button's go affordance once it is idle. */
+export function ArrowRightThinIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Rounded send-arrow for the prompt composer's submit button. */
diff --git a/src/components/project-menu.tsx b/src/components/project-menu.tsx
index cf6cfe31e..b2af7e34c 100644
--- a/src/components/project-menu.tsx
+++ b/src/components/project-menu.tsx
@@ -8,9 +8,15 @@ import { isDocumentProject } from '../store/projects';
import { isProjectMissing, showNotification, startProjectTask, store } from '../store/store';
import type { Project } from '../store/types';
import { ContextMenu, type MoreMenuItem } from './chat/MoreMenu';
-import { DocumentIcon } from '../documents/DocumentIcon';
-import { FolderIcon, GitHubIcon, PencilIcon, PlanIcon, PlusIcon, TrashIcon } from './icons';
-
+import {
+ DocumentIcon,
+ FolderIcon,
+ GitHubIcon,
+ PencilIcon,
+ PlanIcon,
+ PlusIcon,
+ TrashIcon,
+} from './icons';
/** Dialogs the menu opens; whoever renders the menu owns them. */
export interface ProjectMenuDialogs {
editSettings: (project: Project) => void;
diff --git a/src/documents/AnnotationMarker.tsx b/src/documents/AnnotationMarker.tsx
index a35de063f..6c7a705bb 100644
--- a/src/documents/AnnotationMarker.tsx
+++ b/src/documents/AnnotationMarker.tsx
@@ -9,6 +9,7 @@ import {
import { AnnotationBubble } from './AnnotationBubble';
import type { DocumentAnnotation } from './types';
import { focusAnnotation, registerPinnedBubble, workspaceUi } from './workspace-ui';
+import { CommentAltIcon } from '../components/icons';
interface AnnotationMarkerProps {
/** Portalled controls must follow their owning panel’s visibility. */
@@ -172,24 +173,7 @@ export function AnnotationMarker(props: AnnotationMarkerProps) {
>
{/* A question waiting on its agent spins in the margin, so the wait is
visible without opening the bubble to read "Answering…". */}
-
-
-
-
- }
- >
+ }>
1}>
diff --git a/src/documents/BlockActions.tsx b/src/documents/BlockActions.tsx
index fe63bcc5f..a77b2841f 100644
--- a/src/documents/BlockActions.tsx
+++ b/src/documents/BlockActions.tsx
@@ -1,7 +1,17 @@
-import { For, createEffect, createSignal, onCleanup, type Accessor, type JSX } from 'solid-js';
+import {
+ For,
+ Show,
+ createEffect,
+ createSignal,
+ onCleanup,
+ type Accessor,
+ type Component,
+ type JSX,
+} from 'solid-js';
import { Portal } from 'solid-js/web';
import { createAnchorEffect } from '../lib/floating';
import type { ComposerMode } from './store';
+import { GitGraphIcon, NoteIcon, QuestionIcon } from '../components/icons';
/** Passage actions; direct editing is an explicit option in the composer. */
export type BlockActionKind = Exclude;
@@ -11,8 +21,8 @@ interface BlockAction {
title: string;
/** The key that does the same on a selected passage, in the workspace. */
shortcut?: string;
- /** 16×16 icon path. */
- path: string;
+ /** The action's glyph, the app's shared icon-module component for it. */
+ icon: Component<{ size?: number | string }>;
}
/** Labeled passage actions. */
@@ -20,32 +30,29 @@ export const BLOCK_ACTIONS: readonly BlockAction[] = [
{
kind: 'proposals',
title: 'Rewrite with AI',
- path: 'M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z',
+ icon: GitGraphIcon,
},
{
kind: 'note',
title: 'Add comment',
shortcut: 'C',
- path: 'M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z',
+ icon: NoteIcon,
},
{
kind: 'question',
title: 'Ask about this passage',
shortcut: 'A',
- path: 'M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
+ icon: QuestionIcon,
},
];
-function iconPath(kind: BlockActionKind): string {
- return BLOCK_ACTIONS.find((a) => a.kind === kind)?.path ?? '';
-}
-
/** One action's glyph, shared by the block toolbar and composer. */
export function ActionIcon(props: { kind: BlockActionKind }) {
+ const icon = () => BLOCK_ACTIONS.find((a) => a.kind === props.kind)?.icon;
return (
-
+
+ {(Icon) => }
+
);
}
diff --git a/src/documents/DocumentIcon.tsx b/src/documents/DocumentIcon.tsx
deleted file mode 100644
index 1c4c2afb4..000000000
--- a/src/documents/DocumentIcon.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-/** The document-project glyph: shared by the sidebar row and the workspace header. */
-export function DocumentIcon(props: { size?: number }) {
- return (
-
- );
-}
diff --git a/src/documents/DocumentWorkspacePanel.tsx b/src/documents/DocumentWorkspacePanel.tsx
index f0c43531d..3b3ffd2a5 100644
--- a/src/documents/DocumentWorkspacePanel.tsx
+++ b/src/documents/DocumentWorkspacePanel.tsx
@@ -73,11 +73,22 @@ import { MarkdownEditor } from './MarkdownEditor';
import { afterSavingMarkdown, readMarkdownDraft } from './markdown-editing';
import { ResizablePanel, type PanelChild } from '../components/ResizablePanel';
import { createRenderedBlocks } from './use-blocks';
-import { DocumentIcon } from './DocumentIcon';
import { ActionIcon } from './BlockActions';
import { Dialog } from '../components/Dialog';
import { openInEditor, revealItemInDir } from '../lib/shell';
-import { UndoIcon } from '../components/icons';
+import {
+ CollapseAltIcon,
+ DocumentIcon,
+ ExpandAltIcon,
+ ExternalLinkIcon,
+ FolderOpenIcon,
+ FullWidthIcon,
+ GearIcon,
+ GitGraphIcon,
+ MinimizeBarIcon,
+ PanelRightIcon,
+ UndoIcon,
+} from '../components/icons';
import {
setDocumentFullWidth,
toggleTaskFocusMode,
@@ -578,17 +589,7 @@ function DocumentPane(props: { project: Project; toolbarSlot: HTMLElement | unde
}
onClick={() => setDocumentFullWidth(!store.documentFullWidth)}
>
-
+
@@ -1068,7 +1069,7 @@ export function DocumentWorkspacePanel() {
-
+ {project()?.name}{(quick) => }
@@ -1096,15 +1097,7 @@ export function DocumentWorkspacePanel() {
/>
-
+
{snapshot()?.branch ?? 'detached'} ·{' '}
{snapshot()?.headSha?.slice(0, 7) ?? 'no commits'}
@@ -1124,9 +1117,7 @@ export function DocumentWorkspacePanel() {
disabled={!editorCommand() || !openPath()}
onClick={() => void openDocumentInEditor()}
>
-
+
{/* The sidebar opens a document project as this workspace, so its
settings are reachable only from here. */}
@@ -1150,9 +1139,7 @@ export function DocumentWorkspacePanel() {
disabled={!project()}
onClick={() => setEditing(project() ?? null)}
>
-
+
-
+ {/* Unfocused shows the corners-in glyph (enter focus); focused shows corners-out. */}
+ }>
+
+
{/* Minimize, not close: the project stays open in the sidebar and the
workspace reopens from there, while an X on a task means closing it
@@ -1219,17 +1188,7 @@ export function DocumentWorkspacePanel() {
title="Minimize · reopen from the sidebar"
onClick={() => closeDocumentWorkspace()}
>
-
+
diff --git a/src/documents/FileTreePanel.tsx b/src/documents/FileTreePanel.tsx
index 061d6b4b9..706f4bc8c 100644
--- a/src/documents/FileTreePanel.tsx
+++ b/src/documents/FileTreePanel.tsx
@@ -2,7 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal, on } from 'solid-js'
import { activeDocumentPath, documentStore, openDocumentFile } from './store';
import { loadDocumentFiles, workspaceUi } from './workspace-ui';
import { buildPathTree, type PathTreeNode } from './path-tree';
-import { DocumentIcon } from './DocumentIcon';
+import { ChevronRightAltIcon, DocumentIcon } from '../components/icons';
const DOCUMENT_RE = /\.(md|markdown|html?)$/i;
/** Mirrors MAX_FILES in electron/documents/files.ts: a list this long was cut. */
@@ -16,16 +16,10 @@ interface FileTreePanelProps {
function Chevron(props: { open: boolean }) {
return (
-
+ />
);
}
@@ -81,7 +75,7 @@ export function FileTreePanel(props: FileTreePanelProps) {
>
·}>
-
+ {nodeProps.node.name}
diff --git a/src/documents/RunComposer.draft.client.test.tsx b/src/documents/RunComposer.draft.client.test.tsx
index a1ceafeae..4b1dd59a7 100644
--- a/src/documents/RunComposer.draft.client.test.tsx
+++ b/src/documents/RunComposer.draft.client.test.tsx
@@ -1,6 +1,8 @@
import { render } from 'solid-js/web';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setStore } from '../store/core';
+import { GitGraphIcon, NoteIcon, QuestionIcon } from '../components/icons';
+import { ActionIcon, BLOCK_ACTIONS } from './BlockActions';
import { RunComposer } from './RunComposer';
import { documentStore, setDocumentComposerDraft, type DocumentSelection } from './store';
import type { DocumentBlock } from './markdown-blocks';
@@ -39,6 +41,35 @@ const passage: DocumentSelection = {
wholeDocument: false,
};
+describe('block action glyphs', () => {
+ it('points every action at its shared icon-module component', () => {
+ expect(BLOCK_ACTIONS.find((a) => a.kind === 'proposals')?.icon).toBe(GitGraphIcon);
+ expect(BLOCK_ACTIONS.find((a) => a.kind === 'note')?.icon).toBe(NoteIcon);
+ expect(BLOCK_ACTIONS.find((a) => a.kind === 'question')?.icon).toBe(QuestionIcon);
+ });
+
+ it('renders the same glyph body as the module icon itself', () => {
+ for (const action of BLOCK_ACTIONS) {
+ const host = document.createElement('div');
+ document.body.append(host);
+ const Icon = action.icon;
+ disposers.push(
+ render(
+ () => (
+ <>
+
+
+ >
+ ),
+ host,
+ ),
+ );
+ const [fromAction, fromModule] = Array.from(host.querySelectorAll('path'));
+ expect(fromAction.getAttribute('d'), action.kind).toBe(fromModule.getAttribute('d'));
+ }
+ });
+});
+
describe('RunComposer draft mode', () => {
it('opens on the mode a hover action asked for', () => {
setDocumentComposerDraft({ text: '', mode: 'note' });
diff --git a/src/documents/RunComposer.tsx b/src/documents/RunComposer.tsx
index 31a6c57b0..509dba231 100644
--- a/src/documents/RunComposer.tsx
+++ b/src/documents/RunComposer.tsx
@@ -39,7 +39,7 @@ import type { DocumentModelChoice } from '../store/types';
import { ModelRows, type ChoiceSlot, type ModelSlot } from './ModelRows';
import { buildInteractivePrompt } from './interactive-prompt';
import { sendToDocumentAgent } from './agent-task';
-import { SendIcon, SparkleIcon } from '../components/icons';
+import { CloseIcon, SendIcon, SparkleIcon } from '../components/icons';
interface RunComposerProps {
/** The picked passage; null means the whole document. */
@@ -484,9 +484,7 @@ export function RunComposer(props: RunComposerProps) {
title="Close · Esc"
onClick={close}
>
-
+
From 31e0a875a93321e88aa0b1c16aa4cf3e714b3ee3 Mon Sep 17 00:00:00 2001
From: truongthinh1412 <9260650+truongthinh1412@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:25:45 +0700
Subject: [PATCH 3/4] refactor(ui): migrate task component icons to the shared
module (tranche 3)
---
src/components/AskCodeCard.tsx | 3 +-
src/components/BranchPrefixField.tsx | 21 +-----
src/components/ChangedFilesList.tsx | 26 ++-----
src/components/CommitNavBar.tsx | 9 +--
src/components/MergeReadinessPanel.tsx | 11 +--
src/components/PlanViewerDialog.tsx | 6 +-
src/components/PromptInput.tsx | 11 +--
src/components/ScrollingDiffView.tsx | 5 +-
src/components/TaskBranchAdoptionBanner.tsx | 4 +-
src/components/TaskBranchInfoBar.tsx | 32 ++-------
src/components/TaskNotesBody.tsx | 11 +--
src/components/icons.tsx | 28 ++++++++
src/remote/TaskActionsDialog.client.test.tsx | 72 ++++++++++++++++++++
src/remote/TaskActionsDialog.tsx | 21 +++---
14 files changed, 146 insertions(+), 114 deletions(-)
create mode 100644 src/remote/TaskActionsDialog.client.test.tsx
diff --git a/src/components/AskCodeCard.tsx b/src/components/AskCodeCard.tsx
index 066f0a7e3..2a09ce6c8 100644
--- a/src/components/AskCodeCard.tsx
+++ b/src/components/AskCodeCard.tsx
@@ -6,6 +6,7 @@ import { IPC } from '../../electron/ipc/channels';
import { askCodeEnvFile } from '../../electron/shared/ask-code-models';
import { store } from '../store/store';
import { warn as logWarn, errMessage } from '../lib/log';
+import { CloseIcon } from './icons';
interface AskCodeCardProps {
requestId: string;
@@ -132,7 +133,7 @@ export function AskCodeCard(props: AskCodeCardProps) {
}}
title="Dismiss"
>
- ×
+
diff --git a/src/components/BranchPrefixField.tsx b/src/components/BranchPrefixField.tsx
index fe897b31d..6d1570668 100644
--- a/src/components/BranchPrefixField.tsx
+++ b/src/components/BranchPrefixField.tsx
@@ -1,5 +1,6 @@
import { Show } from 'solid-js';
import { theme } from '../lib/theme';
+import { FolderIcon, GitBranchIcon } from './icons';
interface BranchPrefixFieldProps {
branchPrefix: string;
@@ -54,27 +55,11 @@ export function BranchPrefixField(props: BranchPrefixFieldProps) {
}}
>
-
+
{props.branchPreview}
-
+
{props.projectPath}/.worktrees/{props.branchPreview}
diff --git a/src/components/ChangedFilesList.tsx b/src/components/ChangedFilesList.tsx
index 4996326b1..149e889a4 100644
--- a/src/components/ChangedFilesList.tsx
+++ b/src/components/ChangedFilesList.tsx
@@ -8,6 +8,7 @@ import { getStatusColor } from '../lib/status-colors';
import { openFileInEditor } from '../lib/shell';
import { isMarkdownPath } from '../lib/canvas-tabs';
import { buildFileTree, flattenVisibleTree } from '../lib/file-tree';
+import { ExternalLinkIcon, PanelRightIcon, QuestionAltIcon } from './icons';
import {
buildCoverageComparison,
formatCoverageDelta,
@@ -362,9 +363,7 @@ function OpenInEditorButton(props: {
title="Open in editor"
aria-label={`Open ${props.filePath} in editor`}
>
-
+
);
}
@@ -409,14 +408,7 @@ function UnderstandFileButton(props: {
}}
aria-label={`Understand ${props.filePath}`}
>
-
-
-
- }
- >
+ }>
@@ -449,17 +441,7 @@ function OpenMarkdownButton(props: { filePath: string; onOpenMarkdownClick?: ()
title="Open in canvas"
aria-label={`Open ${props.filePath} in canvas`}
>
-
+
);
}
diff --git a/src/components/CommitNavBar.tsx b/src/components/CommitNavBar.tsx
index 658d3ed12..6976ce9ec 100644
--- a/src/components/CommitNavBar.tsx
+++ b/src/components/CommitNavBar.tsx
@@ -3,6 +3,7 @@ import { theme } from '../lib/theme';
import { sf } from '../lib/fontScale';
import { accentControlColors } from '../lib/controlStyle';
import type { CommitInfo } from '../ipc/types';
+import { ChevronLeftAltIcon, ChevronRightAltIcon } from './icons';
/**
* Sentinel value used in place of a commit hash to mean "show only currently
@@ -146,9 +147,7 @@ export function CommitNavBar(props: CommitNavBarProps) {
'flex-shrink': '0',
}}
>
-
+
{/* Chevron Right */}
@@ -175,9 +174,7 @@ export function CommitNavBar(props: CommitNavBarProps) {
'flex-shrink': '0',
}}
>
-
+
{/* Uncommitted-only button */}
diff --git a/src/components/MergeReadinessPanel.tsx b/src/components/MergeReadinessPanel.tsx
index a2149099b..948564872 100644
--- a/src/components/MergeReadinessPanel.tsx
+++ b/src/components/MergeReadinessPanel.tsx
@@ -1,6 +1,7 @@
-import { For, Show } from 'solid-js';
+import { For, Show, type JSX } from 'solid-js';
import { theme } from '../lib/theme';
import { sf } from '../lib/fontScale';
+import { CheckIcon, CloseIcon } from './icons';
import type { MergeReadinessCheck, MergeReadinessCheckStatus } from './merge-readiness';
function checkHelp(label: string): string | undefined {
@@ -29,9 +30,11 @@ function statusColor(status: MergeReadinessCheckStatus): string {
return theme.fgMuted;
}
-function statusSymbol(status: MergeReadinessCheckStatus): string {
- if (status === 'pass') return '✓';
- if (status === 'blocked') return '×';
+/** Built per call, so every rendered row owns its icon node (never share JSX
+ * elements across `` rows: Solid moves the node, the rest lose it). */
+function statusSymbol(status: MergeReadinessCheckStatus): JSX.Element {
+ if (status === 'pass') return ;
+ if (status === 'blocked') return ;
if (status === 'warning') return '!';
if (status === 'checking') return '…';
return '—';
diff --git a/src/components/PlanViewerDialog.tsx b/src/components/PlanViewerDialog.tsx
index 9c7a081cd..b730ff3c7 100644
--- a/src/components/PlanViewerDialog.tsx
+++ b/src/components/PlanViewerDialog.tsx
@@ -7,7 +7,7 @@ import { ReviewCommentsButton, ReviewSidebarPanel } from './ReviewSidebarPanel';
import { ReviewCommentCard } from './ReviewCommentCard';
import { InlineInput } from './InlineInput';
import { AskCodeCard } from './AskCodeCard';
-import { CloseIcon } from './icons';
+import { CloseIcon, ExternalLinkIcon } from './icons';
import { createHighlightedMarkdown } from '../lib/marked-shiki';
import { renderMermaidIn } from '../lib/mermaid';
import {
@@ -349,9 +349,7 @@ function PlanViewerContent(props: PlanViewerContentProps) {
}}
title="Open in editor"
>
-
+
diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx
index c4895dc83..8918ab595 100644
--- a/src/components/PromptInput.tsx
+++ b/src/components/PromptInput.tsx
@@ -47,6 +47,7 @@ import {
import { taskUsesAgentChat } from '../store/agent-chat';
import { isLandedTaskState } from '../store/landing';
import { processAutoFireTick } from './autofire-tick';
+import { ArrowUpIcon } from './icons';
import {
resolveAutoSendVerifyOutcome,
shouldAckInitialPromptDelivery,
@@ -1046,15 +1047,7 @@ export function PromptInput(props: PromptInputProps) {
}}
title="Send prompt"
>
-
+
-
+
diff --git a/src/components/TaskBranchAdoptionBanner.tsx b/src/components/TaskBranchAdoptionBanner.tsx
index 2b5aa43a0..c0edfbe93 100644
--- a/src/components/TaskBranchAdoptionBanner.tsx
+++ b/src/components/TaskBranchAdoptionBanner.tsx
@@ -2,7 +2,7 @@ import { Show, type JSX } from 'solid-js';
import { undoBranchAdoption, dismissBranchAdoptionNotice } from '../store/store';
import { theme } from '../lib/theme';
import type { Task } from '../store/types';
-import { UndoIcon } from './icons';
+import { CloseIcon, UndoIcon } from './icons';
const bannerBtnStyle: JSX.CSSProperties = {
background: 'transparent',
@@ -59,7 +59,7 @@ export function TaskBranchAdoptionBanner(props: { task: Task }) {
style={{ ...bannerBtnStyle, border: 'none', padding: '1px 4px', 'font-size': '13px' }}
onClick={() => dismissBranchAdoptionNotice(props.task.id)}
>
- ×
+
)}
diff --git a/src/components/TaskBranchInfoBar.tsx b/src/components/TaskBranchInfoBar.tsx
index 5505276ce..5adae0a9c 100644
--- a/src/components/TaskBranchInfoBar.tsx
+++ b/src/components/TaskBranchInfoBar.tsx
@@ -19,7 +19,7 @@ import { parseGitHubUrl } from '../lib/github-url';
import { abbreviateHomePath } from '../lib/path';
import { projectInitials } from '../lib/project-initials';
import type { Task } from '../store/types';
-import { GitMergeIcon, ToolsIcon } from './icons';
+import { FolderIcon, GitBranchIcon, GitMergeIcon, GitHubMarkIcon, ToolsIcon } from './icons';
import { PrChip } from './PrChip';
import { menuPointFor, ProjectMenu } from './project-menu';
import { RemoveProjectConfirm } from './RemoveProjectConfirm';
@@ -322,15 +322,7 @@ export function TaskBranchInfoBar(props: TaskBranchInfoBarProps) {
>
Source
-
+ {githubLabel(url())}{compactSourceLabel(url())}
@@ -344,15 +336,7 @@ export function TaskBranchInfoBar(props: TaskBranchInfoBarProps) {
onClick={handleOpenInEditor}
style={{ ...infoBarBtnStyle, 'margin-right': '12px' }}
>
-
+ {props.task.branchName}
@@ -396,15 +380,7 @@ export function TaskBranchInfoBar(props: TaskBranchInfoBarProps) {
aria-label={`Worktree folder: ${abbreviateHomePath(props.task.worktreePath)}`}
style={{ ...infoBarBtnStyle, opacity: 0.6 }}
>
-
+
-
+
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index d59a75bd2..8dca1ff51 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -1120,3 +1120,31 @@ export function ArrowRightThinIcon(props: IconProps): JSX.Element {
}
/** Rounded send-arrow for the prompt composer's submit button. */
+/** Rounded send-arrow for the prompt composer's submit button. */
+export function ArrowUpIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Rounded send-arrow for the task-notes composer's submit button. */
+export function ArrowDownIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Simplified question mark: the file Understand affordance; the octicon-style question differs. */
+export function QuestionAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Stroke-drawn padlock: a mind-map node the user has edited. */
diff --git a/src/remote/TaskActionsDialog.client.test.tsx b/src/remote/TaskActionsDialog.client.test.tsx
new file mode 100644
index 000000000..47d13aeaf
--- /dev/null
+++ b/src/remote/TaskActionsDialog.client.test.tsx
@@ -0,0 +1,72 @@
+import { render } from 'solid-js/web';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { TaskActionsDialog } from './TaskActionsDialog';
+import type { ReadinessCheck } from './api';
+
+// The dialog is driven entirely by the readiness resource; hand it a fixed
+// payload so the rows below render deterministically.
+vi.mock('./api', () => ({
+ fetchMergeReadiness: vi.fn(() =>
+ Promise.resolve({
+ readiness: {
+ overall: 'ready',
+ checks: [
+ { label: 'Working tree clean', detail: 'Nothing uncommitted.', status: 'pass' },
+ { label: 'Verify passed', detail: 'All checks green.', status: 'pass' },
+ { label: 'Up to date', detail: 'No conflicts with the base.', status: 'pass' },
+ ] satisfies ReadinessCheck[],
+ },
+ canMerge: true,
+ deleteBranchOnClose: undefined,
+ }),
+ ),
+ mergeTask: vi.fn(),
+ closeTask: vi.fn(),
+ ApiError: class ApiError extends Error {},
+}));
+
+const disposers: Array<() => void> = [];
+
+afterEach(() => {
+ while (disposers.length > 0) disposers.pop()?.();
+ document.body.replaceChildren();
+});
+
+describe('TaskActionsDialog readiness symbols', () => {
+ it('gives every row of the same status its own icon node', async () => {
+ const host = document.createElement('div');
+ document.body.append(host);
+ disposers.push(
+ render(
+ () => (
+ {}}
+ onNeedsPairing={() => {}}
+ onDone={() => {}}
+ />
+ ),
+ host,
+ ),
+ );
+
+ // Wait for the resource to resolve and the rows to render.
+ await vi.waitFor(() => {
+ expect(host.querySelectorAll('.mobile-readiness-row').length).toBe(3);
+ });
+
+ // Regression: a module-level JSX element is moved between rows by
+ // Solid, so only the last row kept its svg. Each row now builds its own.
+ const rows = Array.from(host.querySelectorAll('.mobile-readiness-row'));
+ expect(rows.length).toBe(3);
+ for (const row of rows) {
+ expect(
+ row.querySelector('.mobile-readiness-symbol svg'),
+ row.textContent ?? '',
+ ).not.toBeNull();
+ }
+ expect(host.querySelectorAll('.mobile-readiness-symbol svg').length).toBe(3);
+ });
+});
diff --git a/src/remote/TaskActionsDialog.tsx b/src/remote/TaskActionsDialog.tsx
index ffcb9b7bf..a3df6e7c3 100644
--- a/src/remote/TaskActionsDialog.tsx
+++ b/src/remote/TaskActionsDialog.tsx
@@ -1,4 +1,5 @@
-import { createSignal, createResource, For, Show } from 'solid-js';
+import { createSignal, createResource, For, Show, type JSX } from 'solid-js';
+import { CheckIcon, CloseIcon } from '../components/icons';
import {
fetchMergeReadiness,
mergeTask as mergeTaskRequest,
@@ -27,12 +28,16 @@ const OVERALL_COPY: Record = {
checking: { title: 'Checking', detail: 'Waiting for merge status.' },
};
-const SYMBOL: Record = {
- pass: '✓',
- warning: '!',
- blocked: '×',
- checking: '…',
- neutral: '—',
+/** Per-status symbol factories: every readiness row calls one and builds its
+ * own DOM node. A shared JSX element at module level would be MOVED between
+ * `` rows by Solid's reconciliation — with three rows, only the last row
+ * kept its icon. */
+const SYMBOL: Record JSX.Element> = {
+ pass: () => ,
+ warning: () => '!',
+ blocked: () => ,
+ checking: () => '…',
+ neutral: () => '—',
};
function statusClass(status: ReadinessCheck['status'] | string): string {
@@ -167,7 +172,7 @@ export function TaskActionsDialog(props: TaskActionsDialogProps) {
class={`mobile-readiness-row mobile-readiness-${statusClass(check.status)}`}
>
- {SYMBOL[check.status]}
+ {SYMBOL[check.status]()}
{check.label}
From 145b28a77fea4fbae15ac4f4e9db85025d221915 Mon Sep 17 00:00:00 2001
From: truongthinh1412 <9260650+truongthinh1412@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:31:07 +0700
Subject: [PATCH 4/4] refactor(ui): migrate remaining upstream call sites to
the shared icon module (tranche 4)
---
src/components/BoardSidePanel.tsx | 19 ++---
src/components/MarkdownViewerDialog.tsx | 6 +-
src/components/PromptHistory.tsx | 15 +---
src/components/StatusDot.tsx | 16 +----
src/components/TaskShellSection.tsx | 18 +----
src/components/chat/Composer.tsx | 22 +-----
src/components/icons.tsx | 87 +++++++++++++++++++++++
src/components/task-later-menu.tsx | 16 +++--
src/components/understanding/TourCard.tsx | 13 +---
9 files changed, 114 insertions(+), 98 deletions(-)
diff --git a/src/components/BoardSidePanel.tsx b/src/components/BoardSidePanel.tsx
index 5968d8413..a4024d5cb 100644
--- a/src/components/BoardSidePanel.tsx
+++ b/src/components/BoardSidePanel.tsx
@@ -4,6 +4,7 @@ import { store } from '../store/core';
import { closeBoardSidePanel } from '../store/project-pages';
import { deletePanelUserSize, getPanelUserSize, setPanelUserSize } from '../store/ui';
import { focusBoardCard } from './task-board-keys';
+import { ChevronRightSmallIcon } from './icons';
const WIDTH_KEY = 'board:side-panel';
const MIN_WIDTH = 360;
@@ -52,22 +53,10 @@ export function BoardSidePanelClose() {
onClick={() => closeSidePanel()}
>
{/* Points the way the panel leaves: towards its own edge of the window. */}
-
+ />
);
diff --git a/src/components/MarkdownViewerDialog.tsx b/src/components/MarkdownViewerDialog.tsx
index 5e5964461..862d6fe84 100644
--- a/src/components/MarkdownViewerDialog.tsx
+++ b/src/components/MarkdownViewerDialog.tsx
@@ -1,4 +1,4 @@
-import { DocumentIcon } from './icons';
+import { DocumentIcon, ExternalLinkIcon } from './icons';
import { Show, createSignal, onCleanup } from 'solid-js';
import type { JSX } from 'solid-js';
import { IPC } from '../../electron/ipc/channels';
@@ -148,9 +148,7 @@ function MarkdownViewerDialog(props: {
}}
title="Open in editor"
>
-
+
diff --git a/src/components/PromptHistory.tsx b/src/components/PromptHistory.tsx
index a5658dadb..66fd8bce5 100644
--- a/src/components/PromptHistory.tsx
+++ b/src/components/PromptHistory.tsx
@@ -4,6 +4,7 @@ import { createAnchorEffect, placeBelow } from '../lib/floating';
import type { BelowAnchor } from '../lib/floating';
import type { Task } from '../store/types';
import { promptHistoryOf } from '../lib/prompt-history';
+import { HistoryAltIcon } from './icons';
export function PromptHistory(props: { task: Task; emptyLabel: string }) {
const id = createUniqueId();
@@ -79,19 +80,7 @@ export function PromptHistory(props: { task: Task; emptyLabel: string }) {
}
}}
>
-
+ {props.task.lastPrompt || props.emptyLabel}
diff --git a/src/components/StatusDot.tsx b/src/components/StatusDot.tsx
index 18cf28004..5c4964e51 100644
--- a/src/components/StatusDot.tsx
+++ b/src/components/StatusDot.tsx
@@ -5,6 +5,7 @@ import {
type TaskDotStatus,
} from '../store/taskStatus';
import { getCiGlyphState, type CiGlyphState } from '../store/pr-checks-state';
+import { StatusCheckIcon } from './icons';
import { theme } from '../lib/theme';
const SIZES = { sm: 6, md: 8 } as const;
@@ -196,20 +197,7 @@ export function StatusDot(props: {
}}
/>
) : glyph() === 'ready' ? (
-
+
) : glyph() === 'waiting' ? (
-
+
{(bookmark, i) => (
diff --git a/src/components/chat/Composer.tsx b/src/components/chat/Composer.tsx
index 9f5b8247b..fd78756c8 100644
--- a/src/components/chat/Composer.tsx
+++ b/src/components/chat/Composer.tsx
@@ -11,7 +11,7 @@ import { shellCommand, type Composer as ComposerState } from './composer-state';
import { ModelPicker, PermissionPicker } from './ModelPicker';
import { Progress } from './Progress';
import { RequestCard, type RespondToRequest } from './RequestCard';
-import { PencilIcon, SendIcon, SyncIcon } from '../icons';
+import { ArrowUpAltIcon, PencilIcon, SendIcon, StopSmallIcon, SyncIcon } from '../icons';
export interface ChatHistoryEntry {
title: string;
@@ -360,15 +360,7 @@ export function Composer(props: ComposerProps) {
}
onClick={() => void c.send()}
>
-
+
@@ -379,15 +371,7 @@ export function Composer(props: ComposerProps) {
title={stopLabel()}
onClick={() => void c.stop()}
>
-
+
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 8dca1ff51..8fccda982 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -1148,3 +1148,90 @@ export function QuestionAltIcon(props: IconProps): JSX.Element {
}
/** Stroke-drawn padlock: a mind-map node the user has edited. */
+/** Stroke-drawn history: the prompt-history trigger; HistoryIcon above is the filled one. */
+export function HistoryAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Compact prompt-style terminal for the shell toolbar; see also TerminalIcon above. */
+export function TerminalSmallIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+/** The chat composer's send arrow; ArrowUpIcon is the prompt bar's compact variant. */
+export function ArrowUpAltIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Compact stop square: the chat composer's stop button. */
+export function StopSmallIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Tiny outlined file, 9x11: marks a ref chip as a place in the codebase. */
+export function FileIcon(props: IconProps & { height?: number | string }): JSX.Element {
+ const width = () => props.size ?? 16;
+ const height = () => props.height ?? props.size ?? 16;
+
+ return (
+
+ );
+}
+/** Single horizontal bar: the collapsed-task affordance in the later menu. */
+export function MinusIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Fat round chevron for small click targets; see also ChevronRightThinIcon above. */
+export function ChevronRightSmallIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/** Fat stroke check in a 12 box: the status dot's ready glyph. */
+export function StatusCheckIcon(props: IconProps): JSX.Element {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/task-later-menu.tsx b/src/components/task-later-menu.tsx
index b067a2e4b..83be17c93 100644
--- a/src/components/task-later-menu.tsx
+++ b/src/components/task-later-menu.tsx
@@ -10,7 +10,15 @@ import {
import { collapseTask } from '../store/tasks';
import type { Task } from '../store/types';
import type { MenuAction, MoreMenuItem } from './chat/MoreMenu';
-import { ActivityIcon, CheckIcon, ClockIcon, PlayIcon, StopIcon, UndoIcon } from './icons';
+import {
+ ActivityIcon,
+ CheckIcon,
+ ClockIcon,
+ MinusIcon,
+ PlayIcon,
+ StopIcon,
+ UndoIcon,
+} from './icons';
function presetLabel(hours: number): string {
if (hours < 1) return `${hours * 60} minutes`;
@@ -44,11 +52,7 @@ function canStopAgents(task: Task): boolean {
return !task.coordinatorMode && !task.delegationParent && !task.coordinatedBy;
}
-const minimizeIcon = () => (
-
-);
+const minimizeIcon = () => ;
/** Ways to set a task aside for later: snoozes, restoring a snoozed task, and minimizing. */
export function laterMenuItems(task: Task): MoreMenuItem[] {
diff --git a/src/components/understanding/TourCard.tsx b/src/components/understanding/TourCard.tsx
index efb0a5155..14850f169 100644
--- a/src/components/understanding/TourCard.tsx
+++ b/src/components/understanding/TourCard.tsx
@@ -4,6 +4,7 @@ import { createHighlightedMarkdown } from '../../lib/marked-shiki';
import { renderMermaidIn } from '../../lib/mermaid';
import { theme } from '../../lib/theme';
import type { TourCard as TourCardData, TourRef, TourTone } from '../../lib/understanding-tour';
+import { FileIcon } from '../icons';
/** Tone colour per card; `neutral` and `mechanical` carry no accent. */
const TONE_COLOR: Record = {
@@ -25,17 +26,7 @@ function refLabel(ref: TourRef): string {
/** Marks a ref chip as a place in the codebase rather than a word. */
function FileGlyph() {
- return (
-
- );
+ return ;
}
/**