From 3f1fb76a84f43a772ed73acd1406d2d4f1126f06 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 12 Jul 2026 00:56:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(agenthub):=20TSK-0120=20=E2=80=94=20split?= =?UTF-8?q?=20messaging=20from=20/activity=20into=20a=20/messages=20conver?= =?UTF-8?q?sation=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MessageSchema: status enum unread|delivered|read|acked + replyTo (additive, RW-compatible) - messageService: listInbox transitions unread->delivered on fetch (agent-scoped); markMessageDelivered (idempotent, never downgrades); ackMessage(cwd,id,by?) - routes: GET /messages HTML branch -> renderMessagesHtml; GET /messages/:id; POST /messages/:id/ack (mirror of /read, emits message/updated status=acked) - server/messages.ts: 2-column conversation view (list grouped by pair + unread badge, thread bubbles aligned by ?as=, replyTo indentation, TSK pill, live via /events) - activity.ts: drop message rows (tasks-only hard separation) - ui-shared: HeaderPage +messages + nav link - CLI: message ack [--by], message reply --from --text [--task]; remoteClient ackMessage + getMessage - tests: +message-receipts.test.ts, server.test.ts activity/messages/receipt-chain Co-Authored-By: Claude Opus 4.8 --- src/cli/commands/message.ts | 30 +++- src/cli/index.ts | 41 +++++- src/cli/remoteClient.ts | 8 + src/core/schema.ts | 12 +- src/core/services/messageService.ts | 74 +++++++++- src/server/activity.ts | 13 +- src/server/messages.ts | 221 ++++++++++++++++++++++++++++ src/server/routes.ts | 38 ++++- src/server/ui-shared.ts | 3 +- tests/message-receipts.test.ts | 72 +++++++++ tests/server.test.ts | 39 ++++- 11 files changed, 524 insertions(+), 27 deletions(-) create mode 100644 src/server/messages.ts create mode 100644 tests/message-receipts.test.ts diff --git a/src/cli/commands/message.ts b/src/cli/commands/message.ts index f7bfe82..1465da1 100644 --- a/src/cli/commands/message.ts +++ b/src/cli/commands/message.ts @@ -1,4 +1,5 @@ -import { createMessage, listInbox, markMessageRead } from '../../core/services/messageService.js'; +import { createMessage, listInbox, markMessageRead, ackMessage, getMessage } from '../../core/services/messageService.js'; +import type { Message } from '../../core/schema.js'; export function messageSend( cwd: string, @@ -30,3 +31,30 @@ export function inboxMarkRead(cwd: string, opts: { agent: string; unreadOnly?: b for (const m of msgs) markMessageRead(cwd, m.id); console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${opts.agent}`); } + +export function messageAck(cwd: string, id: string, by?: string): void { + const m = ackMessage(cwd, id, by); + console.log(`AgentHub: Message acked ${m.id} (${m.from} → ${m.to})${by ? ` by ${by}` : ''}`); +} + +/** + * Reply to a message: loads the parent, sends a new message back to the parent's + * sender (to = parent.from), links it via replyTo, and inherits the parent's + * taskId unless one is given. + */ +export function messageReply( + cwd: string, + parentId: string, + opts: { from: string; text: string; taskId?: string }, +): Message { + const { message: parent } = getMessage(cwd, parentId); + const m = createMessage(cwd, { + from: opts.from, + to: parent.from, + text: opts.text, + taskId: opts.taskId ?? parent.taskId, + replyTo: parentId, + }); + console.log(`AgentHub: Reply sent ${m.id} (${m.from} → ${m.to}) ↩ ${parentId}`); + return m; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index f5d9f30..5358e2b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,7 +5,7 @@ import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './co import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { decisionCreate, decisionList } from './commands/decision.js'; -import { messageSend, inboxList, messageRead, inboxMarkRead } from './commands/message.js'; +import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js'; import { agentSetup, hookContext } from './commands/agentSetup.js'; import { syncOrgFromFile } from '../core/services/orgService.js'; import { delegate } from './commands/delegate.js'; @@ -493,6 +493,45 @@ export function createProgram(cwd: string): Command { messageRead(projectCwd, id); } }); + messageCmd + .command('ack ') + .description('Acknowledge a message (strongest read-receipt: you actioned it)') + .option('--by ', 'Agent acknowledging the message') + .action(async (id: string, options: { by?: string }) => { + const { serverUrl, projectCwd } = await resolveContext(program, cwd); + if (serverUrl) { + await runRemote(serverUrl, async () => { + const m = await remoteClient.ackMessage(serverUrl, id, options.by); + console.log(`AgentHub: Message acked ${m.id} (${m.from} → ${m.to})${options.by ? ` by ${options.by}` : ''}`); + }); + } else { + messageAck(projectCwd, id, options.by); + } + }); + messageCmd + .command('reply ') + .description('Reply to a message: sends back to its sender, links via replyTo, inherits its task') + .requiredOption('--from ', 'Sender agent name') + .requiredOption('--text ', 'Reply text') + .option('--task ', 'Related task ID (defaults to the parent message\'s task)') + .action(async (parentId: string, options: { from: string; text: string; task?: string }) => { + const { serverUrl, projectCwd } = await resolveContext(program, cwd); + if (serverUrl) { + await runRemote(serverUrl, async () => { + const { message: parent } = await remoteClient.getMessage(serverUrl, parentId); + const m = await remoteClient.sendMessage(serverUrl, { + from: options.from, + to: parent.from, + text: options.text, + taskId: options.task ?? parent.taskId, + replyTo: parentId, + }); + console.log(`AgentHub: Reply sent ${m.id} (${m.from} → ${m.to}) ↩ ${parentId}`); + }); + } else { + messageReply(projectCwd, parentId, { from: options.from, text: options.text, taskId: options.task }); + } + }); messageCmd .command('send ') .description('Send a direct message to another agent') diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index 2bf6ca2..0ed1c5a 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -137,6 +137,14 @@ export const remoteClient = { return request(baseUrl, 'POST', `/messages/${id}/read`); }, + async ackMessage(baseUrl: string, id: string, by?: string): Promise { + return request(baseUrl, 'POST', `/messages/${id}/ack`, by ? { by } : undefined); + }, + + async getMessage(baseUrl: string, id: string): Promise<{ message: Message; body: string }> { + return request<{ message: Message; body: string }>(baseUrl, 'GET', `/messages/${id}`); + }, + async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> { return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`); }, diff --git a/src/core/schema.ts b/src/core/schema.ts index e74cc1d..5e75e67 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -84,7 +84,17 @@ export const MessageSchema = z.object({ to: z.string().min(1), text: z.string().min(1), taskId: z.string().optional(), - status: z.enum(['unread', 'read']).default('unread'), + /** + * Read-receipt lifecycle: + * unread — created, never fetched by the recipient + * delivered — surfaced to the recipient's inbox (fetched), not yet opened + * read — recipient marked it read + * acked — recipient explicitly acknowledged/actioned it + * Additive + backward-compatible: old records ('unread'|'read') stay valid. + */ + status: z.enum(['unread', 'delivered', 'read', 'acked']).default('unread'), + /** For threaded replies: the id of the parent message this answers. */ + replyTo: z.string().optional(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); diff --git a/src/core/services/messageService.ts b/src/core/services/messageService.ts index 6fe23bf..49bf5df 100644 --- a/src/core/services/messageService.ts +++ b/src/core/services/messageService.ts @@ -34,6 +34,7 @@ export function createMessage(cwd: string, options: Partial = {}): Mess to: options.to, text: options.text, taskId: options.taskId, + replyTo: options.replyTo, status: 'unread', createdAt: now, updatedAt: now, @@ -70,24 +71,46 @@ export interface InboxMessage { createdAt: string; } -/** Messages addressed to `agent`, newest first. */ +/** + * Messages addressed to `agent`, newest first. + * + * Read-receipt side effect: any still-`unread` message that passes the filter is + * transitioned to `delivered` (a message the recipient has now been shown) AFTER + * filtering and BEFORE returning, so the returned rows reflect the new status. + * This is agent-scoped only — the architect-wide `listMessages` never mutates. + * (The `agenthub work` drainInbox path filters `unreadOnly` BEFORE this mutation + * and then marks read, so the delivered intermediate is invisible there — no + * regress and no spin.) + */ export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] { const index = new Index(cwd); const all = index.list('message'); index.close(); const recipients = messageRecipientAliases(agent); - return all + const filtered = all .filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase())) - .filter((m) => !opts.unreadOnly || m.status === 'unread') - .map((m) => ({ + .filter((m) => !opts.unreadOnly || m.status === 'unread'); + + return filtered.map((m) => { + let status = m.status ?? 'unread'; + if (status === 'unread') { + try { + markMessageDelivered(cwd, m.id); + status = 'delivered'; + } catch { + /* best-effort: leave as unread if the file can't be updated */ + } + } + return { id: m.id, from: m.fromAgent ?? '', to: m.toAgent ?? '', text: m.content, taskId: m.taskId, - status: m.status ?? 'unread', + status, createdAt: m.createdAt, - })); + }; + }); } /** All messages (architect-visible view), newest first. */ @@ -113,6 +136,26 @@ export function getMessage(cwd: string, id: string): { message: Message; body: s return { message: MessageSchema.parse(frontmatter), body, filePath }; } +/** + * Transition a message from `unread` to `delivered` (the recipient has been + * shown it). Idempotent: a message that is already delivered/read/acked is left + * untouched — this never downgrades a stronger receipt. + */ +export function markMessageDelivered(cwd: string, id: string): Message { + const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`); + const { frontmatter, body } = readEntity(filePath); + const message = MessageSchema.parse(frontmatter); + if (message.status !== 'unread') return message; + const updated: Message = { ...message, status: 'delivered', updatedAt: new Date().toISOString() }; + writeEntity(filePath, updated, body); + + const index = new Index(cwd); + index.upsert(indexEntryFor(updated, filePath)); + index.close(); + + return updated; +} + /** Mark a message as read. */ export function markMessageRead(cwd: string, id: string): Message { const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`); @@ -127,3 +170,22 @@ export function markMessageRead(cwd: string, id: string): Message { return updated; } + +/** + * Acknowledge a message — the recipient has explicitly actioned it (the strongest + * receipt). Optionally records who acked in the body trailer. + */ +export function ackMessage(cwd: string, id: string, by?: string): Message { + const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`); + const { frontmatter, body } = readEntity(filePath); + const message = MessageSchema.parse(frontmatter); + const updated: Message = { ...message, status: 'acked', updatedAt: new Date().toISOString() }; + const newBody = by ? `${body}\n\n_acked by ${by}_` : body; + writeEntity(filePath, updated, newBody); + + const index = new Index(cwd); + index.upsert(indexEntryFor(updated, filePath)); + index.close(); + + return updated; +} diff --git a/src/server/activity.ts b/src/server/activity.ts index 6dc07a2..bf6dd15 100644 --- a/src/server/activity.ts +++ b/src/server/activity.ts @@ -1,5 +1,4 @@ import { loadConfig } from '../core/config.js'; -import { listMessages } from '../core/services/messageService.js'; import { listTasks } from '../core/services/taskService.js'; import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js'; import type { IndexEntry } from '../core/index.js'; @@ -36,18 +35,10 @@ function snippet(text: string, max = 120): string { export function renderActivityHtml(cwd: string): string { const config = loadConfig(cwd); const tasks = listTasks(cwd); - const messages = listMessages(cwd); const done = tasks.filter((t) => t.status === 'done').sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + // Messaging lives on its own /messages page now — /activity is tasks-only. const activityRows = [ - ...messages.map((m) => ({ - at: m.createdAt, - html: `
- message -
${escapeHtml(m.id)} ${escapeHtml(m.from || '?')} to ${escapeHtml(m.to || '?')}: ${escapeHtml(snippet(m.text))}
- ${m.status === 'read' ? `read by ${escapeHtml(m.to || '?')}` : 'unread'} -
`, - })), ...tasks.map((t) => ({ at: t.updatedAt || t.createdAt, html: ` @@ -95,10 +86,8 @@ export function renderActivityHtml(cwd: string): string { .activity-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:start;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; } .activity-row:first-child { border-top:0;padding-top:0; } .kind { font:10px/1.4 var(--font-mono);border-radius:999px;padding:1px 6px;border:1px solid var(--border);white-space:nowrap; } - .kind-message { color:var(--accent);border-color:rgba(88,166,255,.32);background:rgba(88,166,255,.08); } .kind-task { color:var(--status-review);border-color:rgba(210,153,34,.32);background:rgba(210,153,34,.08); } .activity-main,.title { min-width:0;overflow-wrap:anywhere; } - .unread-dot { display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-right:4px; } .task-row { display:grid;grid-template-columns:82px minmax(0,1fr) 74px auto;gap:10px;align-items:center;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; } .task-row:first-child { border-top:0;padding-top:0; } .task-row:hover,.activity-row:hover { color:var(--text); } diff --git a/src/server/messages.ts b/src/server/messages.ts new file mode 100644 index 0000000..231e429 --- /dev/null +++ b/src/server/messages.ts @@ -0,0 +1,221 @@ +import { loadConfig } from '../core/config.js'; +import { listMessages, getMessage } from '../core/services/messageService.js'; +import { getRoster } from '../core/services/rosterService.js'; +import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js'; +import type { Message } from '../core/schema.js'; + +function ago(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return ''; + const s = Math.max(0, Math.floor((Date.now() - t) / 1000)); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +function snippet(text: string, max = 88): string { + const clean = (text ?? '').replace(/\s+/g, ' ').trim(); + if (clean.length <= max) return clean; + return `${clean.slice(0, max - 1)}...`; +} + +/** Stable, order-independent key for the conversation between two agents. */ +function convoKey(a: string, b: string): string { + return [a, b].map((s) => s.toLowerCase()).sort().join('__'); +} + +interface Convo { + key: string; + a: string; + b: string; + messages: Message[]; // oldest → newest + lastAt: string; + unread: number; // messages addressed to the viewer not yet read/acked +} + +/** + * The /messages conversation view: messaging split cleanly out of /activity. + * Two columns — left the conversation list grouped by {from,to} pair, right the + * selected thread rendered as bubbles aligned by the viewer (`?as=`, + * default `architect`). Live via its own EventSource('/events'), reloading on + * message events. Reply indentation is driven by `replyTo`. + */ +export function renderMessagesHtml(cwd: string, asAgent?: string, selectedKey?: string): string { + const config = loadConfig(cwd); + const viewer = (asAgent && asAgent.trim()) || 'architect'; + + // Load full messages (frontmatter has replyTo + status); fall back to the + // lightweight index entry when a file can't be read. + const entries = listMessages(cwd); + const messages: Message[] = entries.map((e) => { + try { + return getMessage(cwd, e.id).message; + } catch { + return { + id: e.id, + from: e.from, + to: e.to, + text: e.text, + taskId: e.taskId, + status: (e.status as Message['status']) ?? 'unread', + createdAt: e.createdAt, + updatedAt: e.createdAt, + } as Message; + } + }); + + // Group into conversations. + const convos = new Map(); + for (const m of messages) { + const key = convoKey(m.from, m.to); + let c = convos.get(key); + if (!c) { + c = { key, a: m.from, b: m.to, messages: [], lastAt: m.createdAt, unread: 0 }; + convos.set(key, c); + } + c.messages.push(m); + if (m.createdAt > c.lastAt) c.lastAt = m.createdAt; + const toViewer = m.to.toLowerCase() === viewer.toLowerCase(); + if (toViewer && (m.status === 'unread' || m.status === 'delivered')) c.unread += 1; + } + const convoList = [...convos.values()].sort((x, y) => y.lastAt.localeCompare(x.lastAt)); + for (const c of convoList) c.messages.sort((x, y) => x.createdAt.localeCompare(y.createdAt)); + + const selected = convoList.find((c) => c.key === selectedKey) ?? convoList[0]; + + const convoRows = convoList.length + ? convoList + .map((c) => { + const last = c.messages[c.messages.length - 1]; + const active = selected && c.key === selected.key; + return ` + ${agentAvatar(c.a, { size: 26 })}${agentAvatar(c.b, { size: 26 })} + + ${escapeHtml(c.a)} ↔ ${escapeHtml(c.b)} + ${escapeHtml(snippet(last?.text ?? ''))} + + + ${c.unread ? `${c.unread}` : ''} + ${escapeHtml(ago(c.lastAt))} + +`; + }) + .join('') + : '
No conversations yet.
'; + + const statusLabel = (s: string) => (s === 'acked' ? 'acked' : s === 'read' ? 'read' : s === 'delivered' ? 'delivered' : 'sent'); + + const thread = selected + ? selected.messages + .map((m) => { + const mine = m.from.toLowerCase() === viewer.toLowerCase(); + const isReply = !!m.replyTo; + return `
+
+
${agentAvatar(m.from, { size: 20 })}${escapeHtml(m.from)}${escapeHtml(m.id)}${m.taskId ? `${escapeHtml(m.taskId)}` : ''}
+
${escapeHtml(m.text)}
+
${escapeHtml(ago(m.createdAt))}${escapeHtml(statusLabel(m.status))}
+
+
`; + }) + .join('') + : '
Pick a conversation.
'; + + const roster = getRoster(cwd).map((r) => r.name); + const switchNames = ['architect', ...roster.filter((n) => n.toLowerCase() !== 'architect')]; + const switcher = switchNames + .map( + (n) => + `${escapeHtml(n)}`, + ) + .join(''); + + const headerTitle = selected ? `${escapeHtml(selected.a)} ↔ ${escapeHtml(selected.b)}` : 'Messages'; + + return ` + + + + + + AgentHub Messages + + + + ${appHeader(config.projectName, 'messages')} +
+
+

Conversations

${convoList.length}
+
${convoRows}
+
+
+
View as${switcher}
+

${headerTitle}

+
${thread}
+
+
+ ${taskModalHtml()} + ${appHeaderJs()} + + +`; +} diff --git a/src/server/routes.ts b/src/server/routes.ts index bb64dad..ca945de 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -4,7 +4,7 @@ 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'; +import { createMessage, listMessages, listInbox, markMessageRead, ackMessage, getMessage } from '../core/services/messageService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; import { getStatus, updateStatus } from '../core/services/statusService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; @@ -16,6 +16,7 @@ import { renderBoardHtml } from './board.js'; import { renderTeamHtml } from './team.js'; import { renderArchiveHtml } from './archive.js'; import { renderDecisionsHtml } from './decisions.js'; +import { renderMessagesHtml } from './messages.js'; import { renderTaskDetailHtml } from './taskDetail.js'; import { eventBus, emitChange } from './events.js'; import type { AgentHubEvent } from './events.js'; @@ -426,9 +427,14 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise // Direct agent-to-agent / architect-to-agent messages. GET /messages returns // all (architect view); GET /messages/inbox?agent=X&unread=1 returns one // agent's inbox. - app.get('/messages', async (request) => { - const { agent, unread } = request.query as { agent?: string; unread?: string }; + app.get('/messages', async (request, reply) => { + const { agent, unread, as, c } = request.query as { agent?: string; unread?: string; as?: string; c?: string }; + // JSON inbox contract (remoteClient.getInbox / agenthub_inbox) — unchanged. if (agent) return listInbox(cwd, agent, { unreadOnly: unread === '1' || unread === 'true' }); + // Browser navigation → the /messages conversation view. + if (wantsHtml(request)) { + return reply.type('text/html; charset=utf-8').send(renderMessagesHtml(cwd, as, c)); + } return listMessages(cwd); }); app.post('/messages', async (request, reply) => { @@ -450,6 +456,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise ); return message; }); + // Single message (frontmatter + body) — used by `message reply` to load the + // parent it answers. JSON only. + app.get('/messages/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + try { + const { message, body } = getMessage(cwd, id); + return { message, body }; + } catch { + return notFound(reply, 'Message'); + } + }); app.post('/messages/:id/read', async (request, reply) => { const { id } = request.params as { id: string }; try { @@ -465,6 +482,21 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise return badRequest(reply, err instanceof Error ? err.message : 'Message not found'); } }); + app.post('/messages/:id/ack', async (request, reply) => { + const { id } = request.params as { id: string }; + const { by } = (request.body ?? {}) as { by?: string }; + try { + const message = ackMessage(cwd, id, by); + // Ack receipt: mirror of /read so the sender's stream shows the strongest state. + emitChange( + { type: 'message', action: 'updated', id: message.id, status: 'acked', title: `${message.from} → ${message.to}`, assignedTo: message.to }, + message.updatedAt, + ); + return message; + } catch (err) { + return badRequest(reply, err instanceof Error ? err.message : 'Message not found'); + } + }); // ─── Memory ────────────────────────────────────────────────────────────── app.get('/memory', async () => listMemory(cwd)); diff --git a/src/server/ui-shared.ts b/src/server/ui-shared.ts index 300217e..563c0c7 100644 --- a/src/server/ui-shared.ts +++ b/src/server/ui-shared.ts @@ -291,7 +291,7 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act // identically on the non-board pages. // ───────────────────────────────────────────────────────────────────────────── -export type HeaderPage = 'board' | 'team' | 'activity' | 'decisions' | 'archive' | 'task'; +export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task'; /** CSS for the shared header + new-task modal + toasts. Include once per page. */ export function appHeaderCss(): string { @@ -385,6 +385,7 @@ export function appHeader(projectName: string, current: HeaderPage): string { ${link('Board', '/board', 'board')} ${link('Team', '/team', 'team')} ${link('Activity', '/activity', 'activity')} + ${link('Messages', '/messages', 'messages')} ${link('Decisions', '/decisions', 'decisions')}