feat(board): session batch — delete + unified fixed header + in-card live console + reviewer field + 3/4 kanban token-insights redesign

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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-08 20:43:42 +02:00
parent 1bb18d96b5
commit 0884032db1
17 changed files with 1152 additions and 365 deletions

View File

@ -14,6 +14,7 @@ export interface IndexEntry {
status?: string; status?: string;
role?: string; role?: string;
assignedTo?: string; assignedTo?: string;
reviewer?: string;
tags?: string; tags?: string;
// Handoff-specific routing fields // Handoff-specific routing fields
fromRole?: string; fromRole?: string;
@ -45,6 +46,7 @@ export class Index {
status TEXT, status TEXT,
role TEXT, role TEXT,
assignedTo TEXT, assignedTo TEXT,
reviewer TEXT,
tags TEXT, tags TEXT,
fromRole TEXT, fromRole TEXT,
toRole TEXT, toRole TEXT,
@ -60,7 +62,7 @@ export class Index {
const existingCols = new Set( const existingCols = new Set(
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name), (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)) { if (!existingCols.has(col)) {
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`); this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
} }
@ -72,6 +74,7 @@ export class Index {
status: null, status: null,
role: null, role: null,
assignedTo: null, assignedTo: null,
reviewer: null,
tags: null, tags: null,
fromRole: null, fromRole: null,
toRole: null, toRole: null,
@ -83,12 +86,12 @@ export class Index {
}; };
const insert = this.db.prepare(` 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) 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, @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 ON CONFLICT(id) DO UPDATE SET
type=@type, title=@title, content=@content, filePath=@filePath, type=@type, title=@title, content=@content, filePath=@filePath,
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, 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, fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent,
taskId=@taskId, relatedTasks=@relatedTasks taskId=@taskId, relatedTasks=@relatedTasks
`); `);
@ -100,6 +103,12 @@ export class Index {
search.run(params); 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 }> { search(query: string): Array<{ id: string; type: string; title: string }> {
const ftsQuery = toFts5Phrase(query); const ftsQuery = toFts5Phrase(query);
if (!ftsQuery) return []; if (!ftsQuery) return [];

View File

@ -15,6 +15,7 @@ export const TaskSchema = z.object({
priority: Priority.default('medium'), priority: Priority.default('medium'),
role: Role.optional(), role: Role.optional(),
assignedTo: z.string().optional(), assignedTo: z.string().optional(),
reviewer: z.string().optional(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
dueDate: z.string().datetime().optional(), dueDate: z.string().datetime().optional(),

View File

@ -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;
}

View File

@ -1,9 +1,11 @@
import { join } from 'path'; import { join } from 'path';
import { rmSync } from 'fs';
import { getEntityDir } from '../paths.js'; import { getEntityDir } from '../paths.js';
import { getNextId } from '../counter.js'; import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js'; import { readEntity, writeEntity } from '../files.js';
import { TaskSchema, type Task } from '../schema.js'; import { TaskSchema, type Task } from '../schema.js';
import { Index } from '../index.js'; import { Index } from '../index.js';
import { loadConfig } from '../config.js';
export function createTask(cwd: string, options: Partial<Task> = {}): Task { export function createTask(cwd: string, options: Partial<Task> = {}): Task {
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -15,6 +17,7 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
priority: options.priority ?? 'medium', priority: options.priority ?? 'medium',
role: options.role, role: options.role,
assignedTo: options.assignedTo, assignedTo: options.assignedTo,
reviewer: options.reviewer,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@ -72,8 +75,14 @@ export function doneTask(
}); });
} }
export function reviewTask(cwd: string, id: string): Task { export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
return updateTask(cwd, id, { status: 'review' }); const { task } = getTask(cwd, id);
const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd);
const patch: Partial<Task> = { status: 'review' };
if (resolvedReviewer && resolvedReviewer !== task.assignedTo) {
patch.reviewer = resolvedReviewer;
}
return updateTask(cwd, id, patch);
} }
export function cancelTask(cwd: string, id: string): Task { 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' }); 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 * 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- * 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 }); 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) { function toIndexEntry(task: Task, filePath: string) {
return { return {
id: task.id, id: task.id,
@ -106,6 +141,7 @@ function toIndexEntry(task: Task, filePath: string) {
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
reviewer: task.reviewer,
tags: JSON.stringify(task.tags), tags: JSON.stringify(task.tags),
}; };
} }

View File

@ -1,7 +1,7 @@
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { listMessages } from '../core/services/messageService.js'; import { listMessages } from '../core/services/messageService.js';
import { listTasks } from '../core/services/taskService.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'; import type { IndexEntry } from '../core/index.js';
function compactDuration(ms: number): string { function compactDuration(ms: number): string {
@ -84,14 +84,9 @@ export function renderActivityHtml(cwd: string): string {
<title>AgentHub Activity</title> <title>AgentHub Activity</title>
<style> <style>
${designTokensCss()} ${designTokensCss()}
body { padding: 0 20px 32px; } ${appHeaderCss()}
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); } body { padding: 96px 20px 32px; }
.brand { font-weight:700;font-size:17px; } .id,.meta,.state { color:var(--muted);font:12px/1.4 var(--font-mono); }
.project,.id,.meta,.state { color:var(--muted);font:12px/1.4 var(--font-mono); }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a.active,.nav a:hover { color:var(--text);background:var(--raised); }
main { max-width:1120px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:12px;align-items:start; } main { max-width:1120px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:12px;align-items:start; }
.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:0; } .panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:0; }
.panel-head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; } .panel-head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
@ -109,22 +104,12 @@ export function renderActivityHtml(cwd: string): string {
.task-row:hover,.activity-row:hover { color:var(--text); } .task-row:hover,.activity-row:hover { color:var(--text); }
.agent { display:flex;justify-content:flex-end; } .agent { display:flex;justify-content:flex-end; }
.empty { color:var(--muted);font-size:12px; } .empty { color:var(--muted);font-size:12px; }
@media (max-width:860px){ main{grid-template-columns:1fr}.app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center} } @media (max-width:860px){ main{grid-template-columns:1fr} }
@media (max-width:560px){ .activity-row,.task-row{grid-template-columns:1fr;gap:4px}.agent{justify-content:flex-start} } @media (max-width:560px){ .activity-row,.task-row{grid-template-columns:1fr;gap:4px}.agent{justify-content:flex-start} }
</style> </style>
</head> </head>
<body> <body>
<header class="app-header"> ${appHeader(config.projectName, 'activity')}
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a class="active" href="/activity">Activity</a>
<a href="/decisions">Decisions</a>
</nav>
</header>
<main> <main>
<section class="panel"> <section class="panel">
<div class="panel-head"><h1>Recent Activity</h1><span class="meta">${activityRows.length} events</span></div> <div class="panel-head"><h1>Recent Activity</h1><span class="meta">${activityRows.length} events</span></div>
@ -135,6 +120,8 @@ export function renderActivityHtml(cwd: string): string {
<div class="task-list">${archive}</div> <div class="task-list">${archive}</div>
</section> </section>
</main> </main>
${taskModalHtml()}
${appHeaderJs()}
</body> </body>
</html>`; </html>`;
} }

