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:
parent
1bb18d96b5
commit
0884032db1
@ -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 [];
|
||||
|
||||
@ -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(),
|
||||
|
||||
54
src/core/services/taskLogService.ts
Normal file
54
src/core/services/taskLogService.ts
Normal 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;
|
||||
}
|
||||
@ -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> = {}): Task {
|
||||
const now = new Date().toISOString();
|
||||
@ -15,6 +17,7 @@ export function createTask(cwd: string, options: Partial<Task> = {}): 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<Task> = { 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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
<title>AgentHub Activity</title>
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
body { padding: 0 20px 32px; }
|
||||
.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); }
|
||||
.brand { font-weight:700;font-size:17px; }
|
||||
.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); }
|
||||
${appHeaderCss()}
|
||||
body { padding: 96px 20px 32px; }
|
||||
.id,.meta,.state { color:var(--muted);font:12px/1.4 var(--font-mono); }
|
||||
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-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); }
|
||||
.agent { display:flex;justify-content:flex-end; }
|
||||
.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} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<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>
|
||||
${appHeader(config.projectName, 'activity')}
|
||||
<main>
|
||||
<section class="panel">
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
${taskModalHtml()}
|
||||
${appHeaderJs()}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
<title>AgentHub Done Archive</title>
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
body { padding: 0 20px 32px; }
|
||||
.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); }
|
||||
.brand { font-weight:700;font-size:17px; }
|
||||
.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); }
|
||||
${appHeaderCss()}
|
||||
body { padding: 96px 20px 32px; }
|
||||
.id,.meta { color:var(--muted);font:12px/1.4 var(--font-mono); }
|
||||
main { max-width:1040px;margin:0 auto; }
|
||||
.head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
|
||||
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; }
|
||||
.agent { display:flex;justify-content:flex-end; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<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>
|
||||
${appHeader(config.projectName, 'archive')}
|
||||
<main>
|
||||
<div class="head">
|
||||
<h1>Done Archive</h1>
|
||||
@ -87,6 +72,8 @@ export function renderArchiveHtml(cwd: string): string {
|
||||
</div>
|
||||
<div class="task-list">${rows}</div>
|
||||
</main>
|
||||
${taskModalHtml()}
|
||||
${appHeaderJs()}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
import { loadConfig } from '../core/config.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';
|
||||
|
||||
function ago(iso: string): string {
|
||||
@ -65,14 +65,8 @@ export function renderDecisionsHtml(cwd: string): string {
|
||||
<title>AgentHub Decisions</title>
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
body { padding: 0 20px 32px; }
|
||||
.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); }
|
||||
.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); }
|
||||
${appHeaderCss()}
|
||||
body { padding: 96px 20px 32px; }
|
||||
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-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; }
|
||||
.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; }
|
||||
@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>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<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>
|
||||
${appHeader(config.projectName, 'decisions')}
|
||||
<main>
|
||||
<h1>Decision Log</h1>
|
||||
${rows}
|
||||
</main>
|
||||
${taskModalHtml()}
|
||||
${appHeaderJs()}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
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 {
|
||||
type: AgentHubEventType;
|
||||
@ -12,6 +12,7 @@ export interface AgentHubEvent {
|
||||
status?: string;
|
||||
role?: 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
|
||||
* 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 {
|
||||
/** Publish a change event to all current SSE subscribers. */
|
||||
publish(event: AgentHubEvent): void {
|
||||
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();
|
||||
|
||||
@ -60,7 +60,7 @@ function toEvent(
|
||||
case 'task':
|
||||
return {
|
||||
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':
|
||||
return {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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 { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
|
||||
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
||||
import { createDecision, listDecisions } from '../core/services/decisionService.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);
|
||||
|
||||
// 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(() => {
|
||||
raw.write(': keepalive\n\n');
|
||||
}, SSE_KEEPALIVE_MS);
|
||||
@ -117,6 +125,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(keepAliveTimer);
|
||||
eventBus.off('change', listener);
|
||||
eventBus.off('log', logListener);
|
||||
});
|
||||
});
|
||||
|
||||
@ -181,12 +190,24 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
reviewer: task.reviewer,
|
||||
},
|
||||
task.updatedAt,
|
||||
);
|
||||
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) => {
|
||||
const { id } = request.params as { id: string };
|
||||
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) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const patch = request.body as Partial<Task>;
|
||||
@ -226,6 +268,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
status: assigned.status,
|
||||
role: assigned.role,
|
||||
assignedTo: assigned.assignedTo,
|
||||
reviewer: assigned.reviewer,
|
||||
},
|
||||
assigned.updatedAt,
|
||||
);
|
||||
@ -246,7 +289,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
});
|
||||
break;
|
||||
case 'review':
|
||||
task = reviewTask(cwd, id);
|
||||
task = reviewTask(cwd, id, patch.reviewer);
|
||||
break;
|
||||
case 'cancelled':
|
||||
task = cancelTask(cwd, id);
|
||||
@ -267,6 +310,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
reviewer: task.reviewer,
|
||||
},
|
||||
task.updatedAt,
|
||||
);
|
||||
|
||||
@ -3,7 +3,7 @@ import { getTask } from '../core/services/taskService.js';
|
||||
import { getTaskActivity } from '../core/services/activityService.js';
|
||||
import { getDecision } from '../core/services/decisionService.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';
|
||||
|
||||
function ago(iso: string): string {
|
||||
@ -149,14 +149,9 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
<title>${escapeHtml(task.id)} · AgentHub</title>
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
body { padding: 0 20px 32px; }
|
||||
.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); }
|
||||
.brand { font-weight:700;font-size:17px; }
|
||||
.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); }
|
||||
${appHeaderCss()}
|
||||
body { padding: 96px 20px 32px; }
|
||||
.id,.when,.actor { color:var(--muted);font:12px/1.4 var(--font-mono); }
|
||||
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; }
|
||||
.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); }
|
||||
.summary { min-width:0;overflow-wrap:anywhere; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<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>
|
||||
${appHeader(config.projectName, 'task')}
|
||||
<main>
|
||||
<section class="hero">
|
||||
<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}
|
||||
</section>
|
||||
</main>
|
||||
${taskModalHtml()}
|
||||
${appHeaderJs()}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@ -15,10 +15,10 @@
|
||||
|
||||
import { loadConfig } from '../core/config.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';
|
||||
|
||||
type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'updatedAt'>;
|
||||
type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'reviewer' | 'updatedAt'>;
|
||||
interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; }
|
||||
|
||||
/** 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 review = new Map<string, TaskRow>();
|
||||
for (const t of tasks) {
|
||||
if (!t.assignedTo) continue;
|
||||
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') { const e = review.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); }
|
||||
if (t.status === 'in_progress' && t.assignedTo) { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); }
|
||||
else if (t.status === 'review') {
|
||||
// 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>();
|
||||
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>
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
body { overflow-x: hidden; }
|
||||
${appHeaderCss()}
|
||||
body { overflow-x: hidden; padding-top: 96px; }
|
||||
|
||||
.scroll { overflow-x: auto; padding-bottom: 12px; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
${pageHeader(config.projectName, 'team')}
|
||||
${appHeader(config.projectName, 'team')}
|
||||
<div class="team-toolbar">
|
||||
<span class="flow-caption" id="flowCaption" hidden></span>
|
||||
<span class="tb-spacer"></span>
|
||||
@ -279,12 +283,13 @@ export function renderTeamHtml(cwd: string): string {
|
||||
(function() {
|
||||
var NS = 'http://www.w3.org/2000/svg';
|
||||
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 parentOf = {}; // nodeId -> parent nodeId
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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 }; }
|
||||
@ -405,9 +410,8 @@ export function renderTeamHtml(cwd: string): string {
|
||||
|
||||
var active = {}, review = {};
|
||||
tasks.forEach(function(t) {
|
||||
if (!t.assignedTo) return;
|
||||
if (t.status === 'in_progress') { if (!active[t.assignedTo] || t.updatedAt > active[t.assignedTo].updatedAt) active[t.assignedTo] = t; }
|
||||
else if (t.status === 'review') { if (!review[t.assignedTo] || t.updatedAt > review[t.assignedTo].updatedAt) review[t.assignedTo] = t; }
|
||||
if (t.status === 'in_progress' && t.assignedTo) { 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; }
|
||||
});
|
||||
document.querySelectorAll('.node[data-agent]').forEach(function(card) {
|
||||
var name = card.getAttribute('data-agent');
|
||||
@ -553,6 +557,8 @@ export function renderTeamHtml(cwd: string): string {
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
${taskModalHtml()}
|
||||
${newTaskModalJs()}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@ -255,16 +255,19 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
|
||||
|
||||
return `
|
||||
<header style="
|
||||
position: sticky;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, .96);
|
||||
backdrop-filter: blur(10px);
|
||||
">
|
||||
${mark}
|
||||
<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>
|
||||
</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">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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' } });
|
||||
|
||||
42
tests/taskLogService.test.ts
Normal file
42
tests/taskLogService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user