From 2e3657812e2e15688c6278a8726b28d9c739fb31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:48:46 +0000 Subject: [PATCH 1/7] feat(kanban): add a Markdown Kanban board per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind Settings → Experimental → Kanban board. Each code project gets a board button in the sidebar that opens a board page in the task area, like the GitHub issues page. The board is plain Markdown in KANBAN.md at the project root: `##` headings are columns, list items are cards, indented lines are the card's description. Edits made in an editor or by an agent show up on the board (polled while it is open), and a write is refused when the file changed since it was last read, so nothing is overwritten. Start task on a card opens the new-task form prefilled from the card. Once the task exists, its branch is recorded under the card (`branch: ...`) and the card moves to the "In progress" column; the card then links to the open task. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LnrB4p2yrMynkvZo7ayVk --- electron/ipc/channel-manifest.json | 2 + electron/ipc/kanban.test.ts | 63 ++ electron/ipc/kanban.ts | 71 +++ electron/ipc/register.ts | 7 + electron/preload.cjs | 2 + src/App.tsx | 25 +- src/components/KanbanPage.client.test.tsx | 188 ++++++ src/components/KanbanPage.tsx | 673 ++++++++++++++++++++++ src/components/NewTaskPanel.tsx | 7 +- src/components/SettingsDialog.tsx | 9 + src/components/Sidebar.tsx | 34 +- src/components/icons.tsx | 14 + src/components/kanban.css | 267 +++++++++ src/lib/kanban.test.ts | 177 ++++++ src/lib/kanban.ts | 371 ++++++++++++ src/store/autosave.ts | 1 + src/store/core.ts | 2 + src/store/github.ts | 1 + src/store/kanban.ts | 94 +++ src/store/navigation.ts | 2 + src/store/persistence.ts | 3 + src/store/projects.ts | 1 + src/store/store.ts | 1 + src/store/terminals.ts | 1 + src/store/types.ts | 15 + src/store/ui.ts | 5 + 26 files changed, 2026 insertions(+), 10 deletions(-) create mode 100644 electron/ipc/kanban.test.ts create mode 100644 electron/ipc/kanban.ts create mode 100644 src/components/KanbanPage.client.test.tsx create mode 100644 src/components/KanbanPage.tsx create mode 100644 src/components/kanban.css create mode 100644 src/lib/kanban.test.ts create mode 100644 src/lib/kanban.ts create mode 100644 src/store/kanban.ts diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index 9c779553..ae79e1ae 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -130,6 +130,8 @@ "ReadFileText": "read_file_text", "ReadFileTourContext": "read_file_tour_context", "SaveTourFile": "save_tour_file", + "ReadKanbanBoard": "read_kanban_board", + "WriteKanbanBoard": "write_kanban_board", "ListThinkingTours": "list_thinking_tours", "ResolveClipboardPaste": "resolve_clipboard_paste", "SaveDroppedImage": "save_dropped_image", diff --git a/electron/ipc/kanban.test.ts b/electron/ipc/kanban.test.ts new file mode 100644 index 00000000..aaf26142 --- /dev/null +++ b/electron/ipc/kanban.test.ts @@ -0,0 +1,63 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { KANBAN_FILE, readKanbanBoard, writeKanbanBoard } from './kanban.js'; + +let project: string; +let outside: string; +const board = () => path.join(project, KANBAN_FILE); + +beforeEach(() => { + project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'kanban-project-'))); + outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'kanban-outside-'))); +}); + +afterEach(() => { + fs.rmSync(project, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); +}); + +describe('kanban board file', () => { + it('reads null when the project has no board', () => { + expect(readKanbanBoard(project)).toBeNull(); + }); + + it('creates the board, then replaces it only while it holds the expected text', () => { + writeKanbanBoard(project, '# One\n', null); + expect(readKanbanBoard(project)).toBe('# One\n'); + writeKanbanBoard(project, '# Two\n', '# One\n'); + expect(readKanbanBoard(project)).toBe('# Two\n'); + }); + + it('never overwrites an edit made since the last read', () => { + writeKanbanBoard(project, '# One\n', null); + fs.writeFileSync(board(), '# Edited elsewhere\n'); + expect(() => writeKanbanBoard(project, '# Two\n', '# One\n')).toThrow(/changed on disk/); + expect(() => writeKanbanBoard(project, '# Two\n', null)).toThrow(/changed on disk/); + expect(fs.readFileSync(board(), 'utf8')).toBe('# Edited elsewhere\n'); + }); + + it('does not recreate a board deleted since the last read', () => { + expect(() => writeKanbanBoard(project, '# Two\n', '# One\n')).toThrow(/changed on disk/); + expect(fs.existsSync(board())).toBe(false); + }); + + it('never follows a symlinked board out of the project', () => { + const target = path.join(outside, 'secret.md'); + fs.writeFileSync(target, 'secret'); + fs.symlinkSync(target, board()); + expect(() => readKanbanBoard(project)).toThrow(/not a regular file/); + expect(() => writeKanbanBoard(project, 'x', 'secret')).toThrow(/not a regular file/); + expect(fs.readFileSync(target, 'utf8')).toBe('secret'); + }); + + it('validates its inputs before touching the disk', () => { + expect(() => readKanbanBoard('relative/path')).toThrow(/absolute/); + expect(() => readKanbanBoard(`${project}/../x`)).toThrow(/\.\./); + expect(() => writeKanbanBoard(project, 42, null)).toThrow(/content/); + expect(() => writeKanbanBoard(project, 'x', 42)).toThrow(/expected/); + expect(() => writeKanbanBoard(project, 'x'.repeat(1024 * 1024 + 1), null)).toThrow(/too large/); + expect(fs.existsSync(board())).toBe(false); + }); +}); diff --git a/electron/ipc/kanban.ts b/electron/ipc/kanban.ts new file mode 100644 index 00000000..1f974413 --- /dev/null +++ b/electron/ipc/kanban.ts @@ -0,0 +1,71 @@ +import fs from 'fs'; +import path from 'path'; + +import { atomicWriteFileSync } from '../mcp/atomic.js'; +import { validatePath } from './validate.js'; + +/** The board lives at the project root as plain Markdown, so it can be read, + * edited and committed like any other file. */ +export const KANBAN_FILE = 'KANBAN.md'; + +/** Far more than any board needs; keeps a stray huge file out of the renderer. */ +export const KANBAN_MAX_BYTES = 1024 * 1024; + +/** What is at `target`: nothing, a regular file, or anything else (a symlink, a folder). */ +function entryAt(target: string): 'missing' | 'file' | 'other' { + try { + return fs.lstatSync(target).isFile() ? 'file' : 'other'; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'; + throw error; + } +} + +/** The board's path inside the project's real root. Validates before touching the disk. */ +function boardPath(projectRoot: unknown): string { + validatePath(projectRoot, 'projectRoot'); + const root = fs.realpathSync(projectRoot as string); + if (!fs.statSync(root).isDirectory()) throw new Error('projectRoot must be a folder'); + return path.join(root, KANBAN_FILE); +} + +function readBoardFile(target: string): string { + if (fs.statSync(target).size > KANBAN_MAX_BYTES) { + throw new Error(`${KANBAN_FILE} is too large (max 1 MB)`); + } + return fs.readFileSync(target, 'utf8'); +} + +/** The board's Markdown, or null when the project has no board yet. */ +export function readKanbanBoard(projectRoot: unknown): string | null { + const target = boardPath(projectRoot); + const entry = entryAt(target); + if (entry === 'missing') return null; + if (entry === 'other') throw new Error(`${KANBAN_FILE} is not a regular file`); + return readBoardFile(target); +} + +/** + * Writes the board. `expected` is the text the caller last read: null creates + * the file and refuses to replace one; a string replaces the file only while it + * still holds exactly that text, so an edit made in another editor (or by an + * agent) since the last read is never overwritten. A symlink is never followed. + */ +export function writeKanbanBoard(projectRoot: unknown, content: unknown, expected: unknown): void { + if (typeof content !== 'string') throw new Error('content must be a string'); + if (expected !== null && typeof expected !== 'string') { + throw new Error('expected must be a string or null'); + } + if (Buffer.byteLength(content, 'utf8') > KANBAN_MAX_BYTES) { + throw new Error(`The board is too large to save (max 1 MB)`); + } + const target = boardPath(projectRoot); + const entry = entryAt(target); + if (entry === 'other') throw new Error(`${KANBAN_FILE} is not a regular file`); + if (expected === null) { + if (entry === 'file') throw new Error(`${KANBAN_FILE} changed on disk; reload the board`); + } else if (entry === 'missing' || readBoardFile(target) !== expected) { + throw new Error(`${KANBAN_FILE} changed on disk; reload the board`); + } + atomicWriteFileSync(target, content); +} diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index cbe1aeb7..00d3955f 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -51,6 +51,7 @@ import { } from './plans.js'; import { readFileTourContext } from './understanding-context.js'; import { saveTourFile } from './tour-files.js'; +import { readKanbanBoard, writeKanbanBoard } from './kanban.js'; import { listThinkingTours } from './thinking-tours.js'; import { prepareReasoningFeed, @@ -1490,6 +1491,12 @@ export function registerAllHandlers(win: BrowserWindow): void { return saveTourFile(args.worktreePath, args); }); + // --- Kanban board (KANBAN.md at the project root) --- + ipcMain.handle(IPC.ReadKanbanBoard, (_e, args) => readKanbanBoard(args?.projectRoot)); + ipcMain.handle(IPC.WriteKanbanBoard, (_e, args) => + writeKanbanBoard(args?.projectRoot, args?.content, args?.expected), + ); + // Tours written outside any task; they save through SaveTourFile into this folder. ipcMain.handle(IPC.ListThinkingTours, () => listThinkingTours(path.join(getUserDataDir(), 'thinking')), diff --git a/electron/preload.cjs b/electron/preload.cjs index dc36366d..4592e4dc 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -137,6 +137,8 @@ const ALLOWED_CHANNELS = new Set([ 'read_file_text', 'read_file_tour_context', 'save_tour_file', + 'read_kanban_board', + 'write_kanban_board', 'list_thinking_tours', 'resolve_clipboard_paste', 'save_dropped_image', diff --git a/src/App.tsx b/src/App.tsx index 2862f3f1..b36a69b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { GitHubIssuesPage } from './components/GitHubIssuesPage'; +import { KanbanPage } from './components/KanbanPage'; import '@xterm/xterm/css/xterm.css'; import '@fontsource/inter/400.css'; import '@fontsource/inter/500.css'; @@ -135,6 +136,15 @@ const ArenaOverlay = lazy(() => const MIN_WINDOW_DIMENSION = 100; +/** Whether a project page (GitHub issues or the Kanban board) covers the task area. */ +function projectPageOpen(): boolean { + return store.projects.some( + (p) => + p.id === store.githubIssuesProjectId || + (store.kanbanEnabled && p.id === store.kanbanProjectId), + ); +} + function DropOverlay() { return (