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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
94 changes: 65 additions & 29 deletions apps/workbranch-companion/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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: [] };

Expand All @@ -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 (
<div className="app-summary">
<span className="app-inventory">
{taskCount === 0
? "No tasks"
: `${projectCount} ${plural(projectCount, "project")} · ${taskCount} ${plural(taskCount, "task")}`}
</span>
<span className="app-badges">
{active > 0 ? (
<span
className="badge badge-active"
title={`${active} in progress`}
aria-label={`${active} in progress`}
role="img"
>
▶ {active}
</span>
) : null}
{blocked > 0 ? (
<span
className="badge badge-blocked"
title={`${blocked} blocked`}
aria-label={`${blocked} blocked`}
role="img"
>
⚠ {blocked}
</span>
) : null}
{notifications > 0 ? (
<span
className="badge badge-noti"
title={`${notifications} notifications`}
aria-label={`${notifications} notifications`}
role="img"
>
🔔 {notifications}
</span>
) : null}
</span>
</div>
);
}

export function App() {
const [state, setState] = useState<GlobalState>(EMPTY_STATE);
const [status, setStatus] = useState("Ready");
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -130,7 +169,7 @@ export function App() {
return (
<main>
<header>
<h1>{model.title}</h1>
<AppSummary summary={model.summary} />
<button
type="button"
onClick={() => void refresh()}
Expand All @@ -140,16 +179,13 @@ export function App() {
</button>
</header>
<section>
{model.rows.length === 0 ? (
{model.groups.length === 0 ? (
<p className="empty">No workbranch tasks registered.</p>
) : null}
{model.rows.map((row) => (
<TaskRow
key={`${row.root}-${row.task.name}`}
project={row.project}
root={row.root}
task={row.task}
expanded={row.expanded}
{model.groups.map((group) => (
<ProjectGroup
key={group.root}
group={group}
onAction={(root, task, kind) =>
void handleTaskAction(root, task, kind)
}
Expand Down
71 changes: 44 additions & 27 deletions apps/workbranch-companion/src/application/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
6 changes: 1 addition & 5 deletions apps/workbranch-companion/src/infrastructure/tauriClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalState> {
const raw = await invoke<string>("workbranch_list_global");
Expand Down
81 changes: 79 additions & 2 deletions apps/workbranch-companion/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -213,7 +213,6 @@ summary::-webkit-details-marker {
padding: 9px 10px 10px;
}

.project-line,
footer {
color: var(--faint);
font-size: 11px;
Expand Down Expand Up @@ -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;
}
Loading
Loading