setSelectedIssue(null)} />
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/src/components/kanban/kanban-board.tsx b/src/components/kanban/kanban-board.tsx
new file mode 100644
index 0000000..34eea9d
--- /dev/null
+++ b/src/components/kanban/kanban-board.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { KANBAN_STATUSES } from '../../lib/kanban';
+import type { BeadIssue } from '../../lib/types';
+
+import { KanbanCard } from './kanban-card';
+
+interface KanbanBoardProps {
+ columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>;
+ selectedIssueId: string | null;
+ onSelect: (issue: BeadIssue) => void;
+}
+
+export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) {
+ return (
+
+ {KANBAN_STATUSES.map((status) => (
+
+
+ {status}
+ {columns[status].length}
+
+
+ {columns[status].map((issue) => (
+
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/kanban/kanban-card.tsx b/src/components/kanban/kanban-card.tsx
new file mode 100644
index 0000000..04df80b
--- /dev/null
+++ b/src/components/kanban/kanban-card.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import type { BeadIssue } from '../../lib/types';
+
+import { Chip } from '../shared/chip';
+
+interface KanbanCardProps {
+ issue: BeadIssue;
+ selected: boolean;
+ onSelect: (issue: BeadIssue) => void;
+}
+
+export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
+ return (
+
+ );
+}
diff --git a/src/components/kanban/kanban-controls.tsx b/src/components/kanban/kanban-controls.tsx
new file mode 100644
index 0000000..cedefee
--- /dev/null
+++ b/src/components/kanban/kanban-controls.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban';
+
+import { StatPill } from '../shared/stat-pill';
+
+interface KanbanControlsProps {
+ filters: KanbanFilterOptions;
+ stats: KanbanStats;
+ onFiltersChange: (filters: KanbanFilterOptions) => void;
+}
+
+export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) {
+ return (
+
+
+ onFiltersChange({ ...filters, query: event.target.value })}
+ placeholder="Search by id/title/labels"
+ style={{ flex: 1, minWidth: 260, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }}
+ />
+ onFiltersChange({ ...filters, type: event.target.value })}
+ placeholder="Type (task/bug/feature)"
+ style={{ width: 190, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }}
+ />
+ onFiltersChange({ ...filters, priority: event.target.value })}
+ placeholder="Priority"
+ style={{ width: 110, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/kanban/kanban-detail.tsx b/src/components/kanban/kanban-detail.tsx
new file mode 100644
index 0000000..954d47d
--- /dev/null
+++ b/src/components/kanban/kanban-detail.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import type { BeadIssue } from '../../lib/types';
+
+import { Chip } from '../shared/chip';
+
+interface KanbanDetailProps {
+ issue: BeadIssue | null;
+}
+
+export function KanbanDetail({ issue }: KanbanDetailProps) {
+ if (!issue) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/kanban/kanban-page.tsx b/src/components/kanban/kanban-page.tsx
new file mode 100644
index 0000000..a2e91f9
--- /dev/null
+++ b/src/components/kanban/kanban-page.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+
+import type { KanbanFilterOptions } from '../../lib/kanban';
+import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban';
+import type { BeadIssue } from '../../lib/types';
+
+import { KanbanBoard } from './kanban-board';
+import { KanbanControls } from './kanban-controls';
+import { KanbanDetail } from './kanban-detail';
+
+interface KanbanPageProps {
+ issues: BeadIssue[];
+}
+
+export function KanbanPage({ issues }: KanbanPageProps) {
+ const [filters, setFilters] = useState
({
+ query: '',
+ type: '',
+ priority: '',
+ showClosed: false,
+ });
+ const [selectedIssueId, setSelectedIssueId] = useState(issues[0]?.id ?? null);
+
+ const filteredIssues = useMemo(() => filterKanbanIssues(issues, filters), [issues, filters]);
+ const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]);
+ const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]);
+
+ const selectedIssue = useMemo(
+ () => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? filteredIssues[0] ?? null,
+ [filteredIssues, selectedIssueId],
+ );
+
+ return (
+
+
+
+
+ setSelectedIssueId(issue.id)}
+ />
+
+
+
+ );
+}
diff --git a/src/components/shared/chip.tsx b/src/components/shared/chip.tsx
new file mode 100644
index 0000000..b5150f8
--- /dev/null
+++ b/src/components/shared/chip.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from 'react';
+
+interface ChipProps {
+ children: ReactNode;
+}
+
+export function Chip({ children }: ChipProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/shared/stat-pill.tsx b/src/components/shared/stat-pill.tsx
new file mode 100644
index 0000000..c76422c
--- /dev/null
+++ b/src/components/shared/stat-pill.tsx
@@ -0,0 +1,21 @@
+interface StatPillProps {
+ label: string;
+ value: number;
+}
+
+export function StatPill({ label, value }: StatPillProps) {
+ return (
+
+ );
+}
diff --git a/src/lib/kanban.ts b/src/lib/kanban.ts
new file mode 100644
index 0000000..441a49a
--- /dev/null
+++ b/src/lib/kanban.ts
@@ -0,0 +1,99 @@
+import type { BeadIssue } from './types';
+
+export const KANBAN_STATUSES = ['open', 'in_progress', 'blocked', 'deferred', 'closed'] as const;
+
+export type KanbanStatus = (typeof KANBAN_STATUSES)[number];
+
+export type KanbanColumns = Record;
+
+export interface KanbanFilterOptions {
+ query?: string;
+ type?: string;
+ priority?: string;
+ showClosed?: boolean;
+}
+
+export interface KanbanStats {
+ total: number;
+ open: number;
+ active: number;
+ blocked: number;
+ done: number;
+ p0: number;
+}
+
+function isKanbanStatus(status: string): status is KanbanStatus {
+ return KANBAN_STATUSES.includes(status as KanbanStatus);
+}
+
+function issueSort(a: BeadIssue, b: BeadIssue): number {
+ const priorityDiff = a.priority - b.priority;
+ if (priorityDiff !== 0) {
+ return priorityDiff;
+ }
+
+ return b.updated_at.localeCompare(a.updated_at);
+}
+
+export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOptions): BeadIssue[] {
+ const query = (filters.query ?? '').trim().toLowerCase();
+ const type = (filters.type ?? '').trim().toLowerCase();
+ const priority = (filters.priority ?? '').trim();
+ const showClosed = filters.showClosed ?? false;
+
+ return issues.filter((issue) => {
+ if (!showClosed && issue.status === 'closed') {
+ return false;
+ }
+
+ if (query) {
+ const haystack = [issue.id, issue.title, issue.description ?? '', issue.assignee ?? '', issue.labels.join(' ')].join(' ').toLowerCase();
+ if (!haystack.includes(query)) {
+ return false;
+ }
+ }
+
+ if (type && issue.issue_type.toLowerCase() !== type) {
+ return false;
+ }
+
+ if (priority && String(issue.priority) !== priority) {
+ return false;
+ }
+
+ return true;
+ });
+}
+
+export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns {
+ const columns = {
+ open: [],
+ in_progress: [],
+ blocked: [],
+ deferred: [],
+ closed: [],
+ } as KanbanColumns;
+
+ for (const issue of issues) {
+ if (isKanbanStatus(issue.status)) {
+ columns[issue.status].push(issue);
+ }
+ }
+
+ for (const status of KANBAN_STATUSES) {
+ columns[status].sort(issueSort);
+ }
+
+ return columns;
+}
+
+export function buildKanbanStats(issues: BeadIssue[]): KanbanStats {
+ return {
+ total: issues.length,
+ open: issues.filter((x) => x.status === 'open').length,
+ active: issues.filter((x) => x.status === 'in_progress').length,
+ blocked: issues.filter((x) => x.status === 'blocked').length,
+ done: issues.filter((x) => x.status === 'closed').length,
+ p0: issues.filter((x) => x.priority === 0).length,
+ };
+}
diff --git a/src/lib/read-issues.ts b/src/lib/read-issues.ts
new file mode 100644
index 0000000..8e5412f
--- /dev/null
+++ b/src/lib/read-issues.ts
@@ -0,0 +1,33 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+import { parseIssuesJsonl } from './parser';
+import { canonicalizeWindowsPath } from './pathing';
+import type { BeadIssue } from './types';
+
+export interface ReadIssuesOptions {
+ projectRoot?: string;
+ includeTombstones?: boolean;
+}
+
+export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): string {
+ const absolute = path.resolve(projectRoot, '.beads', 'issues.jsonl');
+ return canonicalizeWindowsPath(absolute);
+}
+
+export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise {
+ const issuesPath = resolveIssuesJsonlPath(options.projectRoot);
+
+ try {
+ const jsonl = await fs.readFile(issuesPath, 'utf8');
+ return parseIssuesJsonl(jsonl, {
+ includeTombstones: options.includeTombstones ?? false,
+ });
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return [];
+ }
+
+ throw error;
+ }
+}
diff --git a/tests/lib/kanban.test.ts b/tests/lib/kanban.test.ts
new file mode 100644
index 0000000..b97efab
--- /dev/null
+++ b/tests/lib/kanban.test.ts
@@ -0,0 +1,88 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import type { BeadIssue } from '../../src/lib/types';
+import {
+ KANBAN_STATUSES,
+ buildKanbanColumns,
+ buildKanbanStats,
+ filterKanbanIssues,
+} from '../../src/lib/kanban';
+
+function issue(overrides: Partial): BeadIssue {
+ return {
+ id: overrides.id ?? 'bb-x',
+ title: overrides.title ?? 'Issue',
+ description: overrides.description ?? null,
+ status: overrides.status ?? 'open',
+ priority: overrides.priority ?? 2,
+ issue_type: overrides.issue_type ?? 'task',
+ assignee: overrides.assignee ?? null,
+ owner: overrides.owner ?? null,
+ labels: overrides.labels ?? [],
+ dependencies: overrides.dependencies ?? [],
+ created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
+ updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
+ closed_at: overrides.closed_at ?? null,
+ close_reason: overrides.close_reason ?? null,
+ closed_by_session: overrides.closed_by_session ?? null,
+ created_by: overrides.created_by ?? null,
+ due_at: overrides.due_at ?? null,
+ estimated_minutes: overrides.estimated_minutes ?? null,
+ external_ref: overrides.external_ref ?? null,
+ metadata: overrides.metadata ?? {},
+ };
+}
+
+test('filterKanbanIssues filters by query/type/priority and closed visibility', () => {
+ const issues = [
+ issue({ id: 'bb-1', title: 'OAuth integration', labels: ['security'], status: 'open', priority: 1, issue_type: 'feature' }),
+ issue({ id: 'bb-2', title: 'Fix timezone bug', status: 'in_progress', priority: 0, issue_type: 'bug' }),
+ issue({ id: 'bb-3', title: 'Done task', status: 'closed', priority: 2, issue_type: 'task' }),
+ ];
+
+ const filtered = filterKanbanIssues(issues, {
+ query: 'oauth',
+ type: 'feature',
+ priority: '1',
+ showClosed: false,
+ });
+
+ assert.equal(filtered.length, 1);
+ assert.equal(filtered[0].id, 'bb-1');
+});
+
+test('buildKanbanColumns groups by core statuses and sorts by priority ascending', () => {
+ const issues = [
+ issue({ id: 'bb-1', status: 'open', priority: 2 }),
+ issue({ id: 'bb-2', status: 'open', priority: 0 }),
+ issue({ id: 'bb-3', status: 'blocked', priority: 1 }),
+ issue({ id: 'bb-4', status: 'pinned', priority: 1 }),
+ ];
+
+ const columns = buildKanbanColumns(issues);
+
+ assert.deepEqual(Object.keys(columns), KANBAN_STATUSES);
+ assert.deepEqual(columns.open.map((x) => x.id), ['bb-2', 'bb-1']);
+ assert.deepEqual(columns.blocked.map((x) => x.id), ['bb-3']);
+ assert.equal(columns.closed.length, 0);
+});
+
+test('buildKanbanStats reports total/open/active/blocked/done/p0', () => {
+ const issues = [
+ issue({ status: 'open', priority: 0 }),
+ issue({ status: 'open', priority: 2 }),
+ issue({ status: 'in_progress', priority: 1 }),
+ issue({ status: 'blocked', priority: 1 }),
+ issue({ status: 'closed', priority: 3 }),
+ ];
+
+ const stats = buildKanbanStats(issues);
+
+ assert.equal(stats.total, 5);
+ assert.equal(stats.open, 2);
+ assert.equal(stats.active, 1);
+ assert.equal(stats.blocked, 1);
+ assert.equal(stats.done, 1);
+ assert.equal(stats.p0, 1);
+});
diff --git a/tests/lib/read-issues.test.ts b/tests/lib/read-issues.test.ts
new file mode 100644
index 0000000..244ac7c
--- /dev/null
+++ b/tests/lib/read-issues.test.ts
@@ -0,0 +1,41 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+
+import { readIssuesFromDisk, resolveIssuesJsonlPath } from '../../src/lib/read-issues';
+import { sameWindowsPath } from '../../src/lib/pathing';
+
+test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => {
+ const resolved = resolveIssuesJsonlPath('C:/Repo/Project');
+ assert.equal(sameWindowsPath(resolved, 'C:/Repo/Project/.beads/issues.jsonl'), true);
+});
+
+test('readIssuesFromDisk parses JSONL issues from disk', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-'));
+ const beadsDir = path.join(root, '.beads');
+ const issuesPath = path.join(beadsDir, 'issues.jsonl');
+
+ await fs.mkdir(beadsDir, { recursive: true });
+ await fs.writeFile(
+ issuesPath,
+ [
+ JSON.stringify({ id: 'bb-1', title: 'Open issue', status: 'open', priority: 0, issue_type: 'task' }),
+ JSON.stringify({ id: 'bb-2', title: 'Hidden tombstone', status: 'tombstone' }),
+ ].join('\n'),
+ 'utf8',
+ );
+
+ const issues = await readIssuesFromDisk({ projectRoot: root });
+
+ assert.equal(issues.length, 1);
+ assert.equal(issues[0].id, 'bb-1');
+ assert.equal(issues[0].priority, 0);
+});
+
+test('readIssuesFromDisk returns empty list when issues file does not exist', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-missing-'));
+ const issues = await readIssuesFromDisk({ projectRoot: root });
+ assert.deepEqual(issues, []);
+});