View File

@ -1,6 +1,6 @@
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { listTasks } from '../core/services/taskService.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'; import type { IndexEntry } from '../core/index.js';
function compactDuration(ms: number): string { function compactDuration(ms: number): string {
@ -48,14 +48,9 @@ export function renderArchiveHtml(cwd: string): string {
<title>AgentHub Done Archive</title> <title>AgentHub Done Archive</title>
<style> <style>
${designTokensCss()} ${designTokensCss()}
body { padding: 0 20px 32px; } ${appHeaderCss()}
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); } body { padding: 96px 20px 32px; }
.brand { font-weight:700;font-size:17px; } .id,.meta { color:var(--muted);font:12px/1.4 var(--font-mono); }
.project,.id,.meta { color:var(--muted);font:12px/1.4 var(--font-mono); }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a:hover { color:var(--text);background:var(--raised); }
main { max-width:1040px;margin:0 auto; } main { max-width:1040px;margin:0 auto; }
.head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; } .head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
h1 { font-size:18px;margin:0; } h1 { font-size:18px;margin:0; }
@ -65,21 +60,11 @@ export function renderArchiveHtml(cwd: string): string {
.title { min-width:0;overflow-wrap:anywhere;font-weight:600; } .title { min-width:0;overflow-wrap:anywhere;font-weight:600; }
.agent { display:flex;justify-content:flex-end; } .agent { display:flex;justify-content:flex-end; }
.empty { color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; } .empty { color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.task-row{grid-template-columns:1fr;gap:4px}.agent{justify-content:flex-start} } @media (max-width:640px){ .task-row{grid-template-columns:1fr;gap:4px}.agent{justify-content:flex-start} }
</style> </style>
</head> </head>
<body> <body>
<header class="app-header"> ${appHeader(config.projectName, 'archive')}
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a href="/activity">Activity</a>
<a href="/decisions">Decisions</a>
</nav>
</header>
<main> <main>
<div class="head"> <div class="head">
<h1>Done Archive</h1> <h1>Done Archive</h1>
@ -87,6 +72,8 @@ export function renderArchiveHtml(cwd: string): string {
</div> </div>
<div class="task-list">${rows}</div> <div class="task-list">${rows}</div>
</main> </main>
${taskModalHtml()}
${appHeaderJs()}
</body> </body>
</html>`; </html>`;
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { getDecision, listDecisions } from '../core/services/decisionService.js'; import { getDecision, listDecisions } from '../core/services/decisionService.js';
import { designTokensCss, escapeHtml } from './ui-shared.js'; import { designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { Decision } from '../core/schema.js'; import type { Decision } from '../core/schema.js';
function ago(iso: string): string { function ago(iso: string): string {
@ -65,14 +65,8 @@ export function renderDecisionsHtml(cwd: string): string {
<title>AgentHub Decisions</title> <title>AgentHub Decisions</title>
<style> <style>
${designTokensCss()} ${designTokensCss()}
body { padding: 0 20px 32px; } ${appHeaderCss()}
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); } body { padding: 96px 20px 32px; }
.brand { font-weight:700;font-size:17px; }
.project { color:var(--muted);font:12px/1.4 var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a.active,.nav a:hover { color:var(--text);background:var(--raised); }
main { max-width:980px;margin:0 auto;display:grid;gap:10px; } main { max-width:980px;margin:0 auto;display:grid;gap:10px; }
.decision { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; } .decision { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
.decision-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; } .decision-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; }
@ -83,25 +77,16 @@ export function renderDecisionsHtml(cwd: string): string {
p { margin:0;color:var(--text);overflow-wrap:anywhere; } p { margin:0;color:var(--text);overflow-wrap:anywhere; }
.meta { margin-top:10px;display:grid;gap:5px;color:var(--muted);font-size:12px; } .meta { margin-top:10px;display:grid;gap:5px;color:var(--muted);font-size:12px; }
.empty { color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; } .empty { color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.project{width:100%} }
</style> </style>
</head> </head>
<body> <body>
<header class="app-header"> ${appHeader(config.projectName, 'decisions')}
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a href="/activity">Activity</a>
<a class="active" href="/decisions">Decisions</a>
</nav>
</header>
<main> <main>
<h1>Decision Log</h1> <h1>Decision Log</h1>
${rows} ${rows}
</main> </main>
${taskModalHtml()}
${appHeaderJs()}
</body> </body>
</html>`; </html>`;
} }

View File

@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'agent'; export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'agent';
export type AgentHubEventAction = 'created' | 'updated' | 'joined' | 'left'; export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
export interface AgentHubEvent { export interface AgentHubEvent {
type: AgentHubEventType; type: AgentHubEventType;
@ -12,6 +12,7 @@ export interface AgentHubEvent {
status?: string; status?: string;
role?: string; role?: string;
assignedTo?: string; assignedTo?: string;
reviewer?: string;
} }
/** /**
@ -21,11 +22,27 @@ export interface AgentHubEvent {
* Singleton per Node.js process in server mode that is always exactly one * Singleton per Node.js process in server mode that is always exactly one
* process, which is the intended topology. * process, which is the intended topology.
*/ */
/** A live progress line an agent streams while working a task. */
export interface TaskLogPayload {
taskId: string;
ts: string;
agent?: string;
level?: string;
text: string;
}
class AgentHubEventBus extends EventEmitter { class AgentHubEventBus extends EventEmitter {
/** Publish a change event to all current SSE subscribers. */ /** Publish a change event to all current SSE subscribers. */
publish(event: AgentHubEvent): void { publish(event: AgentHubEvent): void {
this.emit('change', event); this.emit('change', event);
} }
/** Publish a task-log line delivered as a NAMED `task-log` SSE event so the
* board's generic onmessage handler ignores it and only the task-detail
* live console picks it up. */
publishLog(payload: TaskLogPayload): void {
this.emit('log', payload);
}
} }
export const eventBus = new AgentHubEventBus(); export const eventBus = new AgentHubEventBus();

View File

@ -60,7 +60,7 @@ function toEvent(
case 'task': case 'task':
return { return {
stamp, stamp,
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo) }, event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), reviewer: str(fm.reviewer) },
}; };
case 'handoff': case 'handoff':
return { return {

View File

@ -1,6 +1,7 @@
import { FastifyInstance, FastifyReply } from 'fastify'; import { FastifyInstance, FastifyReply } from 'fastify';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask } from '../core/services/taskService.js'; import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js'; import { getTaskActivity } from '../core/services/activityService.js';
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { createMessage, listMessages, listInbox, markMessageRead } from '../core/services/messageService.js'; import { createMessage, listMessages, listInbox, markMessageRead } from '../core/services/messageService.js';
@ -109,6 +110,13 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
eventBus.on('change', listener); eventBus.on('change', listener);
// Task-log lines ride a NAMED `task-log` SSE event so the board's generic
// onmessage handler ignores them; only the task-detail live console listens.
const logListener = (payload: unknown) => {
raw.write(`event: task-log\ndata: ${JSON.stringify(payload)}\n\n`);
};
eventBus.on('log', logListener);
const keepAliveTimer = setInterval(() => { const keepAliveTimer = setInterval(() => {
raw.write(': keepalive\n\n'); raw.write(': keepalive\n\n');
}, SSE_KEEPALIVE_MS); }, SSE_KEEPALIVE_MS);
@ -117,6 +125,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
request.raw.on('close', () => { request.raw.on('close', () => {
clearInterval(keepAliveTimer); clearInterval(keepAliveTimer);
eventBus.off('change', listener); eventBus.off('change', listener);
eventBus.off('log', logListener);
}); });
}); });
@ -181,12 +190,24 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
reviewer: task.reviewer,
}, },
task.updatedAt, task.updatedAt,
); );
return task; return task;
}); });
app.delete('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
const result = deleteTask(cwd, id);
emitChange({ type: 'task', action: 'deleted', id }, new Date().toISOString());
return result;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Delete failed');
}
});
app.get('/tasks/:id', async (request, reply) => { app.get('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
try { try {
@ -209,6 +230,27 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
} }
}); });
// ─── Live agent console ────────────────────────────────────────────────────
// GET returns the task's streamed progress log; POST appends one line and
// fans it out over SSE so an open task-detail console tails it live.
app.get('/tasks/:id/log', async (request) => {
const { id } = request.params as { id: string };
return { log: readTaskLog(cwd, id) };
});
app.post('/tasks/:id/log', async (request, reply) => {
const { id } = request.params as { id: string };
const { text, agent, level } = request.body as { text?: string; agent?: string; level?: string };
let entry;
try {
entry = appendTaskLog(cwd, id, { text: text ?? '', agent, level });
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line');
}
eventBus.publishLog({ taskId: id, ...entry });
return entry;
});
app.patch('/tasks/:id', async (request, reply) => { app.patch('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const patch = request.body as Partial<Task>; const patch = request.body as Partial<Task>;
@ -226,6 +268,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: assigned.status, status: assigned.status,
role: assigned.role, role: assigned.role,
assignedTo: assigned.assignedTo, assignedTo: assigned.assignedTo,
reviewer: assigned.reviewer,
}, },
assigned.updatedAt, assigned.updatedAt,
); );
@ -246,7 +289,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
}); });
break; break;
case 'review': case 'review':
task = reviewTask(cwd, id); task = reviewTask(cwd, id, patch.reviewer);
break; break;
case 'cancelled': case 'cancelled':
task = cancelTask(cwd, id); task = cancelTask(cwd, id);
@ -267,6 +310,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
reviewer: task.reviewer,
}, },
task.updatedAt, task.updatedAt,
); );

