Skip to content

Commit acc301c

Browse files
committed
feat: improve label filtering and listing
1 parent 2a39054 commit acc301c

7 files changed

Lines changed: 225 additions & 12 deletions

File tree

apps/web/src/components/FilterBar.tsx

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { Check, ChevronDown } from "lucide-react";
12
import { Button } from "./ui/button";
3+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
24

35
interface FilterBarProps {
46
repositories: { id: string; name: string }[];
@@ -9,8 +11,34 @@ interface FilterBarProps {
911
onLabelChange: (label: string | null) => void;
1012
}
1113

14+
const VISIBLE_LABEL_LIMIT = 6;
15+
16+
function labelStyle(label: { color: string }, active: boolean) {
17+
return {
18+
color: label.color,
19+
borderColor: `color-mix(in srgb, ${label.color} ${active ? 48 : 30}%, transparent)`,
20+
backgroundColor: `color-mix(in srgb, ${label.color} ${active ? 14 : 6}%, transparent)`,
21+
};
22+
}
23+
24+
function splitLabels(labels: FilterBarProps["labels"], activeLabel: string | null) {
25+
const visible = labels.slice(0, VISIBLE_LABEL_LIMIT);
26+
if (!activeLabel || visible.some((label) => label.name === activeLabel)) return { visible, overflow: labels.slice(VISIBLE_LABEL_LIMIT) };
27+
28+
const active = labels.find((label) => label.name === activeLabel);
29+
if (!active) return { visible, overflow: labels.slice(VISIBLE_LABEL_LIMIT) };
30+
31+
return {
32+
visible: [...visible.slice(0, VISIBLE_LABEL_LIMIT - 1), active],
33+
overflow: labels.filter(
34+
(label) => !visible.slice(0, VISIBLE_LABEL_LIMIT - 1).some((item) => item.name === label.name) && label.name !== activeLabel,
35+
),
36+
};
37+
}
38+
1239
export function FilterBar({ repositories, labels, activeRepository, activeLabel, onRepositoryChange, onLabelChange }: FilterBarProps) {
1340
if (repositories.length === 0 && labels.length === 0) return null;
41+
const { visible, overflow } = splitLabels(labels, activeLabel);
1442

1543
return (
1644
<div className="flex items-center justify-between gap-3 px-5 py-2.5 border-b border-border">
@@ -27,7 +55,7 @@ export function FilterBar({ repositories, labels, activeRepository, activeLabel,
2755
</div>
2856
)}
2957
{labels.length > 0 && (
30-
<div className="ml-auto flex max-w-[50%] shrink-0 justify-end gap-2 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden max-md:max-w-full">
58+
<div className="ml-auto flex max-w-[50%] shrink-0 justify-end gap-2 pb-1 max-md:max-w-full">
3159
<Button
3260
variant={activeLabel === null ? "secondary" : "outline"}
3361
size="xs"
@@ -36,7 +64,7 @@ export function FilterBar({ repositories, labels, activeRepository, activeLabel,
3664
>
3765
All labels
3866
</Button>
39-
{labels.map((label) => {
67+
{visible.map((label) => {
4068
const active = activeLabel === label.name;
4169
return (
4270
<Button
@@ -46,16 +74,44 @@ export function FilterBar({ repositories, labels, activeRepository, activeLabel,
4674
className="h-5 max-w-28 rounded-[4px] px-1.5 font-mono text-[10px] font-medium"
4775
onClick={() => onLabelChange(label.name)}
4876
title={label.description || label.name}
49-
style={{
50-
color: label.color,
51-
borderColor: `color-mix(in srgb, ${label.color} ${active ? 48 : 30}%, transparent)`,
52-
backgroundColor: `color-mix(in srgb, ${label.color} ${active ? 14 : 6}%, transparent)`,
53-
}}
77+
style={labelStyle(label, active)}
5478
>
5579
<span className="min-w-0 truncate">{label.name}</span>
5680
</Button>
5781
);
5882
})}
83+
{overflow.length > 0 && (
84+
<DropdownMenu>
85+
<DropdownMenuTrigger
86+
render={<Button variant="outline" size="xs" className="h-5 rounded-[4px] px-1.5 font-mono text-[10px] font-medium" />}
87+
>
88+
More
89+
<ChevronDown className="size-3" />
90+
</DropdownMenuTrigger>
91+
<DropdownMenuContent align="end" className="max-h-72 w-56">
92+
{overflow.map((label) => {
93+
const active = activeLabel === label.name;
94+
return (
95+
<DropdownMenuItem
96+
key={label.name}
97+
className="cursor-pointer text-xs"
98+
onClick={() => onLabelChange(label.name)}
99+
title={label.description || label.name}
100+
>
101+
<span
102+
className="size-2 shrink-0 rounded-[2px]"
103+
style={{
104+
backgroundColor: label.color,
105+
}}
106+
/>
107+
<span className="min-w-0 flex-1 truncate font-mono">{label.name}</span>
108+
{active && <Check className="size-3 text-accent" />}
109+
</DropdownMenuItem>
110+
);
111+
})}
112+
</DropdownMenuContent>
113+
</DropdownMenu>
114+
)}
59115
</div>
60116
)}
61117
</div>

packages/cli/src/client/base.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { MachineRuntime, UsageInfo } from "@agent-kanban/shared";
1+
import type { BoardWithTasks, MachineRuntime, UsageInfo } from "@agent-kanban/shared";
22
import { getVersion } from "../version.js";
33

44
export class ApiError extends Error {
@@ -174,7 +174,7 @@ export abstract class ApiClient {
174174
return this.request("GET", `/api/boards?name=${encodeURIComponent(name)}`);
175175
}
176176
getBoard(boardId: string) {
177-
return this.request("GET", `/api/boards/${boardId}`);
177+
return this.request<BoardWithTasks>("GET", `/api/boards/${boardId}`);
178178
}
179179
updateBoard(boardId: string, body: Record<string, unknown>) {
180180
return this.request("PATCH", `/api/boards/${boardId}`, body);

packages/cli/src/commands/get.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatAgentList,
66
formatBoard,
77
formatBoardList,
8+
formatLabelList,
89
formatRepository,
910
formatRepositoryList,
1011
formatTask,
@@ -85,6 +86,18 @@ export function registerGetCommand(program: Command) {
8586
}
8687
});
8788

89+
getCmd
90+
.command("label")
91+
.description("List board labels")
92+
.requiredOption("--board <id>", "Board ID")
93+
.option("-o, --output <format>", "Output format (json, yaml, text)")
94+
.action(async (opts) => {
95+
const client = await createClient();
96+
const fmt = getOutputFormat(opts.output);
97+
const board = await client.getBoard(opts.board);
98+
output(board.labels ?? [], fmt, formatLabelList, { kind: "label" });
99+
});
100+
88101
getCmd
89102
.command("task [id]")
90103
.description("Get a task or list tasks")

packages/cli/src/output.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@ export function formatBoardList(boards: any[]): string {
106106
return lines.join("\n");
107107
}
108108

109+
export function formatLabelList(labels: any[]): string {
110+
if (labels.length === 0) return "No labels found.";
111+
112+
return labels
113+
.map((label) => {
114+
const description = label.description ? ` — ${label.description}` : "";
115+
return ` ${label.name} ${label.color}${description}`;
116+
})
117+
.join("\n");
118+
}
119+
109120
export function formatRepository(repo: any): string {
110121
const lines: string[] = [];
111122
lines.push(`${repo.name}`);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// @vitest-environment node
2+
import { Command } from "commander";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const mockGetBoard = vi.fn();
6+
const mockClient = { getBoard: mockGetBoard };
7+
const mockCreateClient = vi.fn(() => Promise.resolve(mockClient));
8+
const mockOutput = vi.fn();
9+
const mockFormatLabelList = vi.fn();
10+
11+
vi.mock("../src/agent/leader.js", () => ({
12+
createClient: mockCreateClient,
13+
}));
14+
15+
vi.mock("../src/output.js", () => ({
16+
getOutputFormat: vi.fn(() => "text"),
17+
output: mockOutput,
18+
formatAgent: vi.fn(),
19+
formatAgentList: vi.fn(),
20+
formatBoard: vi.fn(),
21+
formatBoardList: vi.fn(),
22+
formatLabelList: mockFormatLabelList,
23+
formatRepository: vi.fn(),
24+
formatRepositoryList: vi.fn(),
25+
formatTask: vi.fn(),
26+
formatTaskList: vi.fn(),
27+
formatTaskListWide: vi.fn(),
28+
formatTaskNotes: vi.fn(),
29+
}));
30+
31+
const { registerGetCommand } = await import("../src/commands/get.js");
32+
33+
function makeProgram(): Command {
34+
const program = new Command();
35+
program.exitOverride();
36+
registerGetCommand(program);
37+
return program;
38+
}
39+
40+
let exitSpy: ReturnType<typeof vi.spyOn>;
41+
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
mockGetBoard.mockResolvedValue({
45+
id: "board-1",
46+
labels: [{ name: "backend", color: "#38BDF8", description: "Backend/API work" }],
47+
});
48+
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
49+
throw new Error("process.exit called");
50+
}) as any);
51+
});
52+
53+
afterEach(() => {
54+
exitSpy.mockRestore();
55+
});
56+
57+
describe("get label", () => {
58+
it("loads labels from the board", async () => {
59+
const program = makeProgram();
60+
await program.parseAsync(["get", "label", "--board", "board-1"], { from: "user" });
61+
62+
expect(mockGetBoard).toHaveBeenCalledWith("board-1");
63+
expect(mockOutput).toHaveBeenCalledWith([{ name: "backend", color: "#38BDF8", description: "Backend/API work" }], "text", mockFormatLabelList, {
64+
kind: "label",
65+
});
66+
});
67+
68+
it("outputs an empty label list when the board has no labels", async () => {
69+
mockGetBoard.mockResolvedValue({ id: "board-1", labels: undefined });
70+
const program = makeProgram();
71+
await program.parseAsync(["get", "label", "--board", "board-1"], { from: "user" });
72+
73+
expect(mockOutput).toHaveBeenCalledWith([], "text", mockFormatLabelList, { kind: "label" });
74+
});
75+
});

skills/ak-plan/SKILL.md

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The leader chooses its own username and optional full name.
3737
## Input
3838

3939
Parse the user's input:
40-
- **Name** — version (e.g. "v1.4.0") or product name (e.g. "my-api")
40+
- **Name** — version (e.g. "v1.4") or product name (e.g. "my-api"). If the user provides a patch version such as `v1.4.0`, normalize task labels to `v1.4`.
4141
- **Goals** — what to achieve (if not provided, ask)
4242

4343
## Phase 0: Detect Mode
@@ -142,6 +142,44 @@ Create all tasks? (y/n)
142142

143143
The user must confirm before any `ak create task` calls are made. If the user requests changes, adjust and re-preview.
144144

145+
### Label Best Practices
146+
147+
Labels are board-level taxonomy, not free-form notes. Before task creation, define the small label set this plan will use and show it in the preview. Prefer reusing existing board labels and adding only labels that will remain useful for future filtering.
148+
149+
Recommended label categories:
150+
151+
- **Version** — usually one version label per versioned task, formatted `vX.Y` (for example `v1.4`, `v2.0`). Prefer avoiding patch versions (`v1.4.0`) or suffixes (`v1.4-final`, `v1.4-test`) unless the board already has a specific reason to track that granularity.
152+
- **Area** — one or two stable implementation areas: `backend`, `frontend`, `cli`, `api`, `database`, `infra`, `docs`, `ui`, `security`, `test`.
153+
- **Type** — optional, only when it materially helps filtering: `feature`, `bug`, `refactor`.
154+
155+
Prefer keeping temporary process state, tools, providers, experiments, and implementation trivia in the task description instead of labels. Labels such as `done`, `setup:lefthook`, `prompt-fix-test`, `smoke-test`, `cost-test`, `codex`, `github`, `cloudflare`, `tanstack-query`, or file/library names usually become noisy unless the board already uses that exact label intentionally.
156+
157+
When labels overlap, choose the stable category:
158+
159+
- Use `infra`, not `infrastructure`.
160+
- Use `bug`, not `bugfix`.
161+
- Use `database`, not `db`.
162+
- Use `frontend` for UI implementation unless the task is specifically design polish, then add `ui`.
163+
164+
Task labels must already exist on the board. Check existing labels first; if a needed label does not exist, create it with color and description, then use it on tasks:
165+
166+
```bash
167+
ak get label --board $BOARD
168+
ak create label --board $BOARD --name v1.4 --color "#22C55E" --description "Version 1.4"
169+
ak create label --board $BOARD --name backend --color "#38BDF8" --description "Backend/API work"
170+
ak create label --board $BOARD --name bug --color "#F87171" --description "Bug fix"
171+
```
172+
173+
Useful color defaults:
174+
175+
- Version: `#22C55E`
176+
- Frontend/UI: `#A78BFA`
177+
- Backend/API/database: `#38BDF8`
178+
- CLI/runtime: `#22D3EE`
179+
- Bug/security: `#F87171`
180+
- Infra/deploy: `#F59E0B`
181+
- Docs/refactor/general: `#71717A`
182+
145183
## Phase 3: Create Board, Workers & Tasks
146184

147185
Use the existing board for the project. One project = one board.
@@ -190,7 +228,7 @@ Create tasks with full specs. For each task:
190228
- API endpoints, DB queries, UI components (concrete, not vague)
191229
- Patterns to follow from the existing codebase
192230
3. **`--repo <id>`** — from `ak repo list`
193-
4. **`--labels`** — include version label (e.g. `v1.4.0`) plus category (backend, frontend, cli, etc.)
231+
4. **`--labels`** — include the planned `vX.Y` version label plus one or two stable area/type labels
194232
5. **`--assign-to <agent-id>`** — worker chosen before task creation
195233
6. **`--depends-on`** — task IDs this depends on
196234

@@ -211,7 +249,8 @@ T2=$(ak create task --board $BOARD --title "..." --repo $REPO --assign-to $AGENT
211249
- Assign every task at creation with `--assign-to`.
212250
- Use `--depends-on` for real blockers or overlapping context. Tasks touching the same files, data model, or API contract should be sequential or merged.
213251
- Keep parallel tasks independent by feature/module boundary and data model boundary.
214-
- Use stable labels: version plus area, such as `v1.4.0,backend` or `v1.4.0,cli`.
252+
- Use stable labels: version plus area, such as `v1.4,backend` or `v1.4,cli`.
253+
- Keep the board label set small and reusable. If a label would be used by only one task and is not a version label, put that detail in the task description instead.
215254

216255
### Task Description Quality
217256

tests/output.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatAgentList,
66
formatBoard,
77
formatBoardList,
8+
formatLabelList,
89
formatRepositoryList,
910
formatTask,
1011
formatTaskList,
@@ -402,6 +403,24 @@ describe("formatBoardList", () => {
402403
});
403404
});
404405

406+
describe("formatLabelList", () => {
407+
it("returns 'No labels found.' for empty array", () => {
408+
expect(formatLabelList([])).toBe("No labels found.");
409+
});
410+
411+
it("includes label name and color", () => {
412+
const labels = [{ name: "backend", color: "#38BDF8", description: "" }];
413+
const result = formatLabelList(labels);
414+
expect(result).toContain("backend");
415+
expect(result).toContain("#38BDF8");
416+
});
417+
418+
it("includes description when present", () => {
419+
const labels = [{ name: "v1.4", color: "#22C55E", description: "Version 1.4" }];
420+
expect(formatLabelList(labels)).toContain("Version 1.4");
421+
});
422+
});
423+
405424
describe("formatRepositoryList", () => {
406425
it("returns 'No repositories found.' for empty array", () => {
407426
expect(formatRepositoryList([])).toBe("No repositories found.");

0 commit comments

Comments
 (0)