From cae49caded059a79be694c27263f14b2e5a81d7b Mon Sep 17 00:00:00 2001 From: tkhwang Date: Wed, 17 Jun 2026 23:28:02 +0900 Subject: [PATCH 1/2] feat(ui): redesign companion app layout and summary - Update `DESIGN.md` to reflect new UI architecture - Introduce `AppSummary` component for global inventory and status rollup - Replace flat task list with `ProjectGroup` components - Remove `memoEdit`, `memoClear`, `notiClear`, and `copyPath` actions - Update `commandForTaskAction` to reflect removed actions - Refine terminology and microcopy in `DESIGN.md` This redesign aims to improve the information hierarchy and visual clarity of the companion app. Grouping tasks by project and providing a global status summary makes it easier for users to quickly grasp the state of their work. The removal of certain actions simplifies the interface, focusing on core task management and navigation. --- DESIGN.md | 24 +- apps/workbranch-companion/src/App.tsx | 94 ++- .../src/application/state.ts | 71 ++- .../src/infrastructure/tauriClient.ts | 6 +- apps/workbranch-companion/src/style.css | 81 ++- .../src/ui/ProjectGroup.tsx | 33 ++ apps/workbranch-companion/src/ui/TaskRow.tsx | 46 +- apps/workbranch-companion/tests/acl.test.ts | 99 +++- .../tests/project-group.test.tsx | 49 ++ .../tests/task-row.test.tsx | 27 +- .../0036-companion-project-grouped-ui.md | 560 ++++++++++++++++++ .../0037-companion-settings-cli-theme.md | 265 +++++++++ 12 files changed, 1232 insertions(+), 123 deletions(-) create mode 100644 apps/workbranch-companion/src/ui/ProjectGroup.tsx create mode 100644 apps/workbranch-companion/tests/project-group.test.tsx create mode 100644 docs/plans/0036-companion-project-grouped-ui.md create mode 100644 docs/plans/0037-companion-settings-cli-theme.md diff --git a/DESIGN.md b/DESIGN.md index 064355f..20c81b1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -37,19 +37,19 @@ - Check what is currently in progress. - See which repo/branch is dirty. - Open task in IDE/terminal/Finder. - - Clear memo/notifications when no longer needed. + - Notice notification counts as visible status without clearing them from this surface. - Key contexts of use: quick menu bar glance while coding, before switching tasks, during AI-agent execution. ## Information architecture -- Primary navigation: single popover list sorted by recent update. -- Core screens: task list, expanded task details, error rows. +- Primary navigation: single popover grouped by project, with groups sorted by most recent task update. +- Core screens: project-grouped task list, expanded task details, error rows. - Content hierarchy: - 1. Global rollup title and refresh. - 2. Task name + status dot + progress. - 3. Active plan/current step. + 1. Global inventory + status rollup and refresh. + 2. Project group header (name + task count). + 3. Task name + status dot + progress + notification. 4. Repo branch/dirty state. - 5. Actions. - 6. Nested plan steps. + 5. Active plan / current step, then nested plan steps. + 6. Actions (IDE, Terminal, Finder). ## Design principles - Principle 1: Status is a launcher signal, not a paragraph. Use compact dots, counts, and labels. @@ -68,12 +68,14 @@ ## Components - Existing components to reuse: `TaskRow`, action buttons, native `details/summary` disclosure. - New/changed components: + - global inventory/status summary, + - project group header, - launcher-like task summary line, - current-step strip, - repo chips, - - action bar, + - action bar limited to IDE, Terminal, and Finder, - nested step tree. -- Variants and states: todo, planning, in-progress, review, blocked, done, notification present, dirty repo, disabled action. +- Variants and states: todo, planning, in-progress, review, blocked, done, notification present, dirty repo. - Token/component ownership: `style.css` owns CSS custom properties and component classes. ## Accessibility @@ -99,7 +101,7 @@ ## Content voice - Tone: terse, operational, developer-native. - Terminology: task, plan, step, repo, branch, dirty. -- Microcopy rules: prefer short labels (`IDE`, `Terminal`, `Copy`) over sentences; avoid emoji except where already part of compact rollup. +- Microcopy rules: prefer short row action labels (`IDE`, `Terminal`, `Finder`) over sentences; omit `Copy`/`Memo`/`Noti`/`Clear` row vocabulary because those companion actions are removed, not hidden. ## Implementation constraints - Framework/styling system: React 18 + plain CSS; no Tailwind/shadcn in this refresh. Primary reference is Raycast; Linear remains only a secondary status-hierarchy cue. diff --git a/apps/workbranch-companion/src/App.tsx b/apps/workbranch-companion/src/App.tsx index e0885fe..3797e44 100644 --- a/apps/workbranch-companion/src/App.tsx +++ b/apps/workbranch-companion/src/App.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { createActivityRefresh } from "./application/activity"; -import { buildMenuModel, type MenuModel } from "./application/state"; +import { + buildMenuModel, + type MenuModel, + type MenuSummary, +} from "./application/state"; import type { GlobalState, Task } from "./domain/model"; import { appendActivityEvents, @@ -11,7 +15,8 @@ import { watchRoots, } from "./infrastructure/tauriClient"; import { startWorkspaceMonitor } from "./infrastructure/workspaceMonitor"; -import { type TaskActionKind, TaskRow } from "./ui/TaskRow"; +import { ProjectGroup } from "./ui/ProjectGroup"; +import type { TaskActionKind } from "./ui/TaskRow"; const EMPTY_STATE: GlobalState = { projects: [], errors: [] }; @@ -22,29 +27,66 @@ function currentEpochSeconds(): number { function commandForTaskAction( task: Task, kind: TaskActionKind, -): CompanionCommand | undefined { +): CompanionCommand { switch (kind) { - case "memoEdit": { - const text = window.prompt(`Memo for ${task.name}`, task.memoTitle); - return text === null - ? undefined - : { kind: "memo", task: task.name, text }; - } - case "memoClear": - return { kind: "memoClear", task: task.name }; - case "notiClear": - return { kind: "notiClear", task: task.name }; - case "finder": - return { kind: "finder", task: task.name }; case "ide": return { kind: "ide", task: task.name }; case "terminal": return { kind: "terminal", task: task.name }; - case "copyPath": - return { kind: "copyPath", path: task.path }; + case "finder": + return { kind: "finder", task: task.name }; } } +function plural(count: number, word: string): string { + return count === 1 ? word : `${word}s`; +} + +function AppSummary({ summary }: { readonly summary: MenuSummary }) { + const { projectCount, taskCount, active, blocked, notifications } = summary; + return ( +
+ + {taskCount === 0 + ? "No tasks" + : `${projectCount} ${plural(projectCount, "project")} ยท ${taskCount} ${plural(taskCount, "task")}`} + + + {active > 0 ? ( + + โ–ถ {active} + + ) : null} + {blocked > 0 ? ( + + โš  {blocked} + + ) : null} + {notifications > 0 ? ( + + ๐Ÿ”” {notifications} + + ) : null} + +
+ ); +} + export function App() { const [state, setState] = useState(EMPTY_STATE); const [status, setStatus] = useState("Ready"); @@ -83,9 +125,6 @@ export function App() { const handleTaskAction = useCallback( async (root: string, task: Task, kind: TaskActionKind) => { const command = commandForTaskAction(task, kind); - if (command === undefined) { - return; - } try { await runAction(command, root); applyState(await refreshWithActivity()); @@ -130,7 +169,7 @@ export function App() { return (
-

{model.title}

+
- {model.rows.length === 0 ? ( + {model.groups.length === 0 ? (

No workbranch tasks registered.

) : null} - {model.rows.map((row) => ( - ( + void handleTaskAction(root, task, kind) } diff --git a/apps/workbranch-companion/src/application/state.ts b/apps/workbranch-companion/src/application/state.ts index d54c71a..088d03b 100644 --- a/apps/workbranch-companion/src/application/state.ts +++ b/apps/workbranch-companion/src/application/state.ts @@ -8,41 +8,58 @@ export type TaskRow = { readonly expanded: boolean; }; -export type MenuModel = { - readonly title: string; +export type ProjectGroup = { + readonly project: string; + readonly root: string; readonly rows: readonly TaskRow[]; +}; + +export type MenuSummary = { + readonly projectCount: number; + readonly taskCount: number; + readonly active: number; + readonly blocked: number; + readonly notifications: number; +}; + +export type MenuModel = { + readonly summary: MenuSummary; + readonly groups: readonly ProjectGroup[]; readonly errors: GlobalState["errors"]; }; +function latestUpdate(group: ProjectGroup): number { + return group.rows.reduce((max, row) => Math.max(max, row.task.updatedAt), 0); +} + export function buildMenuModel(state: GlobalState): MenuModel { - const rows = state.projects.flatMap((project) => - project.tasks.map((task) => ({ + const groups = state.projects + .map((project) => ({ project: project.name, root: project.root, - task, - expanded: task.notiCount > 0 || taskStatus(task) === "blocked", - })), - ); - const active = rows.filter( - (row) => taskStatus(row.task) === "in-progress", - ).length; - const blocked = rows.filter( - (row) => taskStatus(row.task) === "blocked", - ).length; - const notifications = rows.reduce( - (count, row) => count + row.task.notiCount, - 0, - ); - const parts = [ - active > 0 ? `โ–ถ${active}` : "", - blocked > 0 ? `โš ${blocked}` : "", - notifications > 0 ? `๐Ÿ””${notifications}` : "", - ].filter(Boolean); + rows: project.tasks + .map((task) => ({ + project: project.name, + root: project.root, + task, + expanded: task.notiCount > 0 || taskStatus(task) === "blocked", + })) + .sort((left, right) => right.task.updatedAt - left.task.updatedAt), + })) + .filter((group) => group.rows.length > 0) + .sort((left, right) => latestUpdate(right) - latestUpdate(left)); + + const rows = groups.flatMap((group) => group.rows); return { - title: parts.length > 0 ? parts.join(" ") : "โއ 0", - rows: rows.sort( - (left, right) => right.task.updatedAt - left.task.updatedAt, - ), + summary: { + projectCount: groups.length, + taskCount: rows.length, + active: rows.filter((row) => taskStatus(row.task) === "in-progress") + .length, + blocked: rows.filter((row) => taskStatus(row.task) === "blocked").length, + notifications: rows.reduce((count, row) => count + row.task.notiCount, 0), + }, + groups, errors: state.errors, }; } diff --git a/apps/workbranch-companion/src/infrastructure/tauriClient.ts b/apps/workbranch-companion/src/infrastructure/tauriClient.ts index 81fa444..299a53c 100644 --- a/apps/workbranch-companion/src/infrastructure/tauriClient.ts +++ b/apps/workbranch-companion/src/infrastructure/tauriClient.ts @@ -34,13 +34,9 @@ export function ensureRunSucceeded(result: RunResult): void { } export type CompanionCommand = - | { readonly kind: "memo"; readonly task: string; readonly text: string } - | { readonly kind: "memoClear"; readonly task: string } - | { readonly kind: "notiClear"; readonly task: string } | { readonly kind: "finder"; readonly task: string } | { readonly kind: "ide"; readonly task: string } - | { readonly kind: "terminal"; readonly task: string } - | { readonly kind: "copyPath"; readonly path: string }; + | { readonly kind: "terminal"; readonly task: string }; export async function refreshStatus(): Promise { const raw = await invoke("workbranch_list_global"); diff --git a/apps/workbranch-companion/src/style.css b/apps/workbranch-companion/src/style.css index 61bc819..8ef202c 100644 --- a/apps/workbranch-companion/src/style.css +++ b/apps/workbranch-companion/src/style.css @@ -181,7 +181,7 @@ summary::-webkit-details-marker { .task-name { color: var(--text); - font-size: 13px; + font-size: 12px; font-weight: 620; overflow: hidden; text-overflow: ellipsis; @@ -213,7 +213,6 @@ summary::-webkit-details-marker { padding: 9px 10px 10px; } -.project-line, footer { color: var(--faint); font-size: 11px; @@ -359,3 +358,81 @@ footer { transform: none; } } + +.app-summary { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.app-inventory { + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.app-badges { + display: inline-flex; + gap: 6px; +} + +.badge { + border: 1px solid var(--line); + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1; + padding: 3px 7px; +} + +.badge-active { + background: var(--accent-soft); + border-color: rgba(124, 92, 255, 0.3); + color: var(--accent); +} + +.badge-blocked { + background: rgba(255, 95, 112, 0.14); + border-color: rgba(255, 95, 112, 0.28); + color: var(--blocked); +} + +.badge-noti { + background: rgba(245, 184, 75, 0.12); + border-color: rgba(245, 184, 75, 0.22); + color: var(--notify); +} + +.project-group { + margin-bottom: 14px; +} + +.project-group-header { + align-items: baseline; + border-left: 2px solid var(--accent); + display: flex; + gap: 8px; + margin-bottom: 8px; + padding-left: 8px; +} + +.project-group-name { + color: var(--text); + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-group-count { + color: var(--faint); + font-size: 11px; + white-space: nowrap; +} + +.project-group .task { + margin-left: 8px; +} diff --git a/apps/workbranch-companion/src/ui/ProjectGroup.tsx b/apps/workbranch-companion/src/ui/ProjectGroup.tsx new file mode 100644 index 0000000..19a43ce --- /dev/null +++ b/apps/workbranch-companion/src/ui/ProjectGroup.tsx @@ -0,0 +1,33 @@ +import type { ProjectGroup as ProjectGroupModel } from "../application/state"; +import type { Task } from "../domain/model"; +import { type TaskActionKind, TaskRow } from "./TaskRow"; + +type Props = { + readonly group: ProjectGroupModel; + readonly onAction: (root: string, task: Task, kind: TaskActionKind) => void; +}; + +export function ProjectGroup({ group, onAction }: Props) { + const count = group.rows.length; + return ( +
+
+ + {group.project} + + + {count} {count === 1 ? "task" : "tasks"} + +
+ {group.rows.map((row) => ( + + ))} +
+ ); +} diff --git a/apps/workbranch-companion/src/ui/TaskRow.tsx b/apps/workbranch-companion/src/ui/TaskRow.tsx index 3016ad9..5ebc0a8 100644 --- a/apps/workbranch-companion/src/ui/TaskRow.tsx +++ b/apps/workbranch-companion/src/ui/TaskRow.tsx @@ -12,15 +12,7 @@ const STATUS_META = { done: { icon: "โœ“", label: "Done" }, } as const; -const TASK_ACTION_KINDS = [ - "memoEdit", - "memoClear", - "notiClear", - "finder", - "ide", - "terminal", - "copyPath", -] as const; +const TASK_ACTION_KINDS = ["ide", "terminal", "finder"] as const; export type TaskActionKind = (typeof TASK_ACTION_KINDS)[number]; @@ -32,17 +24,12 @@ export type TaskRowAction = { }; const TASK_ACTION_LABELS: Record = { - memoEdit: "Memo", - memoClear: "Clear", - notiClear: "Noti", - finder: "Finder", ide: "IDE", terminal: "Terminal", - copyPath: "Copy", + finder: "Finder", } as const; type Props = { - readonly project: string; readonly root: string; readonly task: Task; readonly expanded: boolean; @@ -56,20 +43,12 @@ type StepItemsProps = { function actionAriaLabel(kind: TaskActionKind, taskName: string): string { switch (kind) { - case "memoEdit": - return `edit memo for ${taskName}`; - case "memoClear": - return `clear memo for ${taskName}`; - case "notiClear": - return `clear notifications for ${taskName}`; - case "finder": - return `open ${taskName} in Finder`; case "ide": return `open ${taskName} in IDE`; case "terminal": return `open ${taskName} in terminal`; - case "copyPath": - return `copy path for ${taskName}`; + case "finder": + return `open ${taskName} in Finder`; } } @@ -78,7 +57,7 @@ export function taskActionsFor(task: Task): readonly TaskRowAction[] { kind, label: TASK_ACTION_LABELS[kind], ariaLabel: actionAriaLabel(kind, task.name), - disabled: kind === "notiClear" && task.notiCount === 0, + disabled: false, })); } @@ -178,7 +157,7 @@ function RepoChips({ repos }: RepoChipsProps) { ); } -export function TaskRow({ project, root, task, expanded, onAction }: Props) { +export function TaskRow({ root, task, expanded, onAction }: Props) { const status = taskStatus(task); const progress = taskProgress(task); const now = currentItem(task); @@ -190,11 +169,15 @@ export function TaskRow({ project, root, task, expanded, onAction }: Props) {
-
{project}
+ {plan && now ? ( ) : null} - + {plan ? ( +
    + +
+ ) : null}
{actions.map((action) => (
- {plan ? ( -
    - -
- ) : null}
); diff --git a/apps/workbranch-companion/tests/acl.test.ts b/apps/workbranch-companion/tests/acl.test.ts index bded269..f5c0c87 100644 --- a/apps/workbranch-companion/tests/acl.test.ts +++ b/apps/workbranch-companion/tests/acl.test.ts @@ -51,8 +51,103 @@ describe("ACL", () => { it("builds a compact menu rollup", () => { const model = buildMenuModel(mapGlobalDocumentToState(document)); - expect(model.title).toBe("โ–ถ1 ๐Ÿ””2"); - expect(model.rows[0]?.expanded).toBe(true); + expect(model.summary.projectCount).toBe(1); + expect(model.summary.taskCount).toBe(1); + expect(model.summary.active).toBe(1); + expect(model.summary.notifications).toBe(2); + expect(model.groups[0]?.rows[0]?.expanded).toBe(true); + }); + + it("groups non-empty projects by their latest task update", () => { + const multiProjectDocument: WorkbranchListGlobalDocument = { + schemaVersion: 1, + projects: [ + { + schemaVersion: 1, + project: "alpha", + root: "/tmp/alpha", + tasks: [ + { + name: "alpha-old", + path: "/tmp/alpha/alpha-old", + memoTitle: "", + planTitle: "Plan", + status: "todo", + progressDone: 0, + progressTotal: 1, + currentItem: "", + updatedAt: 10, + items: [], + plans: [], + notiCount: 0, + repos: [], + }, + { + name: "alpha-new", + path: "/tmp/alpha/alpha-new", + memoTitle: "", + planTitle: "Plan", + status: "blocked", + progressDone: 0, + progressTotal: 1, + currentItem: "", + updatedAt: 40, + items: [], + plans: [], + notiCount: 0, + repos: [], + }, + ], + }, + { + schemaVersion: 1, + project: "empty", + root: "/tmp/empty", + tasks: [], + }, + { + schemaVersion: 1, + project: "beta", + root: "/tmp/beta", + tasks: [ + { + name: "beta-task", + path: "/tmp/beta/beta-task", + memoTitle: "", + planTitle: "Plan", + status: "in-progress", + progressDone: 0, + progressTotal: 1, + currentItem: "", + updatedAt: 80, + items: [], + plans: [], + notiCount: 3, + repos: [], + }, + ], + }, + ], + errors: [], + }; + + const model = buildMenuModel( + mapGlobalDocumentToState(multiProjectDocument), + ); + + expect(model.summary.projectCount).toBe(2); + expect(model.summary.taskCount).toBe(3); + expect(model.summary.active).toBe(1); + expect(model.summary.blocked).toBe(1); + expect(model.summary.notifications).toBe(3); + expect(model.groups.map((group) => group.project)).toEqual([ + "beta", + "alpha", + ]); + expect(model.groups[1]?.rows.map((row) => row.task.name)).toEqual([ + "alpha-new", + "alpha-old", + ]); }); }); diff --git a/apps/workbranch-companion/tests/project-group.test.tsx b/apps/workbranch-companion/tests/project-group.test.tsx new file mode 100644 index 0000000..7de9002 --- /dev/null +++ b/apps/workbranch-companion/tests/project-group.test.tsx @@ -0,0 +1,49 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { ProjectGroup as ProjectGroupModel } from "../src/application/state"; +import type { Task } from "../src/domain/model"; +import { ProjectGroup } from "../src/ui/ProjectGroup"; + +const task = (name: string, updatedAt: number): Task => ({ + name, + path: `/tmp/acme/${name}`, + memoTitle: "", + notiCount: 0, + updatedAt, + repos: [], + plans: [], +}); + +const group: ProjectGroupModel = { + project: "acme", + root: "/tmp/acme", + rows: [ + { + project: "acme", + root: "/tmp/acme", + task: task("feat-a", 20), + expanded: false, + }, + { + project: "acme", + root: "/tmp/acme", + task: task("feat-b", 10), + expanded: false, + }, + ], +}; + +describe("ProjectGroup", () => { + it("renders the project header with task count above its task rows", () => { + const html = renderToStaticMarkup( + {}} />, + ); + expect(html).toContain("project-group-header"); + expect(html).toContain("acme"); + expect(html).toContain("2 tasks"); + expect(html).toContain("feat-a"); + expect(html.indexOf("project-group-header")).toBeLessThan( + html.indexOf("feat-a"), + ); + }); +}); diff --git a/apps/workbranch-companion/tests/task-row.test.tsx b/apps/workbranch-companion/tests/task-row.test.tsx index 2325aa9..639a8ab 100644 --- a/apps/workbranch-companion/tests/task-row.test.tsx +++ b/apps/workbranch-companion/tests/task-row.test.tsx @@ -2,7 +2,11 @@ import { Children, isValidElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import type { Task } from "../src/domain/model"; -import { type TaskActionKind, TaskRow } from "../src/ui/TaskRow"; +import { + type TaskActionKind, + TaskRow, + taskActionsFor, +} from "../src/ui/TaskRow"; type ButtonProps = { readonly children?: ReactNode; @@ -118,7 +122,6 @@ describe("TaskRow", () => { it("renders nested checklist children for generated task briefs", () => { const html = renderToStaticMarkup( { it("renders a Raycast-style task summary with current step and repo state", () => { const html = renderToStaticMarkup( { expect(html).toContain("workbranch"); expect(html).toContain("feat/update-0617"); }); - it("exposes the allowed task actions from each row", () => { + it("exposes only IDE, Terminal, and Finder actions", () => { const html = renderToStaticMarkup( { />, ); - expect(html).toContain('aria-label="edit memo for generated-task"'); - expect(html).toContain('aria-label="clear memo for generated-task"'); - expect(html).toContain( - 'aria-label="clear notifications for generated-task"', - ); - expect(html).toContain('aria-label="open generated-task in Finder"'); expect(html).toContain('aria-label="open generated-task in IDE"'); expect(html).toContain('aria-label="open generated-task in terminal"'); - expect(html).toContain('aria-label="copy path for generated-task"'); + expect(html).toContain('aria-label="open generated-task in Finder"'); + expect(html).not.toContain("edit memo for generated-task"); + expect(html).not.toContain("clear memo for generated-task"); + expect(html).not.toContain("clear notifications for generated-task"); + expect(html).not.toContain("copy path for generated-task"); + expect( + taskActionsFor(nestedChecklistTask).map((action) => action.label), + ).toEqual(["IDE", "Terminal", "Finder"]); }); it("passes the project root, task, and action kind when a row action is clicked", () => { const calls: ActionCall[] = []; const element = TaskRow({ - project: "workbranch", root: "/tmp/workbranch", task: nestedChecklistTask, expanded: true, diff --git a/docs/plans/0036-companion-project-grouped-ui.md b/docs/plans/0036-companion-project-grouped-ui.md new file mode 100644 index 0000000..e11e4e2 --- /dev/null +++ b/docs/plans/0036-companion-project-grouped-ui.md @@ -0,0 +1,560 @@ +# 0036 Companion Project-Grouped UI and Header Summary Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` or `$plan-execute auto` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. For `.ts`/`.tsx` edits, follow TypeScript guidance and lock semantics with Vitest before markup changes. For visual changes, drive the built app (`pnpm --filter @workbranch/companion tauri dev`) and confirm the popover hierarchy before claiming completion. Scope is the companion presentation layer only โ€” do not touch the CLI, `list --json` contract, Rust ports, activity store, or watcher. + +**Goal:** Restructure the Workbranch Companion popover so the scan hierarchy reads **Project โ†’ Task โ†’ Branch โ†’ Plan**: group task rows under a prominent project header, replace the cryptic `โއ 0` rollup title with an inventory + status summary, reorder each task's body to put branch state above the plan, and reduce the per-task action bar to **IDE | Terminal | Finder**. + +**Architecture:** Keep the existing React 18 + TypeScript + plain-CSS architecture and the `DESIGN.md` contract. Reshape the view model in `application/state.ts` from a flat `{ title, rows }` into `{ summary, groups }`, add one small `ui/ProjectGroup.tsx` view part, and adjust `App.tsx`, `ui/TaskRow.tsx`, and `style.css`. No changes to the domain model, ACL/DTO mapping, CLI contract, Rust side, or runtime Tauri command implementation. The presentation-facing `CompanionCommand` union may be narrowed to remove deleted row actions. + +**Tech Stack:** Tauri v2, React 18, TypeScript, Vite, Vitest, Biome, plain CSS. No new runtime dependencies. + +--- + +## Product framing + +Workbranch Companion is a macOS menu bar popover (420ร—680, always-on-top) that answers, at a glance, for developers running git-worktree tasks through `workbranch`: + +1. Which **project** owns which **tasks**? +2. What task is active/blocked, and how far along? +3. What **branch / dirty** state matters? +4. What **plan** and current step is progressing? +5. What immediate action can I take? + +Today the popover flattens every task into one recency-sorted list (`buildMenuModel` in `application/state.ts`), shows the project only as a small faint label *inside* the expanded row (`.project-line`, `TaskRow.tsx:193`), and leads with a cryptic header (`โއ 0`) that is actually a global status fallback, not a name. The result inverts the intended hierarchy: the task name (13px/620) dominates the project (11px/faint). This plan makes **project** the primary grouping and gives the header a legible job. + +## Current repo evidence + +- `apps/workbranch-companion/src/application/state.ts:17-48` โ€” `buildMenuModel` flat-maps `projects โ†’ tasks`, sorts by `updatedAt`, and computes a `title` string whose idle fallback is the literal `"โއ 0"` (`โއ` = branch glyph, `0` = nothing active). `MenuModel = { title, rows, errors }`. +- `apps/workbranch-companion/src/App.tsx:133` renders `

{model.title}

`; `:143,:146` consume `model.rows`. +- `apps/workbranch-companion/src/ui/TaskRow.tsx:15-23` defines 7 action kinds (`memoEdit, memoClear, notiClear, finder, ide, terminal, copyPath`); `:76-83` `taskActionsFor` renders all 7; `:192-211` body order is `project-line โ†’ current-step โ†’ repo-chips โ†’ actions โ†’ steps`. +- `apps/workbranch-companion/src/style.css:67` styles a bare `header` selector (so any nested `
` would inherit it โ€” use a `div` for the group header); `:182-189` `.task-name` 13px/620; `:216-221` `.project-line, footer` faint/11px. +- The action command plumbing (`commandForTaskAction` in `App.tsx:22-46`, the `CompanionCommand` union in `infrastructure/tauriClient.ts:36-43`) currently handles all 7 kinds. Because the decision is full removal rather than hide-only, the TypeScript presentation command surface should be narrowed, while Rust command handling remains untouched. +- **Direct consumers of the model shape that must change with it:** `App.tsx` (`model.title`, `model.rows`) and `tests/acl.test.ts:53-55` (`model.title`, `model.rows[0]?.expanded`). No other file references `buildMenuModel`/`MenuModel`/`.project-line`. +- Window title "Workbranch Companion" already lives in the OS title bar (`src-tauri/tauri.conf.json:16`, `index.html:6`), so the in-content header does not need to repeat the app name. + +## Decision gates + +- [x] **Header content** (was `โއ 0`): resolved to **inventory + status summary** โ€” `N projects ยท M tasks` plus `โ–ถactive โš blocked ๐Ÿ””noti` badges (each shown only when > 0). Idle shows inventory with no badges; zero tasks shows `No tasks`. Rationale: an always-on-top HUD benefits most from a glanceable rollup, and the app name is already in the OS title bar, so repeating it is redundant. +- [x] **Project โ†’ Task hierarchy:** resolved to a **project group header** (`โ–Œ project ยท n tasks`) with that project's task rows nested beneath it, instead of a flat list with a faint inline project label. Rationale: expresses Project > Task even when a project has multiple tasks, and removes per-row project duplication. +- [x] **Per-task body order:** resolved to **task name โ†’ branch (repo chips) โ†’ Plan (current step + steps) โ†’ actions**. Branch/dirty state moves above the plan; actions move to the bottom of the row. +- [x] **Action bar:** resolved to **IDE | Terminal | Finder only**, in that order. +- [x] **Memo/clear/noti/copy actions:** resolved to **fully remove from the companion presentation command surface**. Remove the deleted kinds from `TaskActionKind`, labels, aria-label switch, `taskActionsFor`, `commandForTaskAction`, and the `CompanionCommand` union. Consequence: the popover no longer offers memo edit/clear, notification clear, or copy-path actions; notifications remain visible as status only. Rationale: once the product direction is a focused three-action row, leaving hidden plumbing creates ambiguous dead surface and makes future behavior less clear. + +## File structure + +Modify: +- `DESIGN.md` โ€” update Information Architecture / Content hierarchy / Components / Content voice to the project-grouped structure (keep it the source of truth). +- `apps/workbranch-companion/src/application/state.ts` โ€” reshape `MenuModel` to `{ summary, groups, errors }`; add `MenuSummary` and `ProjectGroup` types; group + sort. +- `apps/workbranch-companion/src/App.tsx` โ€” header inventory+status summary; render `groups.map()`; empty state on `groups.length === 0`. +- `apps/workbranch-companion/src/ui/TaskRow.tsx` โ€” remove memo/clear/noti/copy action kinds; keep only IDE/Terminal/Finder; reorder body; drop `project` prop and `.project-line`. +- `apps/workbranch-companion/src/style.css` โ€” `.app-summary`/badges, `.project-group`/`.project-group-header`, row nesting, action bar at bottom; remove `.project-line`; ensure project header reads stronger than task name. +- `apps/workbranch-companion/tests/acl.test.ts` โ€” assert the new `summary`/`groups` shape. +- `apps/workbranch-companion/tests/task-row.test.tsx` โ€” assert exactly the three remaining actions in order; drop `project=` prop. +- `apps/workbranch-companion/src/infrastructure/tauriClient.ts` โ€” narrow `CompanionCommand` to the three remaining presentation actions. +- `TASK-WORKBRANCH.md` โ€” task progress updates only. + +Create: +- `apps/workbranch-companion/src/ui/ProjectGroup.tsx` โ€” project header + nested `TaskRow`s. +- `apps/workbranch-companion/tests/project-group.test.tsx` โ€” header/count/order coverage. + +Do not modify: +- `apps/workbranch-cli/**`, `packages/contract/**`, `apps/workbranch-companion/src-tauri/**`, `apps/workbranch-companion/src/domain/**`, and `apps/workbranch-companion/src/infrastructure/**` except the explicit `tauriClient.ts` `CompanionCommand` type narrowing above. + +## Task 1: Update the design contract + +**Files:** Modify `DESIGN.md`, `../TASK-WORKBRANCH.md` + +- [x] In `DESIGN.md`, update **Information architecture โ†’ Content hierarchy** to: + 1. Global inventory + status rollup and refresh. + 2. Project group header (name + task count). + 3. Task name + status dot + progress + notification. + 4. Repo branch/dirty state. + 5. Active plan / current step, then nested plan steps. + 6. Actions (IDE, Terminal, Finder). +- [x] Update **Personas and jobs** to remove memo/notification clearing from current-slice user jobs; notifications remain visible status only. +- [x] Update **Components** to add "project group header" and note the action bar is limited to IDE/Terminal/Finder; update variants/states to remove disabled notification-clear action as a current component state. +- [x] Update **Content voice โ†’ Microcopy** to drop `Copy`/`Memo`/`Noti`/`Clear` from the row label vocabulary because those companion row actions are being removed, not hidden. +- [x] Update `Last refreshed:` to the implementation date. +- [x] Verify no placeholders: `rg -n "TBD|TODO|placeholder|fill in" DESIGN.md` โ†’ no matches. + +## Task 2: Reshape the view model with tests (TDD) + +**Files:** Modify `apps/workbranch-companion/tests/acl.test.ts`; Modify `apps/workbranch-companion/src/application/state.ts` + +- [x] **Step 1 (RED):** Rewrite the "builds a compact menu rollup" test in `acl.test.ts` to the new shape: + + ```ts + it("builds a compact menu rollup", () => { + const model = buildMenuModel(mapGlobalDocumentToState(document)); + expect(model.summary.projectCount).toBe(1); + expect(model.summary.taskCount).toBe(1); + expect(model.summary.active).toBe(1); + expect(model.summary.notifications).toBe(2); + expect(model.groups[0]?.rows[0]?.expanded).toBe(true); + }); + ``` + + Run `pnpm --filter @workbranch/companion test -- tests/acl.test.ts` โ†’ expect FAIL/typecheck error (no `summary`/`groups` yet). + +- [x] Add one multi-project view-model test that locks grouping semantics: at least two non-empty projects and one empty project; assert empty projects are filtered, groups sort by latest task `updatedAt`, rows within each group sort by `updatedAt`, and `summary.projectCount` counts rendered non-empty groups. + +- [x] **Step 2 (GREEN):** Replace the model + builder in `state.ts`: + + ```ts + import type { GlobalState, Task } from "../domain/model"; + import { activePlan, taskStatus } from "../domain/model"; + + export type TaskRow = { + readonly project: string; + readonly root: string; + readonly task: Task; + readonly expanded: boolean; + }; + + export type ProjectGroup = { + readonly project: string; + readonly root: string; + readonly rows: readonly TaskRow[]; + }; + + export type MenuSummary = { + readonly projectCount: number; + readonly taskCount: number; + readonly active: number; + readonly blocked: number; + readonly notifications: number; + }; + + export type MenuModel = { + readonly summary: MenuSummary; + readonly groups: readonly ProjectGroup[]; + readonly errors: GlobalState["errors"]; + }; + + function latestUpdate(group: ProjectGroup): number { + return group.rows.reduce((max, row) => Math.max(max, row.task.updatedAt), 0); + } + + export function buildMenuModel(state: GlobalState): MenuModel { + const groups = state.projects + .map((project) => ({ + project: project.name, + root: project.root, + rows: project.tasks + .map((task) => ({ + project: project.name, + root: project.root, + task, + expanded: task.notiCount > 0 || taskStatus(task) === "blocked", + })) + .sort((left, right) => right.task.updatedAt - left.task.updatedAt), + })) + .filter((group) => group.rows.length > 0) + .sort((left, right) => latestUpdate(right) - latestUpdate(left)); + + const rows = groups.flatMap((group) => group.rows); + return { + summary: { + projectCount: groups.length, + taskCount: rows.length, + active: rows.filter((row) => taskStatus(row.task) === "in-progress").length, + blocked: rows.filter((row) => taskStatus(row.task) === "blocked").length, + notifications: rows.reduce((count, row) => count + row.task.notiCount, 0), + }, + groups, + errors: state.errors, + }; + } + + export function currentItem(task: Task): string { + return activePlan(task)?.currentItem ?? ""; + } + ``` + + Run `pnpm --filter @workbranch/companion test -- tests/acl.test.ts` โ†’ expect PASS. + +## Task 3: Add the ProjectGroup view part with tests (TDD) + +**Files:** Create `apps/workbranch-companion/tests/project-group.test.tsx`, `apps/workbranch-companion/src/ui/ProjectGroup.tsx` + +- [x] **Step 1 (RED):** Add `tests/project-group.test.tsx`: + + ```tsx + import { renderToStaticMarkup } from "react-dom/server"; + import { describe, expect, it } from "vitest"; + import type { ProjectGroup as ProjectGroupModel } from "../src/application/state"; + import type { Task } from "../src/domain/model"; + import { ProjectGroup } from "../src/ui/ProjectGroup"; + + const task = (name: string, updatedAt: number): Task => ({ + name, + path: `/tmp/acme/${name}`, + memoTitle: "", + notiCount: 0, + updatedAt, + repos: [], + plans: [], + }); + + const group: ProjectGroupModel = { + project: "acme", + root: "/tmp/acme", + rows: [ + { project: "acme", root: "/tmp/acme", task: task("feat-a", 20), expanded: false }, + { project: "acme", root: "/tmp/acme", task: task("feat-b", 10), expanded: false }, + ], + }; + + describe("ProjectGroup", () => { + it("renders the project header with task count above its task rows", () => { + const html = renderToStaticMarkup( {}} />); + expect(html).toContain("project-group-header"); + expect(html).toContain("acme"); + expect(html).toContain("2 tasks"); + expect(html).toContain("feat-a"); + expect(html.indexOf("project-group-header")).toBeLessThan(html.indexOf("feat-a")); + }); + }); + ``` + +- [x] **Step 2 (GREEN):** Create `src/ui/ProjectGroup.tsx`: + + ```tsx + import type { ProjectGroup as ProjectGroupModel } from "../application/state"; + import type { Task } from "../domain/model"; + import { type TaskActionKind, TaskRow } from "./TaskRow"; + + type Props = { + readonly group: ProjectGroupModel; + readonly onAction: (root: string, task: Task, kind: TaskActionKind) => void; + }; + + export function ProjectGroup({ group, onAction }: Props) { + const count = group.rows.length; + return ( +
+
+ + {group.project} + + + {count} {count === 1 ? "task" : "tasks"} + +
+ {group.rows.map((row) => ( + + ))} +
+ ); + } + ``` + + (Uses a `div`, not `
`, to avoid inheriting the global `header` rule in `style.css`.) This will not compile until Task 4 drops `project` from `TaskRow` Props โ€” run the test after Task 4. + +## Task 4: TaskRow โ€” three actions + reordered body + +**Files:** Modify `apps/workbranch-companion/src/ui/TaskRow.tsx`, `apps/workbranch-companion/tests/task-row.test.tsx` + +> Decision resolved: fully remove memo/clear/noti/copy from the companion presentation command surface. Do not leave hidden action kinds or labels behind. + +- [x] **Step 1 (RED):** In `task-row.test.tsx`, import `taskActionsFor`, drop `project="workbranch"` from all four `` / `TaskRow({...})` usages, and rewrite the actions test: + + ```ts + it("exposes only IDE, Terminal, and Finder actions", () => { + const html = renderToStaticMarkup( + {}} />, + ); + expect(html).toContain('aria-label="open generated-task in IDE"'); + expect(html).toContain('aria-label="open generated-task in terminal"'); + expect(html).toContain('aria-label="open generated-task in Finder"'); + expect(html).not.toContain("edit memo for generated-task"); + expect(html).not.toContain("clear memo for generated-task"); + expect(html).not.toContain("clear notifications for generated-task"); + expect(html).not.toContain("copy path for generated-task"); + expect(taskActionsFor(nestedChecklistTask).map((action) => action.label)).toEqual([ + "IDE", + "Terminal", + "Finder", + ]); + }); + ``` + + (The other three tests stay valid after the prop drop: the Raycast-summary test still finds `workbranch`/`feat/update-0617` via the repo chip, and the click test still finds the IDE button.) + +- [x] **Step 2 (GREEN):** In `TaskRow.tsx`: + - Replace `TASK_ACTION_KINDS` with `const TASK_ACTION_KINDS = ["ide", "terminal", "finder"] as const;` so `TaskActionKind` is only the three remaining actions. + - Remove `memoEdit`, `memoClear`, `notiClear`, and `copyPath` from `TASK_ACTION_LABELS` and `actionAriaLabel`. + - Simplify `taskActionsFor`: + + ```ts + export function taskActionsFor(task: Task): readonly TaskRowAction[] { + return TASK_ACTION_KINDS.map((kind) => ({ + kind, + label: TASK_ACTION_LABELS[kind], + ariaLabel: actionAriaLabel(kind, task.name), + disabled: false, + })); + } + ``` + - Add/adjust a direct action-order assertion, e.g. `expect(taskActionsFor(nestedChecklistTask).map((action) => action.label)).toEqual(["IDE", "Terminal", "Finder"]);`. + - Drop `project` from `Props` and the destructure; delete the `
{project}
` line. + - Reorder the `.task-detail` children to **repo chips โ†’ current step โ†’ steps โ†’ actions**: + + ```tsx +
+ + {plan && now ? : null} + {plan ? ( +
    + +
+ ) : null} +
+ {actions.map((action) => ( + + ))} +
+
+ ``` + +- [x] **Step 3:** Run `pnpm --filter @workbranch/companion test -- tests/task-row.test.tsx tests/project-group.test.tsx` โ†’ expect PASS. + +## Task 5: App shell โ€” header summary + grouped rendering + +**Files:** Modify `apps/workbranch-companion/src/App.tsx`, `apps/workbranch-companion/src/infrastructure/tauriClient.ts` + +- [x] Update imports: `import { buildMenuModel, type MenuModel, type MenuSummary } from "./application/state";`, add `import { ProjectGroup } from "./ui/ProjectGroup";`, and reduce the TaskRow import to `import type { TaskActionKind } from "./ui/TaskRow";` (App no longer renders `TaskRow` directly; `commandForTaskAction`/`handleTaskAction` still use the type). +- [x] Narrow `commandForTaskAction` to the three remaining `TaskActionKind` cases: `ide`, `terminal`, and `finder`; remove the `window.prompt` memo path and deleted memo/notification/copy cases. +- [x] In `tauriClient.ts`, narrow `CompanionCommand` to `{ kind: "finder" | "ide" | "terminal"; task: string }` variants only. Do not touch Rust command handling in this slice. +- [x] Add an inline header summary helper: + + ```tsx + function plural(count: number, word: string): string { + return count === 1 ? word : `${word}s`; + } + + function AppSummary({ summary }: { readonly summary: MenuSummary }) { + const { projectCount, taskCount, active, blocked, notifications } = summary; + return ( +
+ + {taskCount === 0 + ? "No tasks" + : `${projectCount} ${plural(projectCount, "project")} ยท ${taskCount} ${plural(taskCount, "task")}`} + + + {active > 0 ? ( + โ–ถ {active} + ) : null} + {blocked > 0 ? ( + โš  {blocked} + ) : null} + {notifications > 0 ? ( + ๐Ÿ”” {notifications} + ) : null} + +
+ ); + } + ``` + +- [x] Replace the header and body JSX: + + ```tsx +
+ + +
+
+ {model.groups.length === 0 ? ( +

No workbranch tasks registered.

+ ) : null} + {model.groups.map((group) => ( + void handleTaskAction(root, task, kind)} + /> + ))} +
+ ``` + +## Task 6: CSS โ€” header summary, project grouping, row order + +**Files:** Modify `apps/workbranch-companion/src/style.css` + +- [x] Change the `.project-line, footer { ... }` rule (`:216`) to `footer { ... }` only (delete `.project-line`). +- [x] Optionally reduce `.task-name` `font-size` from `13px` to `12px` so the project header reads as the larger element. +- [x] Append: + + ```css + .app-summary { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + } + + .app-inventory { + color: var(--muted); + font-size: 12px; + font-weight: 600; + } + + .app-badges { + display: inline-flex; + gap: 6px; + } + + .badge { + border: 1px solid var(--line); + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1; + padding: 3px 7px; + } + + .badge-active { + background: var(--accent-soft); + border-color: rgba(124, 92, 255, 0.3); + color: var(--accent); + } + + .badge-blocked { + background: rgba(255, 95, 112, 0.14); + border-color: rgba(255, 95, 112, 0.28); + color: var(--blocked); + } + + .badge-noti { + background: rgba(245, 184, 75, 0.12); + border-color: rgba(245, 184, 75, 0.22); + color: var(--notify); + } + + .project-group { + margin-bottom: 14px; + } + + .project-group-header { + align-items: baseline; + border-left: 2px solid var(--accent); + display: flex; + gap: 8px; + margin-bottom: 8px; + padding-left: 8px; + } + + .project-group-name { + color: var(--text); + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .project-group-count { + color: var(--faint); + font-size: 11px; + white-space: nowrap; + } + + .project-group .task { + margin-left: 8px; + } + ``` + +## Task 7: Verification + +**Files:** Update `docs/plans/0036-companion-project-grouped-ui.md` (evidence), `../TASK-WORKBRANCH.md` + +- [x] Automated (from repo root): + + ```bash + pnpm --filter @workbranch/companion test + pnpm --filter @workbranch/companion typecheck + pnpm --filter @workbranch/companion lint + pnpm --filter @workbranch/companion build + git diff --check + ``` + + Expected: Vitest green (incl. new `project-group.test.tsx` and updated `acl`/`task-row`), TypeScript clean, Biome exit 0 (pre-existing `parseContract.ts` info diagnostics may print but no new ones from changed files), `tsc && vite build` succeeds, whitespace clean. + +- [ ] Visual gate: + + ```bash + pnpm --filter @workbranch/companion tauri dev + ``` + + Manual checks: + - Header shows `N projects ยท M tasks` + status badges (no `โއ 0`); idle shows inventory only; zero tasks shows `No tasks`. + - Tasks are grouped under a project header that reads as the primary level; task rows are visibly subordinate (indent + accent bar). + - Within a row, order is task name โ†’ branch chips โ†’ plan/current step โ†’ steps โ†’ actions. + - Exactly three actions in order **IDE | Terminal | Finder**. + - Narrow-width wrapping and keyboard focus rings still work. + + If this session cannot observe the rendered menu bar, record the gap and require a human visual check before release. + +- [x] Append a `## ๊ตฌํ˜„ ๊ฒฐ๊ณผ` section (files changed, tests pass/fail, visual result or gap, remaining risk) and set `TASK-WORKBRANCH.md` status to `done` (automated + visual confirmed) or `review` (code complete, visual pending). + +## Acceptance criteria + +- The popover groups tasks under a project header (`project ยท n tasks`); the project level reads as primary and task rows as subordinate. +- The header shows inventory + status (`N projects ยท M tasks` + `โ–ถ/โš /๐Ÿ””` badges when > 0), idle shows inventory only, zero tasks shows `No tasks`; the `โއ 0` fallback is gone. +- Each task body is ordered task name โ†’ branch โ†’ plan (current step + steps) โ†’ actions. +- Each task row shows exactly IDE, Terminal, Finder, in that order; memo edit/clear, notification clear, and copy-path are removed from the companion presentation command surface. +- `buildMenuModel` returns `{ summary, groups, errors }`; `acl.test.ts`, `task-row.test.tsx`, and new `project-group.test.tsx` pass; typecheck, lint, build, and `git diff --check` pass. +- CLI contract, domain model, ACL mapping, Rust ports, and runtime Tauri command implementation are unchanged; only the presentation-facing `CompanionCommand` union is narrowed to match the remaining UI actions. + +## Non-goals + +- Do not change `workbranch list --json` / `list --global --json` schemaVersion 1, the domain model, or `infrastructure/acl.ts` mapping. +- Do not add task lifecycle mutations or new UI dependencies (Tailwind/shadcn/Radix). +- Do not add replacement memo/notification/copy UI in this slice; deleted row actions are a deliberate product cut, not a relocation. +- Do not modify the Rust side, activity store, or watcher. +- Do not add settings/preferences, launch-at-login, font selection, or color theme selection in this slice; those are split into `docs/plans/0037-companion-settings-cli-theme.md`. + +## Self-review checklist for this plan + +- [x] Resolves the header, hierarchy, body-order, and action decisions from the design Q&A. +- [x] Resolves the memo/noti/copy decision as fully removed from the companion presentation command surface. +- [x] Locks model + new component semantics with tests before markup (TDD). +- [x] Lists exact files, code, and the model consumers that must change together (`App.tsx`, `acl.test.ts`). +- [x] Keeps CLI/contract/domain/Rust scope unchanged. +- [x] Includes a visual/manual gate for a UI change. +- [x] No TBD/TODO placeholders. +- [x] Settings/preferences and CLI-like theme work split to 0037 so this plan stays focused on grouping/action hierarchy. + +## ๊ตฌํ˜„ ๊ฒฐ๊ณผ + +- ๋ณ€๊ฒฝ ํŒŒ์ผ: `DESIGN.md`, companion `state.ts`/`App.tsx`/`TaskRow.tsx`/`ProjectGroup.tsx`/`style.css`/`tauriClient.ts`, `acl.test.ts`, `task-row.test.tsx`, `project-group.test.tsx`, `../TASK-WORKBRANCH.md`. +- ๊ตฌํ˜„ ์™„๋ฃŒ: view model์ด `{ summary, groups, errors }`๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ๊ณ , ๋นˆ ํ”„๋กœ์ ํŠธ ํ•„ํ„ฐ๋ง/ํ”„๋กœ์ ํŠธ ์ตœ์‹  task ๊ธฐ์ค€ ์ •๋ ฌ/task row ์ตœ์‹ ์ˆœ ์ •๋ ฌ์„ ํ…Œ์ŠคํŠธ๋กœ ์ž ๊ฐ”๋‹ค. +- ๊ตฌํ˜„ ์™„๋ฃŒ: popover header๋Š” inventory + status badge๋ฅผ ๋ Œ๋”๋งํ•˜๊ณ , body๋Š” `ProjectGroup` ์•„๋ž˜ `TaskRow`๋ฅผ ์ค‘์ฒฉ ๋ Œ๋”๋งํ•œ๋‹ค. +- ๊ตฌํ˜„ ์™„๋ฃŒ: companion presentation action surface๋Š” `IDE | Terminal | Finder`๋กœ ์ถ•์†Œํ–ˆ๊ณ , `memoEdit`/`memoClear`/`notiClear`/`copyPath` ๋ฐ TS `CompanionCommand` union์˜ ์‚ญ์ œ ๋Œ€์ƒ variants๋ฅผ ์ œ๊ฑฐํ–ˆ๋‹ค. Rust runtime command implementation์€ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์•˜๋‹ค. +- ์ž๋™ ๊ฒ€์ฆ ํ†ต๊ณผ: + - `pnpm --filter @workbranch/companion test` โ†’ 7 files / 20 tests passed. + - `pnpm --filter @workbranch/companion typecheck` โ†’ passed. + - `pnpm --filter @workbranch/companion lint` โ†’ exit 0; ๊ธฐ์กด `parseContract.ts` `useLiteralKeys` info diagnostics ์ถœ๋ ฅ๋จ. + - `pnpm --filter @workbranch/companion build` โ†’ `tsc && vite build` passed. + - `git diff --check` โ†’ passed. + - manual markdown trailing-whitespace check for `../TASK-WORKBRANCH.md`, `0036`, `0037` โ†’ passed. +- Visual gate update: installed Rust 1.95.0 and set a local rustup override for this checkout; `pnpm --filter @workbranch/companion tauri dev` built successfully and ran `target/debug/workbranch-companion`. Native menu-bar popover inspection is still not directly observed in this agent session because macOS Assistive Access and screencapture are unavailable. As partial visual evidence, Playwright drove the Vite surface with Tauri IPC mocks and verified inventory/status badges, project grouping, branch-before-plan order, IDE|Terminal|Finder-only actions, and no page errors. Screenshot: `/tmp/workbranch-companion-qa/grouped-ui.png`. +- Remaining risk: native tray popover visual QA is not directly observed; keep task status at `review` until it is manually inspected in a GUI session with accessibility/screen capture available. diff --git a/docs/plans/0037-companion-settings-cli-theme.md b/docs/plans/0037-companion-settings-cli-theme.md new file mode 100644 index 0000000..f9530e4 --- /dev/null +++ b/docs/plans/0037-companion-settings-cli-theme.md @@ -0,0 +1,265 @@ +# 0037 Companion Settings and CLI Theme Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` or `$plan-execute auto` to implement this plan task-by-task. For `.ts`/`.tsx`/`.rs` edits, follow TypeScript/Rust guidance and lock semantics with tests before visual changes. For visual changes, follow `DESIGN.md`, drive `pnpm --filter @workbranch/companion tauri dev`, and visually verify the settings panel, font/theme application, and launch-at-login toggle before claiming completion. + +**Goal:** Add a compact settings surface to the Workbranch Companion popover and shift the visual system toward a terminal/CLI-like developer HUD: a settings icon beside refresh, launch-at-login toggle, fixed-width font selector, and color theme selector. + +**Architecture:** Split this from 0036. Keep 0036 focused on project grouping and row hierarchy. In this plan, add preference state and settings UI to the Tauri React companion. Use official Tauri v2 plugins for OS/app persistence: `@tauri-apps/plugin-autostart` for launch-at-login and `@tauri-apps/plugin-store` for font/theme preferences. Do not change the CLI JSON contract, domain task model, watcher, or activity store. + +**Tech Stack:** Tauri v2, React 18, TypeScript, Vite, Vitest, Biome, plain CSS, official Tauri plugins `autostart` and `store`. No Tailwind/shadcn/Radix. + +--- + +## Product framing + +The companion is becoming less of a generic dashboard and more of a command-line control panel for task worktrees. The settings surface should feel like a small terminal preferences pane, not a full app settings window. + +User-visible additions: + +1. Top-right toolbar becomes `Refresh | Settings` with compact icon buttons and accessible labels. +2. Settings icon opens an in-popover settings panel. +3. `Launch at login` toggles app autostart. +4. Font selector offers only fixed-width fonts. +5. Color theme selector switches between CLI-like themes. +6. The default visual feel moves from Raycast-like chrome toward fixed-width, terminal-inspired HUD styling. + +## Current repo evidence + +- `apps/workbranch-companion/src/App.tsx` currently renders a single refresh button in the top-right header and has no settings panel state. +- `apps/workbranch-companion/src/style.css` currently uses `Inter, ui-sans-serif, system-ui` at `:root`; only repo chips use a monospace stack. +- `apps/workbranch-companion/package.json` has Tauri/React dependencies but no autostart/store plugins. +- `apps/workbranch-companion/src-tauri/src/lib.rs` currently initializes only `tauri_plugin_positioner`; no autostart/store plugin is wired. +- `apps/workbranch-companion/src-tauri/capabilities/default.json` must grant explicit plugin permissions when new Tauri plugins are exposed to the frontend. +- Historical Swift plan `docs/plans/0027-companion-launch-at-login.md` resolved the product semantics for launch-at-login: system state is the source of truth, not project config, and the setting should be immediate-apply. The current Tauri app needs a fresh implementation path. +- Official Tauri v2 docs confirm: + - Autostart JS API: `enable`, `disable`, `isEnabled` from `@tauri-apps/plugin-autostart`. + - Autostart permissions: `autostart:allow-enable`, `autostart:allow-disable`, `autostart:allow-is-enabled`. + - Autostart Rust init via `tauri_plugin_autostart::init(...)`. + - Store plugin init via `tauri_plugin_store::Builder::new().build()` and permissions via `store:default`. + +## Decision gates + +- [x] **Scope split from 0036:** resolved to a new 0037 plan. Rationale: settings/preferences plus OS launch-at-login are not just presentation hierarchy and would make 0036 too broad. +- [x] **Visual direction:** resolved to **command-line CLI / terminal HUD**. Rationale: fixed-width type, compact status lines, and theme presets match a developer worktree monitor better than generic app chrome. +- [x] **Launch at login implementation:** resolved to official Tauri `autostart` plugin. Rationale: it is the current Tauri v2 path for `enable`/`disable`/`isEnabled` and avoids hand-written LaunchAgent plumbing. +- [x] **Preference persistence:** resolved to official Tauri `store` plugin. Rationale: font/theme are app preferences, not workbranch project config or CLI contract; store gives explicit app-owned persistence without adding ad-hoc files. +- [x] **Font choices:** resolved to fixed-width fonts only. Rationale: the UI identity should not be breakable by proportional fonts. The selector exposes a curated list and stores a font token, not arbitrary CSS text. +- [x] **Theme choices:** resolved to a small fixed preset list. Rationale: app stays designed and testable; no custom color editor in this slice. + +## Preference contract + +Add a small companion settings model in TypeScript: + +```ts +export type CompanionFont = "system-mono" | "sf-mono" | "menlo" | "monaco" | "jetbrains-mono"; +export type CompanionTheme = "terminal-dark" | "amber-crt" | "green-mono" | "high-contrast"; + +export type CompanionPreferences = { + readonly font: CompanionFont; + readonly theme: CompanionTheme; +}; +``` + +Defaults: + +- `font: "system-mono"` +- `theme: "terminal-dark"` +- launch-at-login default is read from `isEnabled()` and not duplicated in the store. + +Font display labels: + +- `System Mono` -> CSS stack: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace` +- `SF Mono` -> `SFMono-Regular, ui-monospace, Menlo, Monaco, Consolas, monospace` +- `Menlo` -> `Menlo, ui-monospace, Monaco, Consolas, monospace` +- `Monaco` -> `Monaco, ui-monospace, Menlo, Consolas, monospace` +- `JetBrains Mono` -> `"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace` + +Theme presets: + +- `terminal-dark`: near-black background, white/gray text, restrained cyan/blue accent. +- `amber-crt`: near-black background, amber foreground/accent. +- `green-mono`: near-black background, green terminal foreground/accent. +- `high-contrast`: black background, high-contrast white text, stronger borders. + +## File structure + +Modify: + +- `DESIGN.md` โ€” update visual language, typography, color/theme tokens, and settings components to reflect CLI-like companion direction. +- `apps/workbranch-companion/package.json` and lockfile โ€” add official Tauri plugin packages. +- `apps/workbranch-companion/src-tauri/Cargo.toml` / `Cargo.lock` โ€” add autostart/store Rust plugin dependencies through the Tauri add flow. +- `apps/workbranch-companion/src-tauri/src/lib.rs` โ€” initialize autostart and store plugins. +- `apps/workbranch-companion/src-tauri/capabilities/default.json` โ€” add autostart and store permissions. +- `apps/workbranch-companion/src/App.tsx` โ€” add toolbar settings button, settings panel state, preference loading/application, and launch-at-login status wiring. +- `apps/workbranch-companion/src/style.css` โ€” add CLI-like theme variables, font-family variable, settings panel styles, toolbar styles, and theme classes. +- `apps/workbranch-companion/tests/**/*.test.tsx?` โ€” add focused preference/settings tests. +- `TASK-WORKBRANCH.md` โ€” task progress updates only. + +Create: + +- `apps/workbranch-companion/src/application/preferences.ts` โ€” preference types, defaults, font/theme option lists, sanitizers, and store helpers. +- `apps/workbranch-companion/src/ui/SettingsPanel.tsx` โ€” settings panel UI. +- `apps/workbranch-companion/tests/preferences.test.ts` โ€” preference sanitization/default coverage. +- `apps/workbranch-companion/tests/settings-panel.test.tsx` โ€” static markup / callback coverage. + +Do not modify: + +- `apps/workbranch-cli/**` +- `packages/contract/**` +- `apps/workbranch-companion/src/domain/**` +- `apps/workbranch-companion/src/infrastructure/acl.ts` +- watcher/activity-store behavior except plugin initialization required by this settings slice. + +## Task 1: Update design contract for CLI-like settings direction + +**Files:** Modify `DESIGN.md`, `../TASK-WORKBRANCH.md` + +- [ ] Update **Brand** personality to terminal/CLI-like developer HUD, not generic app dashboard. +- [ ] Update **Visual language**: + - typography defaults to fixed-width UI; + - theme tokens include `terminal-dark`, `amber-crt`, `green-mono`, `high-contrast`; + - avoid heavy gradients; prefer prompt-like separators, subtle grid/border lines, compact density. +- [ ] Update **Components** to include top toolbar, settings button, settings panel, switch row, select row, and theme swatches. +- [ ] Update **Accessibility** to require real button labels for refresh/settings, select labels, and switch state text. +- [ ] Verify no placeholders: `rg -n "TBD|TODO|placeholder|fill in" DESIGN.md` -> no matches. + +## Task 2: Add official Tauri plugins + +**Files:** Modify package/Cargo/capabilities/lib.rs surfaces + +- [ ] Add autostart plugin via Tauri CLI from `apps/workbranch-companion`: + + ```bash + pnpm tauri add autostart + ``` + +- [ ] Add store plugin via Tauri CLI from `apps/workbranch-companion`: + + ```bash + pnpm tauri add store + ``` + +- [ ] Confirm `src-tauri/src/lib.rs` initializes: + - `tauri_plugin_autostart::init(...)` + - `tauri_plugin_store::Builder::new().build()` +- [ ] Confirm `src-tauri/capabilities/default.json` includes: + - `autostart:allow-enable` + - `autostart:allow-disable` + - `autostart:allow-is-enabled` + - `store:default` + +## Task 3: Preference model and persistence + +**Files:** Create `src/application/preferences.ts`; Create/modify tests + +- [ ] Add `CompanionFont`, `CompanionTheme`, `CompanionPreferences`, defaults, and option arrays. +- [ ] Add sanitizers so unknown stored values fall back to defaults. +- [ ] Add store helpers around `@tauri-apps/plugin-store` that load and save only `{ font, theme }`. +- [ ] Tests: + - default preferences are terminal-dark + system-mono; + - invalid font/theme sanitize to defaults; + - option arrays contain only fixed-width font choices; + - theme list includes the four resolved presets. + +## Task 4: Settings panel UI + +**Files:** Create `src/ui/SettingsPanel.tsx`; Create `tests/settings-panel.test.tsx` + +- [ ] Render a compact CLI-like panel opened from the top toolbar. +- [ ] Include sections: + - `Startup` with `Launch at login` toggle; + - `Font` select with fixed-width options only; + - `Theme` select/swatches with four presets. +- [ ] The launch-at-login toggle is immediate-apply and calls `enable()`/`disable()` through an injected callback. +- [ ] Font/theme updates save preferences and update the app immediately. +- [ ] Static markup tests assert labels, option names, and accessible control names. + +## Task 5: App shell wiring + +**Files:** Modify `src/App.tsx` + +- [ ] Replace single refresh button with a toolbar group: Refresh and Settings controls separated by a thin divider. +- [ ] Add settings panel open/close state. +- [ ] On app startup, load preferences from store and apply theme/font classes to the root shell. +- [ ] On settings change, save preferences and update root shell immediately. +- [ ] On app startup, call `isEnabled()` to initialize launch-at-login state. +- [ ] On launch-at-login toggle: + - true -> `enable()`, then refresh `isEnabled()`; + - false -> `disable()`, then refresh `isEnabled()`; + - failures set the existing footer/status message. +- [ ] Keep task refresh/watch behavior unchanged. + +## Task 6: CLI-like style system + +**Files:** Modify `src/style.css` + +- [ ] Replace proportional root stack with `var(--app-font-family)` defaulting to fixed-width. +- [ ] Add root classes or data attributes for `font-*` and `theme-*` preferences. +- [ ] Define theme variables for the four presets. +- [ ] Style toolbar as compact command controls. Use text labels or inline SVG icons with `aria-label`s; do not rely on emoji glyphs for refresh/settings icons. +- [ ] Style settings panel as terminal-like block: thin border, compact section labels, aligned select/switch rows, no modal-heavy chrome. +- [ ] Preserve readable contrast and focus rings across all themes. + +## Task 7: Verification + +Automated from repo root: + +```bash +pnpm --filter @workbranch/companion test +pnpm --filter @workbranch/companion typecheck +pnpm --filter @workbranch/companion lint +pnpm --filter @workbranch/companion build +git diff --check +``` + +Tauri/plugin verification: + +```bash +pnpm --filter @workbranch/companion tauri build +``` + +Manual visual/behavior gate: + +```bash +pnpm --filter @workbranch/companion tauri dev +``` + +Manual checks: + +- Header shows refresh and settings controls separated visually. +- Settings panel opens/closes without disrupting task refresh/watch behavior. +- Launch-at-login toggle reflects `isEnabled()` and reports errors in the footer/status line. +- Font selector contains only fixed-width choices and changes the whole companion UI. +- Theme selector changes the whole companion UI across the four presets. +- CLI-like visual feel is stronger than 0036/Raycast: fixed-width text, terminal-like density, less glossy app chrome. +- Keyboard focus rings, labels, empty/error states, and narrow width remain readable. + +## Acceptance criteria + +- `docs/plans/0036-companion-project-grouped-ui.md` remains focused on project grouping/action hierarchy; settings/theme work lives in this 0037 plan. +- Top-right toolbar includes refresh and settings controls. +- Settings panel includes launch-at-login, fixed-width font selector, and color theme selector. +- Launch-at-login uses official Tauri autostart plugin and does not store duplicate launch state in workbranch project config. +- Font/theme preferences persist through official Tauri store plugin. +- All selectable fonts are fixed-width options; arbitrary fonts are not accepted. +- Theme presets are fixed and covered by tests. +- UI visual direction is updated in `DESIGN.md` and implemented via CSS tokens/classes, not ad-hoc inline styles. +- CLI contract, task domain model, ACL mapping, watcher behavior, and activity store behavior are unchanged. + +## Non-goals + +- Do not add task lifecycle mutation UI. +- Do not add arbitrary custom theme editor or arbitrary font input. +- Do not introduce Tailwind/shadcn/Radix. +- Do not change `workbranch list --json` / `list --global --json` schemaVersion 1. +- Do not merge this work back into 0036; 0036 should remain independently implementable. + +## Self-review checklist for this plan + +- [x] Split scope from 0036 is explicit. +- [x] Settings includes launch-at-login, fixed-width font selection, and color theme selection. +- [x] CLI-like visual direction is reflected in design-contract tasks. +- [x] Official Tauri v2 autostart/store plugin paths and permissions are captured. +- [x] Preference persistence excludes project config and CLI contract changes. +- [x] Tests and manual visual gate cover settings behavior and theme/font application. +- [x] No TBD/TODO placeholders. From 80e6c7a65d2b78314bd91ddc87b4ba1c9806fae2 Mon Sep 17 00:00:00 2001 From: tkhwang Date: Wed, 17 Jun 2026 23:43:02 +0900 Subject: [PATCH 2/2] docs(plans): add accessibility requirements and error handling details --- docs/plans/0037-companion-settings-cli-theme.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/plans/0037-companion-settings-cli-theme.md b/docs/plans/0037-companion-settings-cli-theme.md index f9530e4..adad455 100644 --- a/docs/plans/0037-companion-settings-cli-theme.md +++ b/docs/plans/0037-companion-settings-cli-theme.md @@ -172,8 +172,17 @@ Do not modify: - `Theme` select/swatches with four presets. - [ ] The launch-at-login toggle is immediate-apply and calls `enable()`/`disable()` through an injected callback. - [ ] Font/theme updates save preferences and update the app immediately. +- [ ] Font/theme update callbacks must report preference save or sanitization failures to the app shell; do not swallow errors inside the panel. - [ ] Static markup tests assert labels, option names, and accessible control names. +### Accessibility Requirements + +- Use semantic grouping: render the `Startup`, `Font`, and `Theme` sections as `fieldset` elements, each with a visible `legend` child. +- The top toolbar icon button that opens the settings panel must have an `aria-label` that names the action, e.g. `Open settings`. +- Every form control must have an associated `label` element using the `for`/`id` pattern: launch-at-login toggle, font select, and theme select. +- Keyboard focus order must start at the settings panel opener, then move through the panel controls in source order: launch-at-login toggle, font select, theme select, and any close/dismiss control. +- State changes must be announced to screen readers: launch-at-login activation/deactivation and preference save/update results should use an appropriate ARIA live region or equivalent role/state change. + ## Task 5: App shell wiring **Files:** Modify `src/App.tsx` @@ -182,6 +191,8 @@ Do not modify: - [ ] Add settings panel open/close state. - [ ] On app startup, load preferences from store and apply theme/font classes to the root shell. - [ ] On settings change, save preferences and update root shell immediately. +- [ ] On font/theme preference failures, keep or restore the last valid applied preferences and set the existing footer/status message, matching the launch-at-login failure pattern. +- [ ] If a stored or incoming font/theme value sanitizes to a fallback, apply the sanitized value and surface a concise footer/status message instead of silently changing the selection. - [ ] On app startup, call `isEnabled()` to initialize launch-at-login state. - [ ] On launch-at-login toggle: - true -> `enable()`, then refresh `isEnabled()`;