View File

@ -3,7 +3,7 @@ import { getTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js'; import { getTaskActivity } from '../core/services/activityService.js';
import { getDecision } from '../core/services/decisionService.js'; import { getDecision } from '../core/services/decisionService.js';
import { getHandoff, listHandoffs } from '../core/services/handoffService.js'; import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
import { agentAvatar, designTokensCss, escapeHtml, statusPill } from './ui-shared.js'; import { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { ActivityItem, Decision, Handoff } from '../core/schema.js'; import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
function ago(iso: string): string { function ago(iso: string): string {
@ -149,14 +149,9 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
<title>${escapeHtml(task.id)} · AgentHub</title> <title>${escapeHtml(task.id)} · AgentHub</title>
<style> <style>
${designTokensCss()} ${designTokensCss()}
body { padding: 0 20px 32px; } ${appHeaderCss()}
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); } body { padding: 96px 20px 32px; }
.brand { font-weight:700;font-size:17px; } .id,.when,.actor { color:var(--muted);font:12px/1.4 var(--font-mono); }
.project,.id,.when,.actor { color:var(--muted);font:12px/1.4 var(--font-mono); }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a:hover { color:var(--text);background:var(--raised); }
main { max-width:1040px;margin:0 auto;display:grid;gap:12px; } main { max-width:1040px;margin:0 auto;display:grid;gap:12px; }
.hero,.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; } .hero,.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
.topline,.item-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; } .topline,.item-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; }
@ -183,21 +178,11 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); } .kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
.summary { min-width:0;overflow-wrap:anywhere; } .summary { min-width:0;overflow-wrap:anywhere; }
.empty { color:var(--muted); } .empty { color:var(--muted); }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.activity-row{grid-template-columns:1fr}.actor{white-space:normal} } @media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} }
</style> </style>
</head> </head>
<body> <body>
<header class="app-header"> ${appHeader(config.projectName, 'task')}
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a href="/activity">Activity</a>
<a href="/decisions">Decisions</a>
</nav>
</header>
<main> <main>
<section class="hero"> <section class="hero">
<div class="topline"><span class="id">${escapeHtml(task.id)}</span>${statusPill(task.status)}${task.assignedTo ? agentAvatar(task.assignedTo, { size: 24 }) : ''}</div> <div class="topline"><span class="id">${escapeHtml(task.id)}</span>${statusPill(task.status)}${task.assignedTo ? agentAvatar(task.assignedTo, { size: 24 }) : ''}</div>
@ -218,6 +203,8 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
${activityRows} ${activityRows}
</section> </section>
</main> </main>
${taskModalHtml()}
${appHeaderJs()}
</body> </body>
</html>`; </html>`;
} }

