diff --git a/.gitignore b/.gitignore index 9767bd93..d5f0f61a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ docs/* !docs/agent-coordination-plan.md !docs/agent-handoff-plan.md !docs/evidence-packages.md +!docs/kanban.md # Sandbox bind-mount artifacts from user home (not project files). # Root-anchored so legitimate nested files with these names are still tracked. diff --git a/README.md b/README.md index c2299c35..cbfc5196 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ When you're happy with the result, merge the branch back to main from the sideba - **Understanding tours** — **Take Tour** beside **Review Plan** (and in the plan viewer's header) explains the plan; **Understand** on a changed-file row explains that file. Both open a short card tour: a gist, then 3–7 cards with **Ask** and **Go deeper**. Plan, document and agent tours read like slides: a claim, a few short points, and collapsed notes. Every answer becomes a card under the card it was asked on; ← → move along one level, ↓ ↑ go into and out of answers, and an outline of every card sits beside the card on wide windows. **Save tour** writes the tour with its answers to a `.tour.md` file in the worktree, which opens again from the canvas with **Open Tour**; **Reference in chat** puts the card on screen into the agent's prompt. See [docs/tour-outline.md](docs/tour-outline.md). They use the same code Q&A provider setting as the change tour and inline Q&A, and run without tools: a plan tour sends the plan markdown, a file tour sends the file plus its direct imports (size-capped; anything omitted is listed on the gist card). Unsaved tours live in memory for as long as the task panel is open and are discarded when you switch tasks. A reopened file tour whose file has changed shows a **Regenerate** notice instead of silently going stale. Free-form subsystem questions ("how does X work?") are not supported yet. - **Task brief** — after each agent turn, a model rewrites a short brief from the turn's prompts, the agent's last message and the branch diff (the task-naming model, or Claude Sonnet when that is Claude): claims the diff does not show, what is still open, decisions the agent made on its own, changes beyond the goal, and what changed. A card above the agent's output shows what to do next, marks a new brief with a dot, and is tinted when a claim is not in the diff; click it for the full brief, where you can keep a decision or send the agent a correction. Off by default; turn it on under Settings → **Write task briefs** - **Confidence ratings** — **Rate confidence** on the brief card (shown while Write task briefs is on) asks the agent to rate its work and publish it with the `confidence_publish` MCP tool. Scores are named by whoever rates (one plain score or your own dimensions), low ones are highlighted, and a rating is marked out of date once the code changes after it. Point the prompt at your own rating skill in Settings +- **Project Kanban board** — the board icon on a project row opens a board kept as plain Markdown in `KANBAN.md` (Obsidian Kanban compatible). Start a task from any card; the card follows its task to In progress, Review and Done, shows when the agent needs input, and links to the task. Cards can carry a checklist (acceptance criteria), dependencies and a GitHub issue, added from the board or the GitHub page. **Plan with agent** lets an agent break a goal into cards. See [docs/kanban.md](docs/kanban.md) - **Notes panel per task** — jot ideas, then send the notes straight to the agent as a prompt - **Canvas per task** — a Markdown file from the worktree rendered live as the agent writes it; edit it in place, or select a passage and send it to the agent with your question - **Browser preview in the canvas** — run a local app in the task shell, open **+ → Browser**, and pick elements to reference in your prompt ([usage and limits](docs/browser-preview.md)) diff --git a/docs/kanban.md b/docs/kanban.md new file mode 100644 index 00000000..a47ca5c0 --- /dev/null +++ b/docs/kanban.md @@ -0,0 +1,104 @@ +# Project Kanban board + +Each code project can have a Kanban board, opened from the board icon on the +project's row in the sidebar (next to the GitHub icon). The board is a plain +Markdown file, `KANBAN.md`, in the project root: edit it on the board, in any +editor, or let an agent write it. The board follows changes on disk within a +couple of seconds. + +## File format + + +```markdown +# Offline sync + +Optional notes about the board. + +## Backlog + +- [ ] Conflict resolution UI + Show both versions side by side and let the user pick. + - [ ] Diff view for text fields + depends: Sync engine + issue: https://github.com/acme/app/issues/12 + +## In progress + +- [ ] Sync engine + branch: `task/sync-engine` + +## Done + +- [x] Spike: evaluate CRDT libraries +``` + +- `##` headings are columns; top-level list items are cards; `- [x]` marks a card done. +- Lines indented under a card are its description (Markdown). `- [ ]` items in + it are the card's checklist, shown with progress on the card and given to a + task as acceptance criteria. +- `depends: ` marks the card blocked until that card is done. +- `issue: ` links a GitHub issue or pull request. +- `` branch: `name` `` is written by the app when a task is started from the card. +- Anything else in a column (paragraphs, sub-headings, comments, code) is kept + where it is. Obsidian Kanban's front matter and settings block are kept too, + as are the file's line endings and byte-order mark. The parser follows + CommonMark for code fences, HTML comments and list items, so text inside them + is never read as board structure. +- Column names decide automation: the first "In progress"/"Doing" column is + where started cards go, "Review"/"QA" where finished ones go, "Done" where + merged ones go, and "Archive" is collapsed by default. + +## Working with tasks + +- **Start task** on a card (or `s`) opens the new-task form prefilled from the + card; nothing is sent until you create the task. Card text comes from a file + in the repository, so control and invisible characters are removed from the + prompt. +- Once a worktree task exists, its branch is recorded under the card and the + card moves to In progress. Direct-mode tasks share the base branch and are + not linked. +- The card then shows the task's state (working, needs input, failed, ready + for review), one line of agent activity and the PR checks. Only needs input + and failures are coloured; **Answer** jumps to the waiting agent pane. +- A card moves forward on its own, never backwards: to Review when the agent + signals done or opens a PR, to Done when the branch is merged from the app, + the task lands, or its PR merges on GitHub. +- Merging is not blocked by the board. Unstaged edits to `KANBAN.md` are set + aside before a merge and written back afterwards, whatever the merge does; + if the task's branch changed the board too, the local board wins and the + merged version stays in the commit. Staged board changes still block a + merge, like any other uncommitted change. + +## Planning agent + +**Plan with agent** opens a separate hidden agent session (not the GitHub +page's) in the project checkout, with a planning prompt in its prompt box. It +edits `KANBAN.md` directly and the board highlights the cards that changed. +An unsent draft in its prompt box is never replaced. + +## GitHub issues + +- **Add from GitHub** on the board picks open issues and pull requests; each + becomes a card with an `issue:` line. Only the title is copied: the issue + body stays on GitHub, where a task started from the card reads it. +- On the GitHub page, **Add to board** adds the open issue (or the selected + batch); an issue already on the board shows its column instead. +- A task started from an issue card is linked to the issue like one started + from the GitHub page, and its prompt asks for "Fixes #N" in commits and the PR. +- Nothing is written to GitHub automatically. + +## Safety + +- The main process reads and writes only `KANBAN.md` in the project's real + root, refuses symlinks, FIFOs, files over 1 MB and files that are not valid + UTF-8, and writes atomically. +- Every write names the text it expects to replace. The board re-applies the + user's change to the file as it is on disk when another writer (an editor, + the planning agent, the automation) got there first, so edits are not lost. + +## Code + +- `src/lib/kanban.ts`: parser, serializer and pure board operations. +- `src/store/kanban.ts`: file access, task linking, automation, GitHub helpers. +- `src/components/KanbanPage.tsx`, `KanbanCard.tsx`, `kanban.css`: the page. +- `electron/ipc/kanban.ts`: the main-process file access. diff --git a/electron/github/gh.test.ts b/electron/github/gh.test.ts index 07a646ed..6e1c531d 100644 --- a/electron/github/gh.test.ts +++ b/electron/github/gh.test.ts @@ -16,6 +16,10 @@ describe('describeGhError', () => { expect(describeGhError(err)).toBe('spawn /bin/sh ENOENT'); }); + it('explains a project without a GitHub remote', () => { + expect(describeGhError({ stderr: 'no git remotes found' })).toMatch(/no GitHub remote/); + }); + it('explains missing auth', () => { expect(describeGhError({ stderr: 'You are not logged into any GitHub hosts.' })).toMatch( /not logged in/, diff --git a/electron/github/gh.ts b/electron/github/gh.ts index 38ac4b3d..e5699574 100644 --- a/electron/github/gh.ts +++ b/electron/github/gh.ts @@ -55,6 +55,11 @@ export function describeGhError(err: unknown): string { if (/gh repo set-default/i.test(stderr)) { return 'GitHub CLI does not know which repository to use. Run "gh repo set-default" in the project folder.'; } + if ( + /no git remotes found|none of the git remotes .* point to a known github host/i.test(stderr) + ) { + return 'This project has no GitHub remote, so there are no GitHub issues to show.'; + } if (/rate limit/i.test(stderr)) { return 'GitHub API rate limit reached. Try again in a few minutes.'; } 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/git-merge-guard.test.ts b/electron/ipc/git-merge-guard.test.ts new file mode 100644 index 00000000..48726f2e --- /dev/null +++ b/electron/ipc/git-merge-guard.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { blockingStatusLines } from './git.js'; + +describe('merge clean-tree guard', () => { + it('lets the Kanban board alone through, but nothing else', () => { + expect(blockingStatusLines(' M KANBAN.md\n')).toEqual([]); + expect(blockingStatusLines('?? KANBAN.md\n')).toEqual([]); + expect(blockingStatusLines(' M KANBAN.md\n M src/a.ts\n')).toEqual([' M src/a.ts']); + expect(blockingStatusLines(' M docs/KANBAN.md\n')).toEqual([' M docs/KANBAN.md']); + expect(blockingStatusLines('')).toEqual([]); + // Staged or conflicted board changes are not the app's doing and still block. + expect(blockingStatusLines('M KANBAN.md\n')).toEqual(['M KANBAN.md']); + expect(blockingStatusLines('MM KANBAN.md\n')).toEqual(['MM KANBAN.md']); + expect(blockingStatusLines('A KANBAN.md\n')).toEqual(['A KANBAN.md']); + expect(blockingStatusLines('UU KANBAN.md\n')).toEqual(['UU KANBAN.md']); + expect(blockingStatusLines('R a.md -> KANBAN.md\n')).toEqual(['R a.md -> KANBAN.md']); + }); +}); diff --git a/electron/ipc/git-merge-kanban.integration.test.ts b/electron/ipc/git-merge-kanban.integration.test.ts new file mode 100644 index 00000000..a0994341 --- /dev/null +++ b/electron/ipc/git-merge-kanban.integration.test.ts @@ -0,0 +1,100 @@ +// Integration test against real git: unstaged edits to the project's Kanban +// board (KANBAN.md), which the app makes as tasks move, survive every merge +// outcome, including the `reset --hard` that recovers a failed squash merge. +import { it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { mergeTask } from './git.js'; + +let base: string; + +function run(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +beforeAll(() => { + base = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-kanban-it-')); +}); + +afterAll(() => { + fs.rmSync(base, { recursive: true, force: true }); +}); + +/** A repo with KANBAN.md committed on main and a task branch. */ +function repo(): string { + const root = fs.mkdtempSync(path.join(base, 'repo-')); + run(root, ['init', '-b', 'main']); + run(root, ['config', 'user.email', 'test@test.local']); + run(root, ['config', 'user.name', 'Test']); + run(root, ['config', 'commit.gpgsign', 'false']); + fs.writeFileSync(path.join(root, 'README.md'), 'hello\n'); + fs.writeFileSync(path.join(root, 'KANBAN.md'), '# Board\n\n## Todo\n'); + run(root, ['add', '.']); + run(root, ['commit', '-m', 'initial']); + run(root, ['branch', 'task/a']); + return root; +} + +const LOCAL = '# Board\n\n## Todo\n\n- [ ] Edited on the board\n'; +const board = (root: string) => fs.readFileSync(path.join(root, 'KANBAN.md'), 'utf8'); + +it('merges with unstaged board edits and keeps them', async () => { + const root = repo(); + run(root, ['checkout', 'task/a']); + fs.writeFileSync(path.join(root, 'feature.txt'), 'work\n'); + run(root, ['add', '.']); + run(root, ['commit', '-m', 'work']); + run(root, ['checkout', 'main']); + fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL); + await mergeTask(root, 'task/a', true, 'Squash', false, 'main'); + expect(board(root)).toBe(LOCAL); + expect(fs.existsSync(path.join(root, 'feature.txt'))).toBe(true); +}); + +it('keeps board edits when a squash merge fails and is reset', async () => { + const root = repo(); + // Nothing new on the branch: the squash commit fails and the merge resets hard. + fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL); + await expect(mergeTask(root, 'task/a', true, 'Squash', false, 'main')).rejects.toThrow(); + expect(board(root)).toBe(LOCAL); +}); + +it('keeps board edits when the task branch changed the board too', async () => { + const root = repo(); + run(root, ['checkout', 'task/a']); + fs.writeFileSync(path.join(root, 'KANBAN.md'), '# Board\n\n## Todo\n\n- [ ] From the task\n'); + run(root, ['commit', '-am', 'task edits board']); + run(root, ['checkout', 'main']); + fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL); + await mergeTask(root, 'task/a', true, 'Squash', false, 'main'); + // The local board wins; the task's version is in the commit. + expect(board(root)).toBe(LOCAL); + expect(run(root, ['show', 'HEAD:KANBAN.md'])).toContain('From the task'); +}); + +it('keeps an untracked board through a merge', async () => { + const root = repo(); + run(root, ['rm', '-q', 'KANBAN.md']); + run(root, ['commit', '-m', 'no board']); + run(root, ['branch', '-f', 'task/a']); + run(root, ['checkout', 'task/a']); + fs.writeFileSync(path.join(root, 'feature.txt'), 'work\n'); + run(root, ['add', '.']); + run(root, ['commit', '-m', 'work']); + run(root, ['checkout', 'main']); + fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL); + await mergeTask(root, 'task/a', false, null, false, 'main'); + expect(board(root)).toBe(LOCAL); +}); + +it('still refuses to merge with staged board changes', async () => { + const root = repo(); + fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL); + run(root, ['add', 'KANBAN.md']); + await expect(mergeTask(root, 'task/a', true, 'Squash', false, 'main')).rejects.toThrow( + /uncommitted changes/, + ); + expect(board(root)).toBe(LOCAL); +}); diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index 161b0885..cc30f347 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -3,6 +3,8 @@ import { promisify } from 'util'; import fs from 'fs'; import path from 'path'; import type { Notify } from './notify.js'; +import { KANBAN_FILE } from './kanban.js'; +import { atomicWriteFileSync } from '../mcp/atomic.js'; import { debug as logDebug } from '../log.js'; import { appendGitInfoExcludeBlock, @@ -1453,6 +1455,48 @@ async function seedClaudeSandboxFiles( } } +/** + * Unstaged edits to the project's Kanban board, which the app makes as tasks + * move: ` M KANBAN.md` or `?? KANBAN.md`. Staged or conflicted states are not + * the app's doing and are left to block a merge like any other change. + */ +function isUnstagedKanbanLine(line: string): boolean { + return (line[0] === ' ' || line[0] === '?') && line.slice(3) === KANBAN_FILE; +} + +/** Uncommitted changes that block a merge: all of them except unstaged board edits. */ +export function blockingStatusLines(porcelain: string): string[] { + return porcelain.split('\n').filter((line) => line.trim() && !isUnstagedKanbanLine(line)); +} + +/** + * Moves unstaged Kanban board edits out of the way for a merge and returns + * a function that puts them back. The file returns to its committed state (or + * is removed when untracked), so the merge, its checkout and any recovery + * (`reset --hard`) run on a clean tree and can never touch the board's edits; + * the saved text is written back afterwards whatever the merge did. If the + * merge changed the board too, the local board wins and the merged version + * stays in the commit, visible as a diff. + */ +export async function setAsideKanbanEdits( + mergeRoot: string, + porcelain: string, +): Promise<() => Promise> { + const line = porcelain.split('\n').find(isUnstagedKanbanLine); + if (!line) return async () => {}; + const target = path.join(mergeRoot, KANBAN_FILE); + const saved = await fs.promises.readFile(target); + if (line.startsWith('??')) await fs.promises.unlink(target); + else await exec('git', ['checkout', '--', KANBAN_FILE], { cwd: mergeRoot }); + return async () => { + try { + atomicWriteFileSync(target, saved); + } catch (err) { + console.warn(`Failed to restore ${KANBAN_FILE} after merge:`, err); + } + }; +} + /** * Keep the worktree container out of the project's `git status`. `.worktrees/` * sits inside the repo's own working tree, so in a project whose `.gitignore` @@ -2354,76 +2398,82 @@ export async function mergeTask( mergeWorktreePath ?? (await getBranchWorktreePath(projectRoot, mainBranch))?.path; const mergeRoot = targetWorktree ?? projectRoot; - // Verify clean working tree in the merge root + // Verify clean working tree in the merge root. The project's Kanban board + // (KANBAN.md) is edited in place by the app as tasks move, so its unstaged + // edits do not block a merge: they are set aside and restored around it. const { stdout: statusOut } = await exec('git', ['status', '--porcelain'], { cwd: mergeRoot, }); - if (statusOut.trim()) + if (blockingStatusLines(statusOut).length) throw new Error( 'Working tree has uncommitted changes. Please commit or stash them before merging.', ); + const restoreKanban = await setAsideKanbanEdits(mergeRoot, statusOut); + try { + // Capture the current branch BEFORE any checkout so we can restore it afterward. + const originalBranch = targetWorktree + ? null + : await getCurrentBranchName(projectRoot).catch(() => null); + + if (!targetWorktree) { + // Need to checkout the target branch in the main repo + await exec('git', ['checkout', mainBranch], { cwd: projectRoot }); + } - // Capture the current branch BEFORE any checkout so we can restore it afterward. - const originalBranch = targetWorktree - ? null - : await getCurrentBranchName(projectRoot).catch(() => null); - - if (!targetWorktree) { - // Need to checkout the target branch in the main repo - await exec('git', ['checkout', mainBranch], { cwd: projectRoot }); - } + const restoreBranch = async () => { + if (originalBranch) { + try { + await exec('git', ['checkout', originalBranch], { cwd: projectRoot }); + } catch (e) { + console.warn(`Failed to restore branch '${originalBranch}':`, e); + } + } + }; - const restoreBranch = async () => { - if (originalBranch) { + if (squash) { try { - await exec('git', ['checkout', originalBranch], { cwd: projectRoot }); + await exec('git', ['merge', '--squash', '--', branchName], { cwd: mergeRoot }); } catch (e) { - console.warn(`Failed to restore branch '${originalBranch}':`, e); + await exec('git', ['reset', '--hard', 'HEAD'], { cwd: mergeRoot }).catch((recoverErr) => + console.warn('git reset --hard failed during squash recovery:', recoverErr), + ); + await restoreBranch(); + throw new Error(`Squash merge failed: ${e}`); + } + const msg = message ?? 'Squash merge'; + try { + await exec('git', ['commit', '-m', msg], { cwd: mergeRoot }); + } catch (e) { + await exec('git', ['reset', '--hard', 'HEAD'], { cwd: mergeRoot }).catch((recoverErr) => + console.warn('git reset --hard failed during commit recovery:', recoverErr), + ); + await restoreBranch(); + throw new Error(`Commit failed: ${e}`); + } + } else { + try { + await exec('git', ['merge', '--', branchName], { cwd: mergeRoot }); + } catch (e) { + await exec('git', ['merge', '--abort'], { cwd: mergeRoot }).catch((recoverErr) => + console.warn('git merge --abort failed:', recoverErr), + ); + await restoreBranch(); + throw new Error(`Merge failed: ${e}`); } } - }; - if (squash) { - try { - await exec('git', ['merge', '--squash', '--', branchName], { cwd: mergeRoot }); - } catch (e) { - await exec('git', ['reset', '--hard', 'HEAD'], { cwd: mergeRoot }).catch((recoverErr) => - console.warn('git reset --hard failed during squash recovery:', recoverErr), - ); - await restoreBranch(); - throw new Error(`Squash merge failed: ${e}`); - } - const msg = message ?? 'Squash merge'; - try { - await exec('git', ['commit', '-m', msg], { cwd: mergeRoot }); - } catch (e) { - await exec('git', ['reset', '--hard', 'HEAD'], { cwd: mergeRoot }).catch((recoverErr) => - console.warn('git reset --hard failed during commit recovery:', recoverErr), - ); - await restoreBranch(); - throw new Error(`Commit failed: ${e}`); - } - } else { - try { - await exec('git', ['merge', '--', branchName], { cwd: mergeRoot }); - } catch (e) { - await exec('git', ['merge', '--abort'], { cwd: mergeRoot }).catch((recoverErr) => - console.warn('git merge --abort failed:', recoverErr), - ); - await restoreBranch(); - throw new Error(`Merge failed: ${e}`); + invalidateDiffBaseCache(); + + if (cleanup) { + await removeWorktree(projectRoot, branchName, true, worktreePath); } - } - invalidateDiffBaseCache(); + await restoreBranch(); - if (cleanup) { - await removeWorktree(projectRoot, branchName, true, worktreePath); + return { main_branch: mainBranch, lines_added: linesAdded, lines_removed: linesRemoved }; + } finally { + await restoreKanban(); } - - await restoreBranch(); - - return { main_branch: mainBranch, lines_added: linesAdded, lines_removed: linesRemoved }; }); } diff --git a/electron/ipc/kanban.test.ts b/electron/ipc/kanban.test.ts new file mode 100644 index 00000000..ba8c59d8 --- /dev/null +++ b/electron/ipc/kanban.test.ts @@ -0,0 +1,81 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +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); + }); +}); + +describe('kanban board file hardening', () => { + it('refuses a FIFO without blocking', () => { + execFileSync('mkfifo', [board()]); + expect(() => readKanbanBoard(project)).toThrow(/not a regular file/); + }); + + it('refuses to read, and so never rewrites, a file that is not valid UTF-8', () => { + fs.writeFileSync(board(), Buffer.from([0x23, 0x20, 0xff, 0x0a])); + expect(() => readKanbanBoard(project)).toThrow(/not valid UTF-8/); + }); + + it('reads a byte-order mark and CRLF unchanged', () => { + fs.writeFileSync(board(), '# B\r\n'); + expect(readKanbanBoard(project)).toBe('# B\r\n'); + }); +}); diff --git a/electron/ipc/kanban.ts b/electron/ipc/kanban.ts new file mode 100644 index 00000000..a8d9d9eb --- /dev/null +++ b/electron/ipc/kanban.ts @@ -0,0 +1,105 @@ +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); +} + +const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +/** + * Reads the board through one descriptor that refuses symlinks and does not + * block on a FIFO, checking type and size on that same descriptor, so nothing + * swapped in between a check and the read is ever read. Null when absent. + * Text that is not valid UTF-8 is refused: saving it back would replace bytes. + */ +function readBoardFile(target: string): string | null { + let fd: number; + try { + fd = fs.openSync( + target, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return null; + if (code === 'ELOOP') throw new Error(`${KANBAN_FILE} is not a regular file`); + throw error; + } + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) throw new Error(`${KANBAN_FILE} is not a regular file`); + if (stat.size > KANBAN_MAX_BYTES) throw new Error(`${KANBAN_FILE} is too large (max 1 MB)`); + const buffer = Buffer.alloc(KANBAN_MAX_BYTES + 1); + let length = 0; + for (;;) { + const read = fs.readSync(fd, buffer, length, buffer.length - length, null); + if (read === 0) break; + length += read; + if (length > KANBAN_MAX_BYTES) throw new Error(`${KANBAN_FILE} is too large (max 1 MB)`); + } + try { + return utf8.decode(buffer.subarray(0, length)); + } catch { + throw new Error( + `${KANBAN_FILE} is not valid UTF-8; fix the file's encoding to use the board`, + ); + } + } finally { + fs.closeSync(fd); + } +} + +/** The board's Markdown, or null when the project has no board yet. */ +export function readKanbanBoard(projectRoot: unknown): string | null { + return readBoardFile(boardPath(projectRoot)); +} + +/** + * 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/ipc/trace.ts b/electron/ipc/trace.ts index c1d64361..1ce11287 100644 --- a/electron/ipc/trace.ts +++ b/electron/ipc/trace.ts @@ -34,6 +34,9 @@ const NEVER_SAFE: ReadonlySet = new Set([ IPC.LoadKeybindings, IPC.ShellOpenInEditor, IPC.ShellOpenFile, + // Carry the content of KANBAN.md. + IPC.ReadKanbanBoard, + IPC.WriteKanbanBoard, IPC.ShellReveal, IPC.ShellOpenExternal, IPC.DialogOpen, 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..9514c538 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'; @@ -116,6 +117,7 @@ import { startDesktopNotificationWatcher } from './store/desktopNotifications'; import { startSuperProductivitySync, startSpOpenListener } from './store/store'; import { startBackgroundTaskWatcher } from './store/background-tasks'; import { startTaskBriefWatcher } from './store/task-brief'; +import { startKanbanAutomation } from './store/kanban'; import { startPrChecksSubscription } from './store/pr-checks'; import { startUpdateSubscription } from './store/updates'; import { startRemoteTaskHandlers } from './store/remoteTaskHandler'; @@ -135,6 +137,13 @@ 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 || p.id === store.kanbanProjectId, + ); +} + function DropOverlay() { return (

{current().title}

diff --git a/src/components/GitHubIssuesPage.client.test.tsx b/src/components/GitHubIssuesPage.client.test.tsx index 00384792..5d7219c5 100644 --- a/src/components/GitHubIssuesPage.client.test.tsx +++ b/src/components/GitHubIssuesPage.client.test.tsx @@ -13,7 +13,7 @@ import { startGitHubIssueTask, updateGitHubIssue, } from '../store/github'; -import { ensureGitHubAgentTask, prefillGitHubAgentPrompt } from '../store/github-agent'; +import { ensureProjectAgentTask, prefillGitHubAgentPrompt } from '../store/github-agent'; import { triggerFocus } from '../store/focused-panel'; import { publishGitHubList, showGitHubList } from '../store/github-lists'; import type { GitHubIssueSummary, GitHubIssuePage } from '../ipc/types'; @@ -46,7 +46,7 @@ vi.mock('../store/tasks', () => ({ vi.mock('../store/focused-panel', () => ({ setTaskFocusedPanel: vi.fn(), triggerFocus: vi.fn() })); const agentState = vi.hoisted(() => ({ loaded: true, failed: false, installed: true })); vi.mock('../store/github-agent', () => ({ - ensureGitHubAgentTask: vi.fn(() => null), + ensureProjectAgentTask: vi.fn(() => null), prefillGitHubAgentPrompt: vi.fn(() => true), githubAgentAvailability: () => !agentState.loaded @@ -433,20 +433,23 @@ it('keeps the agent pane closed and starts no session until opened, then starts mount(); await flush(); expect(host.querySelector('.github-agent')).toBeNull(); - expect(ensureGitHubAgentTask).not.toHaveBeenCalled(); - vi.mocked(ensureGitHubAgentTask).mockImplementationOnce(() => { + expect(ensureProjectAgentTask).not.toHaveBeenCalled(); + vi.mocked(ensureProjectAgentTask).mockImplementationOnce(() => { const task = { id: 'gh-agent-p1', projectId: 'p1' } as Task; setStore('tasks', task.id, task); return task; }); button('Agent').click(); - expect(ensureGitHubAgentTask).toHaveBeenCalledTimes(1); - expect(ensureGitHubAgentTask).toHaveBeenCalledWith(expect.objectContaining({ id: 'p1' })); + expect(ensureProjectAgentTask).toHaveBeenCalledTimes(1); + expect(ensureProjectAgentTask).toHaveBeenCalledWith( + 'gh-agent-p1', + expect.objectContaining({ id: 'p1' }), + ); expect(host.querySelector('.github-agent')?.textContent).toContain('Agent pane for gh-agent-p1'); expect(host.querySelector('.github-agent')?.textContent).not.toContain('Start agent'); await flush(); expect(triggerFocus).toHaveBeenCalledWith('gh-agent-p1:prompt'); - expect(ensureGitHubAgentTask).toHaveBeenCalledTimes(1); + expect(ensureProjectAgentTask).toHaveBeenCalledTimes(1); button('Agent').click(); expect(host.querySelector('.github-agent')).toBeNull(); expect(store.githubIssuesProjectId).toBe('p1'); @@ -499,7 +502,7 @@ it('explains in the opened pane that no agent is installed instead of starting o await flush(); button('Agent').click(); expect(host.querySelector('.github-agent')?.textContent).toContain('No agent is installed'); - expect(ensureGitHubAgentTask).not.toHaveBeenCalled(); + expect(ensureProjectAgentTask).not.toHaveBeenCalled(); }); it('disables "List with agent" and says why when no agent is installed', async () => { diff --git a/src/components/GitHubItemPicker.tsx b/src/components/GitHubItemPicker.tsx index b1243283..5863ab74 100644 --- a/src/components/GitHubItemPicker.tsx +++ b/src/components/GitHubItemPicker.tsx @@ -9,6 +9,11 @@ const SEARCH_DEBOUNCE_MS = 400; interface GitHubItemPickerProps { projectRoot: string; onPick: (item: GitHubWorkItem) => void; + /** Marks items that are already taken (e.g. on the Kanban board) and disables them. */ + pickedLabel?: (item: GitHubWorkItem) => string | null; + /** Without its own frame, for use inside a popover that already has one. */ + bare?: boolean; + autofocus?: boolean; } /** Lists the project repo's open issues and PRs, searchable through `gh --search`. */ @@ -28,13 +33,20 @@ export function GitHubItemPicker(props: GitHubItemPickerProps) { display: 'flex', 'flex-direction': 'column', gap: '6px', - padding: '8px', - border: `1px solid ${theme.border}`, - 'border-radius': 'var(--radius-md)', - background: theme.bgInput, + ...(props.bare + ? {} + : { + padding: '8px', + border: `1px solid ${theme.border}`, + 'border-radius': 'var(--radius-md)', + background: theme.bgInput, + }), }} > { + if (props.autofocus) queueMicrotask(() => el.focus()); + }} class="input-field" type="search" aria-label="Search issues and pull requests" @@ -57,7 +69,8 @@ export function GitHubItemPicker(props: GitHubItemPickerProps) { {(err) => (
- {errMessage(err())} + {/* Electron wraps IPC errors in its own prefix; the cause is what helps. */} + {errMessage(err()).replace(/^Error invoking remote method '[^']+': (?:Error: )?/, '')}
)}
@@ -85,7 +98,11 @@ export function GitHubItemPicker(props: GitHubItemPickerProps) { diff --git a/src/components/KanbanCard.tsx b/src/components/KanbanCard.tsx new file mode 100644 index 00000000..b06067f3 --- /dev/null +++ b/src/components/KanbanCard.tsx @@ -0,0 +1,612 @@ +import { + For, + Show, + createMemo, + createResource, + createEffect, + createSignal, + createUniqueId, + on, + onMount, + untrack, + type JSX, +} from 'solid-js'; +import { CloseIcon, CopyIcon, ExternalLinkIcon, GitHubIcon, LinkIcon, PencilIcon } from './icons'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; +import { formatRelativeAge } from '../lib/relativeAge'; +import { cardChecklist, cardSummary, parseGitHubIssueUrl, type KanbanCard } from '../lib/kanban'; +import { readGitHubIssue } from '../store/github'; +import { store } from '../store/core'; +import { getAgentHookStatus } from '../store/agentHookStatus'; +import { getPrChecks } from '../store/pr-checks-state'; +import { computeAttentionEntries, jumpToWaitingTask } from '../store/sidebar-attention'; +import { getReadyLabel, getTaskAttentionState } from '../store/taskStatus'; +import { showNotification } from '../store/notification'; +import type { Task } from '../store/types'; + +/** How a linked task is doing, from the card's point of view. */ +export type CardTaskState = 'needs_input' | 'error' | 'review' | 'active' | 'ready' | 'idle'; + +const [now, setNow] = createSignal(Date.now()); +let clockUsers = 0; +let clock: ReturnType | undefined; + +/** A minute clock for "working 3m" labels, running only while a board is open. */ +export function useKanbanClock(): () => void { + clockUsers++; + if (!clock) clock = setInterval(() => setNow(Date.now()), 30_000); + return () => { + clockUsers--; + if (clockUsers === 0 && clock) { + clearInterval(clock); + clock = undefined; + } + }; +} + +/** The open task of this project working on `branch`, if any. */ +export function linkedTask(projectId: string, branch: string): Task | undefined { + // Only worktree tasks own their branch; a direct task works on a shared one. + return Object.values(store.tasks).find( + (task) => + task?.projectId === projectId && + task.gitIsolation === 'worktree' && + task.branchName === branch, + ); +} + +export function cardTaskState(task: Task): CardTaskState { + const state = getTaskAttentionState(task.id); + if (state === 'needs_input' || state === 'error' || state === 'review' || state === 'active') { + return state; + } + if (task.signalDoneReceived || task.landingState === 'landed_pending_review') return 'review'; + return state === 'ready' ? 'ready' : 'idle'; +} + +/** The most urgent state among a card's linked tasks, or null without live tasks. */ +export function cardState(projectId: string, card: KanbanCard): CardTaskState | null { + const order: CardTaskState[] = ['needs_input', 'error', 'review', 'active', 'ready', 'idle']; + let best: CardTaskState | null = null; + for (const branch of card.branches) { + const task = linkedTask(projectId, branch); + if (!task) continue; + const state = cardTaskState(task); + if (best === null || order.indexOf(state) < order.indexOf(best)) best = state; + } + return best; +} + +function stateLabel(task: Task, state: CardTaskState): string { + switch (state) { + case 'needs_input': + return 'Needs input'; + case 'error': + return 'Failed'; + case 'review': + return 'Ready for review'; + case 'active': + return 'Working'; + case 'ready': + return getReadyLabel(task.id); + default: + return 'Idle'; + } +} + +function firstLine(text: string | undefined): string { + return ( + (text ?? '') + .split('\n') + .find((line) => line.trim()) + ?.trim() ?? '' + ); +} + +/** One line about what the task's agent is doing or last said. */ +function activityLine(task: Task): { text: string; since?: number } { + const hook = task.agentIds.map(getAgentHookStatus).find((status) => status !== null); + if (hook?.state === 'working' && hook.toolName) { + return { + text: [hook.toolName, firstLine(hook.detail)].filter(Boolean).join(' · '), + since: hook.since, + }; + } + const said = firstLine(hook?.lastAssistantMessage); + if (said) return { text: said, since: hook?.since }; + // Nothing reported yet: name the agent rather than echo the prompt, which + // starts with the card's own title. + const agent = task.agentIds.map((id) => store.agents[id]?.def?.name).find(Boolean); + return { text: agent ?? '', since: hook?.since }; +} + +function openTask(task: Task): void { + const question = computeAttentionEntries().find( + (entry) => entry.taskId === task.id && entry.kind === 'question', + ); + jumpToWaitingTask(task.id, question?.panel ?? null); +} + +function TaskRow(props: { task: Task; tabIndex: number }) { + const state = createMemo(() => cardTaskState(props.task)); + const activity = createMemo(() => activityLine(props.task)); + const checks = () => getPrChecks(props.task.id)?.overall; + const urgent = () => state() === 'needs_input' || state() === 'error'; + return ( +
  • +
    +
    + {/* Always rendered so a task starting or stopping never changes the card's height. */} +

    + {activity().text || ' '} +

    +
  • + ); +} + +function BranchRow(props: { branch: string; tabIndex: number }) { + return ( +
  • +
    + + {props.branch} + + +
    +
  • + ); +} + +/** GitHub state per issue URL, shared by every card and kept for a few minutes. */ +const issueStates = new Map }>(); +const ISSUE_STATE_TTL_MS = 5 * 60_000; + +function issueState(url: string): Promise { + const cached = issueStates.get(url); + if (cached && Date.now() - cached.at < ISSUE_STATE_TTL_MS) return cached.state; + const state = readGitHubIssue(url).then( + (issue) => issue.state, + () => null, + ); + issueStates.set(url, { at: Date.now(), state }); + return state; +} + +/** `#12` for the card's GitHub issue or PR, with its state once GitHub answers. */ +function IssueChip(props: { url: string; tabIndex: number }) { + const parsed = () => parseGitHubIssueUrl(props.url); + const [state] = createResource(() => props.url, issueState); + return ( + + {(issue) => ( + + )} + + ); +} + +export interface KanbanCardViewProps { + card: KanbanCard; + projectId: string; + /** Open dependencies (cards on the board that are not done yet). */ + blockedBy: string[]; + selected: boolean; + tabIndex: number; + draggable: boolean; + dropBefore: boolean; + dragging: boolean; + flash: boolean; + dataRef: string; + onSelect: () => void; + onDragStart: (e: DragEvent) => void; + onDragEnd: () => void; + onDragOver: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + onEdit: () => void; + onStart: () => void; + onToggleDone: (done: boolean) => void; + onToggleChecklist: (line: number) => void; +} + +export function KanbanCardView(props: KanbanCardViewProps) { + const titleId = createUniqueId(); + const describedId = createUniqueId(); + const checklist = createMemo(() => cardChecklist(props.card)); + const summary = createMemo(() => cardSummary(props.card)); + const tasks = createMemo(() => + props.card.branches.map((branch) => ({ branch, task: linkedTask(props.projectId, branch) })), + ); + const state = createMemo(() => cardState(props.projectId, props.card)); + const checked = () => checklist().filter((item) => item.done).length; + // What a screen reader hears after the title: the card's state in words. + const description = () => + [ + props.card.done ? 'Done' : '', + state() === 'needs_input' + ? 'Needs input' + : state() === 'error' + ? 'Task failed' + : state() === 'active' + ? 'Task working' + : state() === 'review' + ? 'Ready for review' + : '', + props.blockedBy.length ? `Waits for ${props.blockedBy.join(', ')}` : '', + checklist().length ? `Checklist ${checked()} of ${checklist().length}` : '', + props.card.issue ? `Linked to #${parseGitHubIssueUrl(props.card.issue)?.number ?? ''}` : '', + ] + .filter(Boolean) + .join('. '); + // Inner controls join the tab order only on the selected card. + const innerTab = () => (props.selected ? 0 : -1); + return ( +
    0, + }} + data-card={props.dataRef} + tabIndex={props.tabIndex} + aria-labelledby={titleId} + aria-describedby={describedId} + role="listitem" + draggable={props.draggable} + onFocus={(e) => { + if (e.target === e.currentTarget) props.onSelect(); + }} + onDragStart={(e) => props.onDragStart(e)} + onDragEnd={() => props.onDragEnd()} + onDragOver={(e) => props.onDragOver(e)} + onDrop={(e) => props.onDrop(e)} + onDblClick={(e) => { + if (!(e.target as HTMLElement).closest('button, input, a, label')) props.onEdit(); + }} + > + + {description()} + +
    + + + {props.card.title || 'Untitled'} + +
    + +

    {summary()}

    +
    + +
      + + {(item) => ( +
    • + +
    • + )} +
      + 5}> +
    • +{checklist().length - 5} more
    • +
      +
    +
    + +

    + Waits for {props.blockedBy.join(', ')} +

    +
    + +
      + + {(entry) => ( + } + > + {(task) => } + + )} + +
    +
    + {/* Always rendered, so ticking a card done never changes its height. */} + +
    + ); +} + +/** What the open card editor holds, kept by the board so a re-render cannot drop typed text. */ +export interface EditorDraft { + title: string; + body: string; + depends: string[]; + column: number; +} + +export function KanbanCardEditor(props: { + card: KanbanCard; + /** Text typed before the editor was re-created; wins over the card's own. */ + draft?: EditorDraft | null; + onDraft?: (draft: EditorDraft) => void; + columns: string[]; + column: number; + /** Titles of the other cards, for dependencies. */ + otherTitles: string[]; + onSave: (change: { title: string; body: string; depends: string[] }, column: number) => void; + onDelete: () => void; + onArchive: () => void; + onCancel: () => void; +}) { + const initial = untrack(() => props.card); + const saved = untrack(() => props.draft); + const [title, setTitle] = createSignal(saved?.title ?? initial.title); + const [body, setBody] = createSignal(saved?.body ?? initial.body); + const [depends, setDepends] = createSignal([...(saved?.depends ?? initial.depends)]); + const [column, setColumn] = createSignal(saved?.column ?? untrack(() => props.column)); + createEffect( + on( + [title, body, depends, column], + ([t, b, d, c]) => props.onDraft?.({ title: t, body: b, depends: d, column: c }), + { + defer: true, + }, + ), + ); + let titleRef: HTMLInputElement | undefined; + let bodyRef: HTMLTextAreaElement | undefined; + const grow = () => { + if (!bodyRef) return; + bodyRef.style.height = 'auto'; + bodyRef.style.height = `${Math.min(bodyRef.scrollHeight + 2, 360)}px`; + }; + onMount(() => { + titleRef?.focus(); + titleRef?.select(); + grow(); + }); + const save = () => props.onSave({ title: title(), body: body(), depends: depends() }, column()); + const onKeyDown: JSX.EventHandler = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onCancel(); + } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + save(); + } + e.stopPropagation(); + }; + const candidates = () => props.otherTitles.filter((t) => !depends().includes(t)); + return ( +
    { + e.preventDefault(); + save(); + }} + > + setTitle(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + bodyRef?.focus(); + } + }} + /> +