From 0884032db1ffaebd797a9e551f176ab352985d65 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 8 Jul 2026 20:43:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(board):=20session=20batch=20=E2=80=94=20de?= =?UTF-8?q?lete=20+=20unified=20fixed=20header=20+=20in-card=20live=20cons?= =?UTF-8?q?ole=20+=20reviewer=20field=20+=203/4=20kanban=20token-insights?= =?UTF-8?q?=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete endpoint + trash UI, shared header across all pages, task-log SSE + in-card console, reviewer separate from assignee (board+team), codex board redesign (3/4 kanban + company half-donuts + per-agent bars + square cards). Co-Authored-By: Claude Opus 4.8 --- src/core/index.ts | 17 +- src/core/schema.ts | 1 + src/core/services/taskLogService.ts | 54 ++ src/core/services/taskService.ts | 40 +- src/server/activity.ts | 29 +- src/server/archive.ts | 29 +- src/server/board.ts | 860 ++++++++++++++++++++-------- src/server/decisions.ts | 27 +- src/server/events.ts | 19 +- src/server/fsWatch.ts | 2 +- src/server/routes.ts | 48 +- src/server/taskDetail.ts | 29 +- src/server/team.ts | 30 +- src/server/ui-shared.ts | 227 +++++++- tests/server.test.ts | 13 + tests/taskLogService.test.ts | 42 ++ tests/taskService.test.ts | 50 +- 17 files changed, 1152 insertions(+), 365 deletions(-) create mode 100644 src/core/services/taskLogService.ts create mode 100644 tests/taskLogService.test.ts diff --git a/src/core/index.ts b/src/core/index.ts index d93f726..705db51 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -14,6 +14,7 @@ export interface IndexEntry { status?: string; role?: string; assignedTo?: string; + reviewer?: string; tags?: string; // Handoff-specific routing fields fromRole?: string; @@ -45,6 +46,7 @@ export class Index { status TEXT, role TEXT, assignedTo TEXT, + reviewer TEXT, tags TEXT, fromRole TEXT, toRole TEXT, @@ -60,7 +62,7 @@ export class Index { const existingCols = new Set( (this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name), ); - for (const col of ['fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) { + for (const col of ['reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) { if (!existingCols.has(col)) { this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`); } @@ -72,6 +74,7 @@ export class Index { status: null, role: null, assignedTo: null, + reviewer: null, tags: null, fromRole: null, toRole: null, @@ -83,12 +86,12 @@ export class Index { }; const insert = this.db.prepare(` - INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks) - VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks) + INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks) + VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks) ON CONFLICT(id) DO UPDATE SET type=@type, title=@title, content=@content, filePath=@filePath, createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, - assignedTo=@assignedTo, tags=@tags, + assignedTo=@assignedTo, reviewer=@reviewer, tags=@tags, fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent, taskId=@taskId, relatedTasks=@relatedTasks `); @@ -100,6 +103,12 @@ export class Index { search.run(params); } + /** Permanently drop an entity from both the entities table and the FTS index. */ + remove(id: string): void { + this.db.prepare('DELETE FROM entities WHERE id = @id').run({ id }); + this.db.prepare('DELETE FROM search WHERE id = @id').run({ id }); + } + search(query: string): Array<{ id: string; type: string; title: string }> { const ftsQuery = toFts5Phrase(query); if (!ftsQuery) return []; diff --git a/src/core/schema.ts b/src/core/schema.ts index f55a68b..3c35056 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -15,6 +15,7 @@ export const TaskSchema = z.object({ priority: Priority.default('medium'), role: Role.optional(), assignedTo: z.string().optional(), + reviewer: z.string().optional(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), dueDate: z.string().datetime().optional(), diff --git a/src/core/services/taskLogService.ts b/src/core/services/taskLogService.ts new file mode 100644 index 0000000..1f1f10a --- /dev/null +++ b/src/core/services/taskLogService.ts @@ -0,0 +1,54 @@ +import { join } from 'path'; +import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; +import { getEntityDir } from '../paths.js'; + +/** One streamed progress line for a task's live console. */ +export interface TaskLogEntry { + ts: string; + agent?: string; + level?: string; + text: string; +} + +/** Log lives next to the task's markdown: `.agenthub/tasks/TSK-XXXX.log` (JSONL). */ +function logPath(cwd: string, id: string): string { + const dir = getEntityDir(cwd, 'tasks'); + mkdirSync(dir, { recursive: true }); + return join(dir, `${id}.log`); +} + +/** Append one progress line to a task's log. Returns the stored entry. */ +export function appendTaskLog( + cwd: string, + id: string, + entry: { text: string; agent?: string; level?: string; ts?: string }, +): TaskLogEntry { + if (!id) throw new Error('Task ID is required'); + const text = String(entry.text ?? ''); + if (!text.trim()) throw new Error('log text is required'); + const rec: TaskLogEntry = { + ts: entry.ts || new Date().toISOString(), + agent: entry.agent, + level: entry.level || 'info', + text, + }; + appendFileSync(logPath(cwd, id), JSON.stringify(rec) + '\n'); + return rec; +} + +/** Read a task's log (most recent `limit` lines). Empty array if none yet. */ +export function readTaskLog(cwd: string, id: string, limit = 500): TaskLogEntry[] { + if (!id) throw new Error('Task ID is required'); + const p = logPath(cwd, id); + if (!existsSync(p)) return []; + const lines = readFileSync(p, 'utf8').split('\n').filter(Boolean).slice(-limit); + const out: TaskLogEntry[] = []; + for (const line of lines) { + try { + out.push(JSON.parse(line) as TaskLogEntry); + } catch { + // Tolerate a torn/partial final line rather than failing the whole read. + } + } + return out; +} diff --git a/src/core/services/taskService.ts b/src/core/services/taskService.ts index db5b4f6..1495a51 100644 --- a/src/core/services/taskService.ts +++ b/src/core/services/taskService.ts @@ -1,9 +1,11 @@ import { join } from 'path'; +import { rmSync } from 'fs'; import { getEntityDir } from '../paths.js'; import { getNextId } from '../counter.js'; import { readEntity, writeEntity } from '../files.js'; import { TaskSchema, type Task } from '../schema.js'; import { Index } from '../index.js'; +import { loadConfig } from '../config.js'; export function createTask(cwd: string, options: Partial = {}): Task { const now = new Date().toISOString(); @@ -15,6 +17,7 @@ export function createTask(cwd: string, options: Partial = {}): Task { priority: options.priority ?? 'medium', role: options.role, assignedTo: options.assignedTo, + reviewer: options.reviewer, createdAt: now, updatedAt: now, }); @@ -72,8 +75,14 @@ export function doneTask( }); } -export function reviewTask(cwd: string, id: string): Task { - return updateTask(cwd, id, { status: 'review' }); +export function reviewTask(cwd: string, id: string, reviewer?: string): Task { + const { task } = getTask(cwd, id); + const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd); + const patch: Partial = { status: 'review' }; + if (resolvedReviewer && resolvedReviewer !== task.assignedTo) { + patch.reviewer = resolvedReviewer; + } + return updateTask(cwd, id, patch); } export function cancelTask(cwd: string, id: string): Task { @@ -84,6 +93,24 @@ export function reopenTask(cwd: string, id: string): Task { return updateTask(cwd, id, { status: 'open' }); } +/** + * Permanently delete a task: remove its markdown file and drop it from the + * search index. Unlike {@link cancelTask} (which just flips status), this + * leaves no trace — used to purge ghost/test tasks from the board. Idempotent: + * a missing file is not an error. + */ +export function deleteTask(cwd: string, id: string): { id: string } { + if (!id) throw new Error('Task ID is required'); + const filePath = join(getEntityDir(cwd, 'tasks'), `${id}.md`); + rmSync(filePath, { force: true }); + + const index = new Index(cwd); + index.remove(id); + index.close(); + + return { id }; +} + /** * Address an OPEN task to an agent without claiming it: sets assignedTo but * leaves the status untouched (open). The agent's `agenthub work` then auto- @@ -94,6 +121,14 @@ export function assignTask(cwd: string, id: string, agentName: string): Task { return updateTask(cwd, id, { assignedTo: agentName }); } +function getPreferredReviewer(cwd: string): string | undefined { + try { + return loadConfig(cwd).roles.reviewer?.preferredAgent; + } catch { + return undefined; + } +} + function toIndexEntry(task: Task, filePath: string) { return { id: task.id, @@ -106,6 +141,7 @@ function toIndexEntry(task: Task, filePath: string) { status: task.status, role: task.role, assignedTo: task.assignedTo, + reviewer: task.reviewer, tags: JSON.stringify(task.tags), }; } diff --git a/src/server/activity.ts b/src/server/activity.ts index 7cd412a..6dc07a2 100644 --- a/src/server/activity.ts +++ b/src/server/activity.ts @@ -1,7 +1,7 @@ import { loadConfig } from '../core/config.js'; import { listMessages } from '../core/services/messageService.js'; import { listTasks } from '../core/services/taskService.js'; -import { agentAvatar, designTokensCss, escapeHtml } from './ui-shared.js'; +import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js'; import type { IndexEntry } from '../core/index.js'; function compactDuration(ms: number): string { @@ -84,14 +84,9 @@ export function renderActivityHtml(cwd: string): string { AgentHub Activity -
- AgentHub - ${escapeHtml(config.projectName)} - - -
+ ${appHeader(config.projectName, 'activity')}

Recent Activity

${activityRows.length} events
@@ -135,6 +120,8 @@ export function renderActivityHtml(cwd: string): string {
${archive}
+ ${taskModalHtml()} + ${appHeaderJs()} `; } diff --git a/src/server/archive.ts b/src/server/archive.ts index 9288c6c..7278689 100644 --- a/src/server/archive.ts +++ b/src/server/archive.ts @@ -1,6 +1,6 @@ import { loadConfig } from '../core/config.js'; import { listTasks } from '../core/services/taskService.js'; -import { agentAvatar, designTokensCss, escapeHtml } from './ui-shared.js'; +import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js'; import type { IndexEntry } from '../core/index.js'; function compactDuration(ms: number): string { @@ -48,14 +48,9 @@ export function renderArchiveHtml(cwd: string): string { AgentHub Done Archive -
- AgentHub - ${escapeHtml(config.projectName)} - - -
+ ${appHeader(config.projectName, 'archive')}

Done Archive

@@ -87,6 +72,8 @@ export function renderArchiveHtml(cwd: string): string {
${rows}
+ ${taskModalHtml()} + ${appHeaderJs()} `; } diff --git a/src/server/board.ts b/src/server/board.ts index cad44a2..36eb93a 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -83,7 +83,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); - padding: 0 20px 32px; + padding: 96px 20px 32px; } button, a, .card { cursor: pointer; } a { color: inherit; text-decoration: none; } @@ -93,14 +93,16 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { } .app-header { - position: sticky; + position: fixed; top: 0; - z-index: 20; + left: 0; + right: 0; + z-index: 40; display: flex; align-items: center; gap: 16px; min-height: 64px; - margin: 0 -20px 18px; + margin: 0; padding: 10px 20px; border-bottom: 1px solid var(--border); background: rgba(15, 23, 42, .96); @@ -243,6 +245,18 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { border-radius: 50%; background: currentColor; } + .workbench { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); + gap: 14px; + align-items: start; + } + .board-area { + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; + } .board { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -257,6 +271,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { border-radius: 8px; padding: 10px; min-height: 88px; + box-shadow: 0 14px 36px rgba(2, 6, 18, .18); } .col-head { display: flex; @@ -285,7 +300,13 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .column[data-column="review"] .col-label { color: var(--review); } .column[data-column="done"] .col-label { color: var(--done); } .column[data-column="cancelled"] .col-label { color: var(--cancelled); } - .cards { display: flex; flex-direction: column; gap: 8px; min-width: 0; } + .cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); + gap: 8px; + min-width: 0; + align-items: start; + } .card { min-width: 0; max-width: 100%; @@ -295,10 +316,14 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { border-left: 3px solid var(--accent); border-radius: 8px; padding: 9px 10px; + min-height: 164px; + aspect-ratio: 1; + display: flex; + flex-direction: column; user-select: none; - transition: border-color 180ms ease, background 180ms ease; + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease; } - .card:hover { border-color: rgba(88, 166, 255, .55); background: #223047; } + .card:hover { border-color: rgba(88, 166, 255, .55); background: #223047; transform: translateY(-1px); box-shadow: 0 12px 28px rgba(2, 6, 18, .24); } .column[data-column="open"] .card { border-left-color: var(--open); } .column[data-column="in_progress"] .card { border-left-color: var(--in_progress); } .column[data-column="review"] .card { border-left-color: var(--review); } @@ -322,12 +347,17 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; } .meta-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + margin-top: auto; } .pill, .badge { display: inline-flex; @@ -382,6 +412,18 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { text-overflow: ellipsis; max-width: 120px; } + .reviewer-wrap { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + max-width: 100%; + } + .reviewer-label { + color: var(--review); + font: 10px/1.2 var(--mono); + white-space: nowrap; + } .empty { color: var(--muted); font-size: 12px; padding: 4px 2px; } .panels { @@ -415,7 +457,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { } @media (max-width: 860px) { body { padding-left: 14px; padding-right: 14px; } - .app-header { margin-left: -14px; margin-right: -14px; padding-left: 14px; padding-right: 14px; } + .app-header { padding-left: 14px; padding-right: 14px; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .board { grid-template-columns: repeat(2, 1fr); } .panels { grid-template-columns: 1fr; } @@ -434,7 +476,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .sse-status { margin-left: auto; } .row { align-items: flex-start; flex-direction: column; gap: 4px; } } - /* ── New-task composer ──────────────────────────────────────────────── */ + /* ── New-task button ────────────────────────────────────────────────── */ .new-task-btn { display: inline-flex; align-items: center; @@ -452,105 +494,130 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .new-task-btn:active { transform: translateY(1px); } .new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; } - .composer { - overflow: hidden; - max-height: 0; - opacity: 0; - transition: max-height 260ms cubic-bezier(.2,.7,.2,1), opacity 200ms ease, margin 200ms ease; - margin: 0; - } - .composer.open { max-height: 240px; opacity: 1; margin: 0 0 14px; } - .composer-inner { + /* ── New-task modal ─────────────────────────────────────────────────── */ + .modal-backdrop { + position: fixed; + inset: 0; + z-index: 60; display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; + align-items: flex-start; + justify-content: center; + padding: 12vh 16px 16px; + background: rgba(2, 6, 18, .62); + backdrop-filter: blur(3px); + animation: modalFade 140ms ease; + } + .modal-backdrop[hidden] { display: none; } + @keyframes modalFade { from { opacity: 0; } to { opacity: 1; } } + .modal { + width: min(520px, 100%); background: var(--surface); border: 1px solid var(--border); - border-radius: 12px; - padding: 12px 14px; + border-radius: 14px; + box-shadow: 0 24px 64px rgba(0,0,0,.5); + animation: modalRise 180ms cubic-bezier(.2,.7,.2,1); } - .composer input, .composer select { + @keyframes modalRise { from { transform: translateY(12px); opacity: .4; } to { transform: none; opacity: 1; } } + .modal-head { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 18px 8px; + } + .modal-head h2 { margin: 0; font-size: 16px; font-weight: 700; } + .modal-x { + width: 30px; height: 30px; border-radius: 8px; + border: 1px solid var(--border); background: var(--raised); + color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer; + transition: color 140ms, border-color 140ms; + } + .modal-x:hover { color: var(--text); border-color: var(--muted); } + #tmForm { padding: 6px 18px 18px; display: flex; flex-direction: column; gap: 12px; } + .modal-field { display: flex; flex-direction: column; gap: 5px; } + .modal-field-inline { flex-direction: row; align-items: center; gap: 12px; } + .modal-field-inline .modal-label { margin: 0; } + .modal-label { color: var(--muted); font: 600 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; } + .modal-field input, .modal-field textarea, .modal-field select { background: var(--raised); border: 1px solid var(--border); border-radius: 8px; color: var(--text); - font: 13px/1.3 var(--sans); - padding: 8px 10px; - min-height: 36px; + font: 14px/1.4 var(--sans); + padding: 9px 11px; } - .composer input:focus, .composer select:focus { outline: 2px solid var(--accent); outline-offset: 1px; } - .composer input.title { flex: 1 1 260px; min-width: 200px; } - .composer .create { - min-height: 36px; - padding: 0 16px; - border-radius: 8px; - border: 1px solid var(--green); - background: rgba(34,197,94,.16); - color: #d1fadf; - font: 600 13px/1 var(--sans); - cursor: pointer; + .modal-field textarea { resize: vertical; min-height: 84px; } + .modal-field input:focus, .modal-field textarea:focus, .modal-field select:focus { outline: 2px solid var(--accent); outline-offset: 1px; } + .modal-note { margin: 0; color: var(--muted); font-size: 11.5px; } + .modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; } + .modal-cancel, .modal-create { + min-height: 38px; padding: 0 16px; border-radius: 8px; + font: 600 13px/1 var(--sans); cursor: pointer; + transition: background 160ms ease, transform 120ms ease, border-color 160ms ease; + } + .modal-cancel { border: 1px solid var(--border); background: var(--raised); color: var(--muted); } + .modal-cancel:hover { color: var(--text); border-color: var(--muted); } + .modal-create { border: 1px solid var(--green); background: rgba(34,197,94,.16); color: #d1fadf; } + .modal-create:hover { background: rgba(34,197,94,.26); } + .modal-create:active, .modal-cancel:active { transform: translateY(1px); } + + /* ── Card delete affordance ─────────────────────────────────────────── */ + .card-top-right { display: flex; align-items: center; gap: 6px; } + .card-del { + width: 24px; height: 24px; flex: 0 0 auto; + display: inline-grid; place-items: center; + border-radius: 7px; border: 1px solid transparent; + background: transparent; color: var(--muted); + line-height: 1; cursor: pointer; + opacity: 0; transition: opacity 140ms, color 140ms, border-color 140ms, background 140ms; + } + .card:hover .card-del, .card:focus-within .card-del { opacity: 1; } + .card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); } + + /* ── Confirm (delete) modal ─────────────────────────────────────────── */ + .confirm-modal { width: min(420px, 100%); } + .confirm-body { padding: 2px 18px 2px; } + .confirm-body p { margin: 0; color: var(--muted); font-size: 13.5px; line-height: 1.5; } + .confirm-body b { color: var(--text); } + .confirm-modal .modal-actions { padding: 14px 18px 18px; margin-top: 0; } + .modal-danger { + min-height: 38px; padding: 0 16px; border-radius: 8px; + border: 1px solid var(--danger); background: rgba(248,81,73,.16); color: #ffd7d3; + font: 600 13px/1 var(--sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease; } - .composer .create:hover { background: rgba(34,197,94,.26); } - .composer .create:active { transform: translateY(1px); } - .composer .hint { color: var(--muted); font-size: 11px; flex-basis: 100%; } + .modal-danger:hover { background: rgba(248,81,73,.26); } + .modal-danger:active { transform: translateY(1px); } - /* ── Agents rail (drop targets) ─────────────────────────────────────── */ - .agents-rail { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: stretch; - margin-bottom: 14px; + /* ── In-card live agent console ─────────────────────────────────────── */ + .card-console-wrap { margin-top: 9px; } + .card:has(.card-console:not([hidden])) { + aspect-ratio: auto; + min-height: 230px; } - .agents-rail .rail-label { - align-self: center; - color: var(--muted); - font: 11px/1 var(--mono); - text-transform: uppercase; - letter-spacing: .06em; - margin-right: 2px; + .card-console-toggle { + display: inline-flex; align-items: center; gap: 6px; + background: transparent; border: 1px solid var(--border); + color: var(--muted); font: 700 10px/1 var(--mono); + text-transform: uppercase; letter-spacing: .05em; + padding: 4px 9px; border-radius: 6px; cursor: pointer; + transition: color 140ms, border-color 140ms; } - .agent-chip { - display: flex; - align-items: center; - gap: 9px; - padding: 8px 12px 8px 9px; - border-radius: 999px; - border: 1px solid var(--border); - background: var(--surface); - box-shadow: inset 3px 0 0 var(--agent-color); - transition: border-color 160ms ease, transform 140ms ease, box-shadow 160ms ease, background 160ms ease; - cursor: grab; - } - .agent-chip.dragging { opacity: .5; cursor: grabbing; transform: scale(.97); } - .agent-chip .ac-core { - width: 26px; height: 26px; border-radius: 50%; - display: inline-grid; place-items: center; - background: var(--agent-color); color: #fff; - font: 800 10px/1 var(--mono); flex: 0 0 auto; - } - .agent-chip .ac-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; } - .agent-chip .ac-name { font-weight: 650; font-size: 13px; white-space: nowrap; } - .agent-chip .ac-sub { color: var(--muted); font: 10.5px/1.2 var(--mono); white-space: nowrap; } - .agent-chip .ac-busy { - width: 8px; height: 8px; border-radius: 50%; - background: var(--muted); flex: 0 0 auto; margin-left: 2px; - } - .agent-chip.busy .ac-busy { background: var(--in_progress); box-shadow: 0 0 0 3px rgba(88,166,255,.16); animation: busyPulse 1.6s ease-in-out infinite; } - .agent-chip.busy { background: rgba(88,166,255,.06); } - .agent-chip.drop-active { - border-color: var(--green); - box-shadow: inset 3px 0 0 var(--agent-color), 0 0 0 2px rgba(34,197,94,.35); - transform: translateY(-2px); - } - .agent-chip.ping { animation: agentPing 700ms ease; } - @keyframes busyPulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } } - @keyframes agentPing { - 0% { box-shadow: inset 3px 0 0 var(--agent-color), 0 0 0 0 rgba(34,197,94,.55); } - 100% { box-shadow: inset 3px 0 0 var(--agent-color), 0 0 0 16px rgba(34,197,94,0); } + .card-console-toggle:hover { color: var(--text); border-color: var(--muted); } + .card-console-toggle[aria-expanded="true"] { color: var(--in_progress); border-color: rgba(88,166,255,.4); } + .card-console-toggle .cc-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--in_progress); box-shadow: 0 0 0 3px rgba(88,166,255,.16); animation: ccPulse 1.6s ease-in-out infinite; } + .card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; } + @keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } } + .card-console { margin-top: 7px; } + .card-console[hidden] { display: none; } + .card-console-body { + max-height: 180px; overflow-y: auto; + background: #0b1020; border: 1px solid var(--border); border-radius: 8px; + padding: 8px 10px; font: 11px/1.55 var(--mono); color: #cbd5e1; } + .cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; } + .cc-line .cc-ts { color: var(--muted); opacity: .8; flex: 0 0 auto; } + .cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; } + .cc-line.level-error .cc-text { color: #fca5a5; } + .cc-line.level-warn .cc-text { color: #fcd34d; } + .cc-empty { color: var(--muted); font-style: italic; } /* ── Drag & drop ────────────────────────────────────────────────────── */ .card { touch-action: none; } @@ -570,11 +637,14 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { /* ── Budget panel ───────────────────────────────────────────────────── */ .budget { - margin-top: 20px; + position: sticky; + top: 82px; + min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; + box-shadow: 0 18px 40px rgba(2, 6, 18, .24); } .budget-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; } .budget-head h2 { font-size: 14px; margin: 0; font-weight: 700; } @@ -585,34 +655,60 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { color: var(--text); } .budget-head .total small { color: var(--muted); font-weight: 400; } - .budget-row { - display: grid; - grid-template-columns: 190px 1fr 128px; - align-items: center; - gap: 12px; - padding: 9px 0; - border-top: 1px solid var(--border); + .donut-grid { display: grid; grid-template-columns: 1fr; gap: 12px; } + .donut-card { + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(15, 23, 42, .44); } - .budget-row:first-of-type { border-top: 0; } - .budget-agent { display: flex; align-items: center; gap: 9px; min-width: 0; } + .donut-title { margin: 0 0 6px; color: var(--muted); font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; } + .half-donut { position: relative; min-height: 132px; display: grid; place-items: center; } + .half-donut svg { width: min(220px, 100%); height: auto; overflow: visible; } + .donut-segment { + animation: donutDraw 1100ms cubic-bezier(.2,.7,.2,1) both; + } + @keyframes donutDraw { + from { stroke-dashoffset: 100; } + to { stroke-dashoffset: 0; } + } + .donut-center { + position: absolute; + left: 0; + right: 0; + top: 58%; + transform: translateY(-50%); + text-align: center; + pointer-events: none; + } + .donut-value { display: block; font: 800 28px/1 var(--mono); color: var(--text); letter-spacing: 0; } + .donut-label { display: block; margin-top: 3px; color: var(--muted); font: 10px/1.2 var(--mono); } + .legend { display: grid; gap: 6px; margin-top: 6px; } + .legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 7px; font: 11px/1.25 var(--mono); color: var(--muted); } + .legend-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); } + .legend-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .agent-bars { margin-top: 14px; } + .agent-bars h3 { margin: 0 0 10px; font: 700 12px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } + .agent-bar-row { display: grid; grid-template-columns: minmax(96px, 1fr) 1.35fr; gap: 10px; align-items: center; padding: 8px 0; border-top: 1px solid var(--border); } + .agent-bar-row:first-of-type { border-top: 0; } + .budget-agent { display: flex; align-items: center; gap: 8px; min-width: 0; } .budget-agent .ba-core { width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center; background: var(--agent-color); color: #fff; font: 800 9px/1 var(--mono); flex: 0 0 auto; } - .budget-agent .ba-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .budget-agent .ba-model { color: var(--muted); font: 10.5px/1.2 var(--mono); white-space: nowrap; } - .budget-bar { height: 8px; border-radius: 999px; background: var(--raised); overflow: hidden; position: relative; } - .budget-bar > span { + .budget-agent .ba-name { display: block; font-weight: 650; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .budget-agent .ba-model { display: block; color: var(--muted); font: 10px/1.2 var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .agent-bar-cell { min-width: 0; } + .agent-bar-track { height: 9px; border-radius: 999px; background: var(--raised); overflow: hidden; position: relative; } + .agent-bar-track > span { display: block; height: 100%; - background: linear-gradient(90deg, var(--in_progress), #7ee2a8); - width: 0; transition: width 700ms cubic-bezier(.2,.7,.2,1); + background: linear-gradient(90deg, var(--bar-color), color-mix(in srgb, var(--bar-color) 62%, #fff)); + width: 0; transition: width 900ms cubic-bezier(.2,.7,.2,1); } - .budget-bar.over > span { background: linear-gradient(90deg, var(--review), var(--danger)); } - .budget-tokens { text-align: right; font: 12px/1.3 var(--mono); } - .budget-tokens .tok { color: var(--text); font-weight: 600; } - .budget-tokens .est { color: var(--muted); } - .budget-tokens .eur { color: var(--muted); display: block; font-size: 11px; } + .agent-bar-meta { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; color: var(--muted); font: 10.5px/1.25 var(--mono); } + .agent-bar-meta .tok { color: var(--text); font-weight: 650; } .budget-empty { color: var(--muted); font-size: 12px; padding: 6px 0; } /* ── Toasts ─────────────────────────────────────────────────────────── */ @@ -644,8 +740,15 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { @keyframes toastOut { to { opacity: 0; transform: translateY(6px); } } @media (max-width: 640px) { - .budget-row { grid-template-columns: 1fr 100px; } - .budget-bar { display: none; } + .agent-bar-row { grid-template-columns: 1fr; gap: 6px; } + } + @media (max-width: 1120px) { + .workbench { grid-template-columns: 1fr; } + .budget { position: static; } + } + @media (max-width: 920px) { + .cards { grid-template-columns: 1fr; } + .card { aspect-ratio: auto; min-height: 142px; } } @media (prefers-reduced-motion: reduce) { @@ -678,7 +781,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { Activity Decisions - @@ -688,29 +791,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
-
-
- - - - -
Tip: start the title with an agent name (e.g. codex: …) to address it — moving the card to In Progress then auto-assigns it. Or drag an agent onto a card to assign manually.
-
-
- -
-
Active tasks
0
@@ -722,20 +802,84 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
-
+
+
+
${columnSkeleton()} -
+
-
-
-

Cost & Budget

- estimated from time-on-task - +
+
+

Handoffs

+
loading
+
+
+

Decisions

+
loading
+
+
-
no agent activity yet
-
+ + +
+ + + +
+ ${taskModalHtml()} + ${newTaskModalJs()} `; } diff --git a/src/server/ui-shared.ts b/src/server/ui-shared.ts index e978b1e..506d1e4 100644 --- a/src/server/ui-shared.ts +++ b/src/server/ui-shared.ts @@ -255,16 +255,19 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act return `
${mark}

AgentHub

@@ -279,3 +282,219 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
`; } + +// ───────────────────────────────────────────────────────────────────────────── +// Unified app header — the SAME chrome on every page: brand, nav, a working +// "New task" button (opens the shared modal) and a live connection status. +// The visual design mirrors the board header; these use the designTokensCss +// token names (--font-mono / --font-sans, danger hard-coded) so they render +// identically on the non-board pages. +// ───────────────────────────────────────────────────────────────────────────── + +export type HeaderPage = 'board' | 'team' | 'activity' | 'decisions' | 'archive' | 'task'; + +/** CSS for the shared header + new-task modal + toasts. Include once per page. */ +export function appHeaderCss(): string { + return ` + .app-header { + position: fixed; top: 0; left: 0; right: 0; z-index: 40; + display: flex; align-items: center; gap: 16px; min-height: 64px; + margin: 0; padding: 10px 20px; + border-bottom: 1px solid var(--border); + background: rgba(15, 23, 42, .96); backdrop-filter: blur(10px); + } + .app-header .brand { display: flex; align-items: center; gap: 10px; min-width: 0; } + .app-header .mark { width: 34px; height: 34px; flex: 0 0 auto; border: 1px solid rgba(88,166,255,.38); border-radius: 8px; display: grid; place-items: center; background: var(--raised); } + .app-header .mark svg { width: 22px; height: 22px; } + .brand-copy { min-width: 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } + .brand-title { font-weight: 700; font-size: 17px; } + .project-name { color: var(--muted); font-size: 12px; font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42vw; } + .header-spacer { flex: 1; } + .app-header .nav { display: flex; align-items: center; gap: 4px; border: 1px solid var(--border); background: var(--surface); padding: 3px; border-radius: 8px; } + .app-header .nav-link { min-height: 34px; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 6px; color: var(--muted); font-size: 13px; text-decoration: none; transition: color 180ms ease, background 180ms ease; } + .app-header .nav-link:hover, .app-header .nav-link.active { color: var(--text); background: var(--raised); } + .new-task-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid rgba(88,166,255,.45); background: linear-gradient(180deg, rgba(88,166,255,.20), rgba(88,166,255,.10)); color: var(--text); font: 600 13px/1 var(--font-sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease, box-shadow 160ms ease; } + .new-task-btn:hover { background: rgba(88,166,255,.28); box-shadow: 0 4px 16px rgba(88,166,255,.18); } + .new-task-btn:active { transform: translateY(1px); } + .new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; } + .sse-status { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; color: var(--muted); font: 12px/1 var(--font-mono); white-space: nowrap; } + .conn-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); } + .sse-status.stale .conn-dot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.14); } + .sse-status.down .conn-dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.14); } + @media (max-width: 680px) { + .app-header { flex-wrap: wrap; gap: 10px 12px; } + .header-spacer { display: none; } + .app-header .nav { order: 3; width: 100%; } + .app-header .nav-link { flex: 1; justify-content: center; } + } + + /* ── New-task modal ─────────────────────────────────────────────────── */ + .modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62); backdrop-filter: blur(3px); animation: modalFade 140ms ease; } + .modal-backdrop[hidden] { display: none; } + @keyframes modalFade { from { opacity: 0; } to { opacity: 1; } } + .modal { width: min(520px, 100%); background: var(--surface); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 24px 64px rgba(0,0,0,.5); animation: modalRise 180ms cubic-bezier(.2,.7,.2,1); } + @keyframes modalRise { from { transform: translateY(12px); opacity: .4; } to { transform: none; opacity: 1; } } + .modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 8px; } + .modal-head h2 { margin: 0; font-size: 16px; font-weight: 700; } + .modal-x { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--raised); color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer; transition: color 140ms, border-color 140ms; } + .modal-x:hover { color: var(--text); border-color: var(--muted); } + #tmForm { padding: 6px 18px 18px; display: flex; flex-direction: column; gap: 12px; } + .modal-field { display: flex; flex-direction: column; gap: 5px; } + .modal-field-inline { flex-direction: row; align-items: center; gap: 12px; } + .modal-field-inline .modal-label { margin: 0; } + .modal-label { color: var(--muted); font: 600 11px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; } + .modal-field input, .modal-field textarea, .modal-field select { background: var(--raised); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font: 14px/1.4 var(--font-sans); padding: 9px 11px; } + .modal-field textarea { resize: vertical; min-height: 84px; } + .modal-field input:focus, .modal-field textarea:focus, .modal-field select:focus { outline: 2px solid var(--accent); outline-offset: 1px; } + .modal-note { margin: 0; color: var(--muted); font-size: 11.5px; } + .modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; } + .modal-cancel, .modal-create { min-height: 38px; padding: 0 16px; border-radius: 8px; font: 600 13px/1 var(--font-sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease, border-color 160ms ease; } + .modal-cancel { border: 1px solid var(--border); background: var(--raised); color: var(--muted); } + .modal-cancel:hover { color: var(--text); border-color: var(--muted); } + .modal-create { border: 1px solid var(--green); background: rgba(34,197,94,.16); color: #d1fadf; } + .modal-create:hover { background: rgba(34,197,94,.26); } + .modal-create:active, .modal-cancel:active { transform: translateY(1px); } + + /* ── Toasts ─────────────────────────────────────────────────────────── */ + .toasts { position: fixed; right: 18px; bottom: 18px; z-index: 70; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } + .toast { display: flex; align-items: center; gap: 8px; background: var(--raised); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: 10px; padding: 9px 13px; color: var(--text); font: 13px/1.3 var(--font-sans); box-shadow: 0 8px 28px rgba(0,0,0,.42); animation: toastIn 220ms cubic-bezier(.2,.7,.2,1); max-width: 340px; } + .toast.out { animation: toastOut 240ms ease forwards; } + .toast .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.18); flex: 0 0 auto; } + .toast.err { border-left-color: #F85149; } + .toast.err .dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.18); } + @keyframes toastIn { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } } + @keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }`; +} + +/** The shared header markup: brand, nav, New-task button, live status. */ +export function appHeader(projectName: string, current: HeaderPage): string { + const mark = ``; + const link = (label: string, path: string, key: HeaderPage) => + `${label}`; + return ` +
+
+ ${mark} +
+ AgentHub + ${escapeHtml(projectName)} +
+
+ + + + + + connecting + +
`; +} + +/** The New-task modal + a toasts container. Place near the end of . */ +export function taskModalHtml(): string { + return ` + + +
`; +} + +/** + * Live connection dot via SSE — for pages that do NOT manage their own + * EventSource. (team.ts has its own SSE and repoints its status itself, so it + * uses newTaskModalJs() alone.) + */ +export function connectionStatusJs(): string { + return ` +`; +} + +/** + * New-task modal wiring + a toast helper. After a successful create it + * navigates to /board so the CEO sees the new card land. (The board wires its + * own richer version — do not include this there.) + */ +export function newTaskModalJs(): string { + return ` +`; +} + +/** Convenience bundle for static pages with no own SSE: dot + new-task modal. */ +export function appHeaderJs(): string { + return connectionStatusJs() + newTaskModalJs(); +} diff --git a/tests/server.test.ts b/tests/server.test.ts index 65ec062..34decc0 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -60,6 +60,19 @@ describe('server routes', () => { expect(JSON.parse(res.payload).status).toBe('review'); }); + it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); + const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } }); + expect(res.statusCode).toBe(200); + const task = JSON.parse(res.payload); + expect(task.status).toBe('review'); + expect(task.assignedTo).toBe('codex'); + expect(task.reviewer).toBe('claude'); + + const listRes = await app.inject({ method: 'GET', url: '/tasks' }); + expect(JSON.parse(listRes.payload)[0]).toMatchObject({ assignedTo: 'codex', reviewer: 'claude' }); + }); + it('PATCH /tasks/:id → cancelled', async () => { await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } }); diff --git a/tests/taskLogService.test.ts b/tests/taskLogService.test.ts new file mode 100644 index 0000000..397c9d1 --- /dev/null +++ b/tests/taskLogService.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { appendTaskLog, readTaskLog } from '../src/core/services/taskLogService.js'; + +describe('taskLogService', () => { + let cwd: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'ah-log-')); + }); + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('returns an empty log for a task with no output yet', () => { + expect(readTaskLog(cwd, 'TSK-0001')).toEqual([]); + }); + + it('appends lines and reads them back in order', () => { + appendTaskLog(cwd, 'TSK-0001', { text: 'cloning repo', agent: 'backyard' }); + appendTaskLog(cwd, 'TSK-0001', { text: 'running build', agent: 'backyard', level: 'info' }); + const log = readTaskLog(cwd, 'TSK-0001'); + expect(log).toHaveLength(2); + expect(log[0].text).toBe('cloning repo'); + expect(log[0].agent).toBe('backyard'); + expect(log[1].text).toBe('running build'); + expect(log[0].ts).toBeTruthy(); + }); + + it('keeps each task log isolated', () => { + appendTaskLog(cwd, 'TSK-0001', { text: 'a' }); + appendTaskLog(cwd, 'TSK-0002', { text: 'b' }); + expect(readTaskLog(cwd, 'TSK-0001')).toHaveLength(1); + expect(readTaskLog(cwd, 'TSK-0002')[0].text).toBe('b'); + }); + + it('rejects empty log text', () => { + expect(() => appendTaskLog(cwd, 'TSK-0001', { text: ' ' })).toThrow(); + }); +}); diff --git a/tests/taskService.test.ts b/tests/taskService.test.ts index 714c9ac..8d02fe4 100644 --- a/tests/taskService.test.ts +++ b/tests/taskService.test.ts @@ -4,8 +4,10 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { createTask, listTasks, getTask, - claimTask, doneTask, reviewTask, cancelTask, reopenTask, + claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, } from '../src/core/services/taskService.js'; +import { init } from '../src/cli/commands/init.js'; +import { Index } from '../src/core/index.js'; describe('taskService', () => { let cwd: string; @@ -49,6 +51,25 @@ describe('taskService', () => { expect(listed[0].id).toBe(task.id); }); + it('records an explicit reviewer separately from the assignee', () => { + const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' }); + const inReview = reviewTask(cwd, task.id, 'claude'); + expect(inReview.status).toBe('review'); + expect(inReview.assignedTo).toBe('codex'); + expect(inReview.reviewer).toBe('claude'); + + const listed = listTasks(cwd, { status: 'review' }); + expect(listed[0].assignedTo).toBe('codex'); + expect(listed[0].reviewer).toBe('claude'); + }); + + it('uses the preferred reviewer when reviewTask is called without one', () => { + init(cwd, { projectName: 'reviewer-test', yes: true }); + const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' }); + const inReview = reviewTask(cwd, task.id); + expect(inReview.reviewer).toBe('claude'); + }); + it('cancels a task', () => { const task = createTask(cwd, { title: 'D', role: 'implementer' }); const cancelled = cancelTask(cwd, task.id); @@ -72,4 +93,31 @@ describe('taskService', () => { expect(read.title).toBe('F'); expect(read.role).toBe('architect'); }); + + it('deletes a task: gone from the list, file, and search index', () => { + const keep = createTask(cwd, { title: 'keep me', role: 'implementer' }); + const ghost = createTask(cwd, { title: 'ghost purge me', role: 'implementer' }); + expect(listTasks(cwd)).toHaveLength(2); + + const res = deleteTask(cwd, ghost.id); + expect(res.id).toBe(ghost.id); + + // Removed from the index list… + const remaining = listTasks(cwd); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe(keep.id); + + // …its markdown file is gone (getTask throws)… + expect(() => getTask(cwd, ghost.id)).toThrow(); + + // …and it no longer surfaces in the FTS search index. + const index = new Index(cwd); + const hits = index.search('purge').map((h) => h.id); + index.close(); + expect(hits).not.toContain(ghost.id); + }); + + it('deleteTask is idempotent (deleting a missing task does not throw)', () => { + expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow(); + }); });