View File

@ -15,10 +15,10 @@
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { listTasks } from '../core/services/taskService.js'; import { listTasks } from '../core/services/taskService.js';
import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js'; import { designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, newTaskModalJs, providerLogo, providerMeta } from './ui-shared.js';
import type { Config, OrgNode, Task } from '../core/schema.js'; import type { Config, OrgNode, Task } from '../core/schema.js';
type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'updatedAt'>; type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'reviewer' | 'updatedAt'>;
interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; } interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; }
/** Per-agent live state for the initial server render (in_progress → active). */ /** Per-agent live state for the initial server render (in_progress → active). */
@ -27,9 +27,12 @@ function computeStates(cwd: string): Map<string, AgentState> {
const active = new Map<string, TaskRow>(); const active = new Map<string, TaskRow>();
const review = new Map<string, TaskRow>(); const review = new Map<string, TaskRow>();
for (const t of tasks) { for (const t of tasks) {
if (!t.assignedTo) continue; if (t.status === 'in_progress' && t.assignedTo) { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); }
if (t.status === 'in_progress') { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); } else if (t.status === 'review') {
else if (t.status === 'review') { const e = review.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); } // Review state belongs to the REVIEWER (falls back to the assignee).
const who = t.reviewer || t.assignedTo;
if (who) { const e = review.get(who); if (!e || t.updatedAt > e.updatedAt) review.set(who, t); }
}
} }
const out = new Map<string, AgentState>(); const out = new Map<string, AgentState>();
for (const [name, task] of active) out.set(name, { state: 'active', task }); for (const [name, task] of active) out.set(name, { state: 'active', task });
@ -153,7 +156,8 @@ export function renderTeamHtml(cwd: string): string {
<title>AgentHub Team</title> <title>AgentHub Team</title>
<style> <style>
${designTokensCss()} ${designTokensCss()}
body { overflow-x: hidden; } ${appHeaderCss()}
body { overflow-x: hidden; padding-top: 96px; }
.scroll { overflow-x: auto; padding-bottom: 12px; } .scroll { overflow-x: auto; padding-bottom: 12px; }
.chart { position: relative; display: inline-flex; min-width: 100%; justify-content: center; padding: 12px 24px 40px; } .chart { position: relative; display: inline-flex; min-width: 100%; justify-content: center; padding: 12px 24px 40px; }
@ -255,7 +259,7 @@ export function renderTeamHtml(cwd: string): string {
</style> </style>
</head> </head>
<body> <body>
${pageHeader(config.projectName, 'team')} ${appHeader(config.projectName, 'team')}
<div class="team-toolbar"> <div class="team-toolbar">
<span class="flow-caption" id="flowCaption" hidden></span> <span class="flow-caption" id="flowCaption" hidden></span>
<span class="tb-spacer"></span> <span class="tb-spacer"></span>
@ -279,12 +283,13 @@ export function renderTeamHtml(cwd: string): string {
(function() { (function() {
var NS = 'http://www.w3.org/2000/svg'; var NS = 'http://www.w3.org/2000/svg';
var SEG = 720, EDGE_DUR = 1850; var SEG = 720, EDGE_DUR = 1850;
var connDot = document.getElementById('conn-dot'); var connBox = document.getElementById('sseStatus');
var connLabel = document.getElementById('sseLabel');
var edgeMap = {}; // nodeId -> { fwd, rev } path from its parent var edgeMap = {}; // nodeId -> { fwd, rev } path from its parent
var parentOf = {}; // nodeId -> parent nodeId var parentOf = {}; // nodeId -> parent nodeId
var taskState = {}, baseline = false; var taskState = {}, baseline = false;
function setConn(ok) { if (connDot) { connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)'; connDot.title = ok ? 'connected' : 'reconnecting'; } } function setConn(ok) { if (!connBox) return; connBox.classList.remove('stale', 'down'); if (!ok) connBox.classList.add('down'); if (connLabel) connLabel.textContent = ok ? 'connected' : 'reconnecting'; }
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function ago(iso) { var t = Date.parse(iso); if (isNaN(t)) return ''; var s = Math.max(0, Math.floor((Date.now()-t)/1000)); if (s<60) return s+'s'; var m=Math.floor(s/60); if (m<60) return m+'m'; var h=Math.floor(m/60); if (h<24) return h+'h'; return Math.floor(h/24)+'d'; } function ago(iso) { var t = Date.parse(iso); if (isNaN(t)) return ''; var s = Math.max(0, Math.floor((Date.now()-t)/1000)); if (s<60) return s+'s'; var m=Math.floor(s/60); if (m<60) return m+'m'; var h=Math.floor(m/60); if (h<24) return h+'h'; return Math.floor(h/24)+'d'; }
function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; } function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; }
@ -405,9 +410,8 @@ export function renderTeamHtml(cwd: string): string {
var active = {}, review = {}; var active = {}, review = {};
tasks.forEach(function(t) { tasks.forEach(function(t) {
if (!t.assignedTo) return; if (t.status === 'in_progress' && t.assignedTo) { if (!active[t.assignedTo] || t.updatedAt > active[t.assignedTo].updatedAt) active[t.assignedTo] = t; }
if (t.status === 'in_progress') { if (!active[t.assignedTo] || t.updatedAt > active[t.assignedTo].updatedAt) active[t.assignedTo] = t; } else if (t.status === 'review') { var who = t.reviewer || t.assignedTo; if (who && (!review[who] || t.updatedAt > review[who].updatedAt)) review[who] = t; }
else if (t.status === 'review') { if (!review[t.assignedTo] || t.updatedAt > review[t.assignedTo].updatedAt) review[t.assignedTo] = t; }
}); });
document.querySelectorAll('.node[data-agent]').forEach(function(card) { document.querySelectorAll('.node[data-agent]').forEach(function(card) {
var name = card.getAttribute('data-agent'); var name = card.getAttribute('data-agent');
@ -553,6 +557,8 @@ export function renderTeamHtml(cwd: string): string {
} }
})(); })();
</script> </script>
${taskModalHtml()}
${newTaskModalJs()}
</body> </body>
</html>`; </html>`;
} }

View File

@ -255,16 +255,19 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
return ` return `
<header style=" <header style="
position: sticky; position: fixed;
top: 0; top: 0;
z-index: 10; left: 0;
right: 0;
z-index: 40;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap; flex-wrap: wrap;
padding-bottom: 12px; padding: 14px 20px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: rgba(15, 23, 42, .96);
backdrop-filter: blur(10px);
"> ">
${mark} ${mark}
<h1 style="font-size:18px;margin:0;font-weight:600;">AgentHub</h1> <h1 style="font-size:18px;margin:0;font-weight:600;">AgentHub</h1>
@ -279,3 +282,219 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
<span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span> <span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span>
</header>`; </header>`;
} }
// ─────────────────────────────────────────────────────────────────────────────
// 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 = `<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="M5 7.5h8.5a5.5 5.5 0 0 1 0 11H5v-11Z" stroke="#58A6FF" stroke-width="1.8"/><path d="M8.5 5.5h7a4 4 0 0 1 0 8h-7v-8Z" stroke="#22C55E" stroke-width="1.8"/></svg></span>`;
const link = (label: string, path: string, key: HeaderPage) =>
`<a class="nav-link${current === key ? ' active' : ''}" href="${path}">${label}</a>`;
return `
<header class="app-header">
<div class="brand">
${mark}
<div class="brand-copy">
<span class="brand-title">AgentHub</span>
<span class="project-name" id="projectName">${escapeHtml(projectName)}</span>
</div>
</div>
<span class="header-spacer"></span>
<nav class="nav" aria-label="Primary">
${link('Board', '/board', 'board')}
${link('Team', '/team', 'team')}
${link('Activity', '/activity', 'activity')}
${link('Decisions', '/decisions', 'decisions')}
</nav>
<button class="new-task-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">
<span class="plus" aria-hidden="true">+</span> New task
</button>
<span class="sse-status stale" id="sseStatus">
<span class="conn-dot" aria-hidden="true"></span>
<span id="sseLabel">connecting</span>
</span>
</header>`;
}
/** The New-task modal + a toasts container. Place near the end of <body>. */
export function taskModalHtml(): string {
return `
<div class="modal-backdrop" id="taskModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="tmHeading">
<div class="modal-head">
<h2 id="tmHeading">New task</h2>
<button class="modal-x" id="tmClose" type="button" aria-label="Close">&times;</button>
</div>
<form id="tmForm" autocomplete="off">
<label class="modal-field">
<span class="modal-label">Title</span>
<input id="tmTitleInput" type="text" placeholder="What needs doing?" required maxlength="140" />
</label>
<label class="modal-field">
<span class="modal-label">Description</span>
<textarea id="tmDesc" rows="4" placeholder="Context, acceptance criteria, links… (optional)"></textarea>
</label>
<label class="modal-field modal-field-inline">
<span class="modal-label">Priority</span>
<select id="tmPriority">
<option value="low">low</option>
<option value="medium" selected>medium</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<p class="modal-note">Created unassigned the architect picks it up and delegates it.</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
<button type="submit" class="modal-create">Create task</button>
</div>
</form>
</div>
</div>
<div class="toasts" id="toasts" aria-live="polite"></div>`;
}
/**
* 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 `
<script>
(function(){
var box=document.getElementById('sseStatus'), label=document.getElementById('sseLabel');
if(!box) return;
function setConn(state,text){ box.classList.remove('stale','down'); if(state==='stale')box.classList.add('stale'); if(state==='down')box.classList.add('down'); if(label)label.textContent=text; }
if(!('EventSource' in window)){ setConn('stale','polling'); return; }
try { var s=new EventSource('/events'); s.onopen=function(){ setConn('ok','connected'); }; s.onerror=function(){ setConn('down','reconnecting'); }; }
catch(_){ setConn('down','offline'); }
})();
</script>`;
}
/**
* 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 `
<script>
(function(){
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
window.ahToast = function(msg, o){ o=o||{}; var wrap=document.getElementById('toasts'); if(!wrap) return; var el=document.createElement('div'); el.className='toast'+(o.error?' err':''); el.innerHTML='<span class="dot"></span><span>'+esc(msg)+'</span>'; wrap.appendChild(el); setTimeout(function(){ el.classList.add('out'); setTimeout(function(){ if(el.parentNode) el.parentNode.removeChild(el); }, 240); }, o.ms||3200); };
var btn=document.getElementById('newTaskBtn'), modal=document.getElementById('taskModal'), form=document.getElementById('tmForm');
if(!btn||!modal||!form) return;
var titleInput=document.getElementById('tmTitleInput');
function open(){ modal.hidden=false; btn.setAttribute('aria-expanded','true'); if(titleInput) setTimeout(function(){ titleInput.focus(); }, 20); }
function close(){ modal.hidden=true; btn.setAttribute('aria-expanded','false'); form.reset(); }
btn.addEventListener('click', open);
var x=document.getElementById('tmClose'); if(x) x.addEventListener('click', close);
var c=document.getElementById('tmCancel'); if(c) c.addEventListener('click', close);
modal.addEventListener('click', function(e){ if(e.target===modal) close(); });
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !modal.hidden) close(); });
form.addEventListener('submit', async function(e){
e.preventDefault();
var title=(titleInput.value||'').trim(); if(!title) return;
var description=(document.getElementById('tmDesc').value||'').trim();
var priority=document.getElementById('tmPriority').value||undefined;
try {
var res=await fetch('/tasks',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ title:title, description:description, priority:priority }) });
if(!res.ok) throw new Error('create failed ('+res.status+')');
var task=await res.json();
window.ahToast(task.id+' created \\u00b7 opening board…');
close();
setTimeout(function(){ window.location.href='/board'; }, 400);
} catch(err){ window.ahToast(err.message||'create failed',{error:true}); }
});
})();
</script>`;
}
/** Convenience bundle for static pages with no own SSE: dot + new-task modal. */
export function appHeaderJs(): string {
return connectionStatusJs() + newTaskModalJs();
}

View File

@ -60,6 +60,19 @@ describe('server routes', () => {
expect(JSON.parse(res.payload).status).toBe('review'); 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 () => { it('PATCH /tasks/:id → cancelled', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); 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' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } });

View File

@ -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();
});
});

View File

@ -4,8 +4,10 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { import {
createTask, listTasks, getTask, createTask, listTasks, getTask,
claimTask, doneTask, reviewTask, cancelTask, reopenTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask,
} from '../src/core/services/taskService.js'; } from '../src/core/services/taskService.js';
import { init } from '../src/cli/commands/init.js';
import { Index } from '../src/core/index.js';
describe('taskService', () => { describe('taskService', () => {
let cwd: string; let cwd: string;
@ -49,6 +51,25 @@ describe('taskService', () => {
expect(listed[0].id).toBe(task.id); 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', () => { it('cancels a task', () => {
const task = createTask(cwd, { title: 'D', role: 'implementer' }); const task = createTask(cwd, { title: 'D', role: 'implementer' });
const cancelled = cancelTask(cwd, task.id); const cancelled = cancelTask(cwd, task.id);
@ -72,4 +93,31 @@ describe('taskService', () => {
expect(read.title).toBe('F'); expect(read.title).toBe('F');
expect(read.role).toBe('architect'); 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();
});
}); });