diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index ecd1659e..ef24b2b9 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -4,7 +4,7 @@ Thanks for contributing to Claude Agent Teams UI!
## Before You Start
-For big features and major changes, please discuss them in our [Discord](https://discord.gg/qtqSZSyuEc) first so we can figure out the best approach together and avoid conflicts.
+For big features and major changes, please discuss them in our [Discord](https://discord.gg/qtqSZSyuEc) first: https://discord.gg/qtqSZSyuEc so we can figure out the best approach together and avoid conflicts.
Small fixes, bug reports, and minor improvements are always welcome - just open a PR.
diff --git a/README.md b/README.md
index 0444fdef..871f52b0 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
- 100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.
+ 100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions/logins or API keys where supported. Not just coding agents.
@@ -48,7 +48,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42
## Installation
-No prerequisites — Claude Code can be installed and configured directly from the app.
+No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
@@ -105,8 +105,9 @@ No prerequisites — Claude Code can be installed and configured directly from t
## What is this
-A new approach to task management with AI agent teams.
+A local orchestration layer for AI agent teams across Claude and Codex.
+- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions/logins or API keys where supported
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
- **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
@@ -114,7 +115,7 @@ A new approach to task management with AI agent teams.
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
- **Built-in review workflow** — easily see how agents review each other's tasks to make sure everything went exactly as planned
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
-- **Task-specific logs and messages** — clearly see all Claude logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment
+- **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment
- **Live process section** — see which agents are running processes and open URLs directly in the browser
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
- **Flexible autonomy** — let agents run fully autonomous, or review and approve each action one by one (you'll get a notification) — configure the level of control that fits your security needs
@@ -125,15 +126,15 @@ A new approach to task management with AI agent teams.
- **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context.
-- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
+- **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses
-- **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks
+- **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
-- **Zero-setup onboarding** — built-in Claude Code installation and authentication
+- **Zero-setup onboarding** — built-in runtime detection and provider authentication
- **Built-in code editor** — edit project files with Git support without leaving the app
@@ -147,7 +148,7 @@ A new approach to task management with AI agent teams.
- **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box
-- **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost
+- **Post-compact context recovery** — when the active runtime compacts its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost
- **Task context is preserved** — thanks to task descriptions, comments, and attachments, all essential information about each task remains available for ongoing work and future reference
@@ -177,14 +178,14 @@ A new approach to task management with AI agent teams.
| **Flexible autonomy** | ✅ Granular settings, per-action approval, notifications | ❌ | ⚠️ Plan approval only | ✅ | ✅ |
| **Git worktree isolation** | ✅ Optional | ⚠️ Mandatory | ⚠️ Mandatory | ✅ | ✅ |
| **Multi-agent backend** | ✅ Claude, Codex, more coming soon | ✅ 6+ agents | ✅ 11 providers | ✅ Multi-model | — |
-| **Price** | **Free** | Free / $30 user/mo | Free | $0–$200/mo | Claude subscription |
+| **Price** | **Free** | Free / $30 user/mo | Free | $0–$200/mo | Provider subscription |
---
## Quick start
1. **Download** the app for your platform (see [Installation](#installation))
-2. **Launch** — On first run, the setup wizard will install and authenticate Claude Code
+2. **Launch** — On first run, the setup wizard will detect the runtime and guide provider authentication
3. **Create a team** — Pick a project, define roles, write a provisioning prompt
4. **Watch** — Agents spawn, create tasks, and work. You see it all on the kanban board
@@ -194,27 +195,27 @@ A new approach to task management with AI agent teams.
## FAQ
-Do I need to install Claude Code before using this app?
+Do I need to install a runtime before using this app?
-No. The app includes built-in installation and authentication — just launch and follow the setup wizard.
+No. The app guides runtime detection/setup and provider authentication from the UI - just launch and follow the setup wizard.
Does it read or upload my code?
-No. Everything runs locally. The app reads Claude Code's session logs from ~/.claude/ — your source code is never sent anywhere.
+No. Everything runs locally. The app reads local runtime/session data to power the UI - your source code is never sent anywhere.
Can agents communicate with each other?
-Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated through Claude Code's team protocol.
+Yes. Agents send direct messages, create shared tasks, and leave comments - all coordinated by the app's own orchestration layer.
Is it free?
-Yes, completely free and open source. The app requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
+Yes, completely free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex.
@@ -229,12 +230,6 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment
Send a direct message to course-correct, or stop and restart from the process dashboard. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
-
-Can I use it just to view past sessions without running agents?
-
-Yes. The app works as a session viewer — browse, search, and analyze any Claude Code session history.
-
-
Does it support multiple projects and teams?
diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js
index 6349cc48..cfc3e257 100644
--- a/agent-teams-controller/src/internal/review.js
+++ b/agent-teams-controller/src/internal/review.js
@@ -1,8 +1,6 @@
-const fs = require('fs');
-const path = require('path');
-
const kanban = require('./kanban.js');
const messages = require('./messages.js');
+const runtimeHelpers = require('./runtimeHelpers.js');
const tasks = require('./tasks.js');
const { wrapAgentBlock } = require('./agentBlocks.js');
@@ -17,19 +15,7 @@ function getReviewer(context, flags) {
}
function resolveLeadSessionId(context, flags) {
- if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) {
- return flags.leadSessionId.trim();
- }
-
- try {
- const configPath = path.join(context.paths.teamDir, 'config.json');
- const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
- return typeof parsed.leadSessionId === 'string' && parsed.leadSessionId.trim()
- ? parsed.leadSessionId.trim()
- : undefined;
- } catch {
- return undefined;
- }
+ return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId);
}
function getCurrentReviewState(task) {
diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js
index e6869831..f8fadc91 100644
--- a/agent-teams-controller/src/internal/runtimeHelpers.js
+++ b/agent-teams-controller/src/internal/runtimeHelpers.js
@@ -299,6 +299,24 @@ function resolveLeadSessionId(paths) {
: undefined;
}
+function resolveCanonicalLeadSessionId(paths, candidate) {
+ const configured = resolveLeadSessionId(paths);
+ const explicit = typeof candidate === 'string' ? candidate.trim() : '';
+
+ if (!explicit) {
+ return configured;
+ }
+
+ // The team config is the canonical source of the current lead runtime session.
+ // If a caller passes a placeholder like "team-lead" or any other mismatched value,
+ // prefer the configured session id instead of persisting dirty metadata into inbox rows.
+ if (configured) {
+ return explicit === configured ? explicit : configured;
+ }
+
+ return explicit;
+}
+
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
@@ -497,6 +515,7 @@ module.exports = {
readTeamConfig,
resolveTeamMembers,
getCurrentRuntimeMemberIdentity,
+ resolveCanonicalLeadSessionId,
resolveLeadSessionId,
saveTaskAttachmentFile,
};
diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js
index a17fba61..2632bdd8 100644
--- a/agent-teams-controller/test/controller.test.js
+++ b/agent-teams-controller/test/controller.test.js
@@ -530,6 +530,25 @@ describe('agent-teams-controller API', () => {
expect(inbox[0].leadSessionId).toBe('lead-session-1');
});
+ it('ignores mismatched leadSessionId placeholders on review_request and uses canonical config session', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
+
+ controller.kanban.addReviewer('alice');
+ controller.tasks.completeTask(task.id, 'bob');
+ controller.review.requestReview(task.id, {
+ from: 'team-lead',
+ leadSessionId: 'team-lead',
+ });
+
+ const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
+ const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
+
+ expect(inbox).toHaveLength(1);
+ expect(inbox[0].leadSessionId).toBe('lead-session-1');
+ });
+
it('starts review idempotently without requiring completed status', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@@ -697,6 +716,47 @@ describe('agent-teams-controller API', () => {
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
});
+ it('ignores mismatched leadSessionId placeholders on review_approve owner notifications', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({ subject: 'Approve me', owner: 'bob' });
+
+ controller.kanban.addReviewer('alice');
+ controller.tasks.completeTask(task.id, 'bob');
+ controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
+ controller.review.approveReview(task.id, {
+ from: 'team-lead',
+ note: 'Looks good.',
+ 'notify-owner': true,
+ leadSessionId: 'team-lead',
+ });
+
+ const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
+ const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(rows.at(-1).summary).toContain('Approved');
+ expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
+ });
+
+ it('ignores mismatched leadSessionId placeholders on review_request_changes owner notifications', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' });
+
+ controller.kanban.addReviewer('alice');
+ controller.tasks.completeTask(task.id, 'bob');
+ controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
+ controller.review.requestChanges(task.id, {
+ from: 'alice',
+ comment: 'Please address review feedback.',
+ leadSessionId: 'team-lead',
+ });
+
+ const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
+ const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(rows.at(-1).summary).toContain('Fix request');
+ expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
+ });
+
it('limits approved briefing section to the latest 10 tasks by freshness', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
diff --git a/bun.lock b/bun.lock
index 96fc0ecf..750e48b5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -79,12 +79,14 @@
"lucide-react": "^0.577.0",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.12.3",
+ "motion": "12.38.0",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2",
"react-markdown": "^10.1.0",
+ "react-modal-sheet": "5.6.0",
"react-resizable": "^3.1.3",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
@@ -2330,6 +2332,8 @@
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
+ "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
+
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
@@ -2956,6 +2960,12 @@
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
+ "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
+
+ "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
+
+ "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
+
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -3352,6 +3362,8 @@
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "devlop": "1.1.0", "hast-util-to-jsx-runtime": "2.3.6", "html-url-attributes": "3.0.1", "mdast-util-to-hast": "13.2.1", "remark-parse": "11.0.0", "remark-rehype": "11.1.2", "unified": "11.0.5", "unist-util-visit": "5.0.0", "vfile": "6.0.3" }, "peerDependencies": { "@types/react": "19.2.14", "react": "19.2.4" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+ "react-modal-sheet": ["react-modal-sheet@5.6.0", "", { "dependencies": { "react-use-measure": "2.1.7" }, "peerDependencies": { "motion": ">=11", "react": ">=16" } }, "sha512-+WE2nVPdB/Jx0QbndIBqGvy6k0IXriW2lFaPeZSW1xOVri6rWhAwrSnArtsR1rxOxW8HBdAYeIPUcbjMvNeeyw=="],
+
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "2.3.8", "react-style-singleton": "2.2.3", "tslib": "2.8.1", "use-callback-ref": "1.3.3", "use-sidecar": "1.1.3" }, "optionalDependencies": { "@types/react": "19.2.14" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
@@ -3362,6 +3374,8 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "1.0.1", "tslib": "2.8.1" }, "optionalDependencies": { "@types/react": "19.2.14" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+ "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="],
+
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "4.4.3" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
diff --git a/docs/iterations/README.md b/docs/iterations/README.md
index e809b9f9..efac21c7 100644
--- a/docs/iterations/README.md
+++ b/docs/iterations/README.md
@@ -10,10 +10,11 @@
- [Итерация 04 — Messaging + Review](./iteration-04-messaging-review.md)
- [Итерация 05 — Testing + Polish](./iteration-05-testing-polish.md)
- [Итерация 06 — Team Provisioning (Create Team из UI)](./iteration-06-team-provisioning.md)
+- [Iteration 07 - Task Logs + Explicit Board Task Links](./iteration-07-task-logs-explicit-board-task-links.md)
+- [Iteration 08 - Exact Task Logs Reusing Existing Execution Renderer](./iteration-08-exact-task-logs-reuse-existing-renderer.md)
## Принципы
- **Vertical slice**: в каждой итерации доводим минимум “end-to-end” (types → main → IPC → preload → renderer → UI)
- **Чёткий scope**: у каждой итерации есть цели и не‑цели
- **Definition of Done**: заранее фиксируем критерии готовности и ручную проверку
-
diff --git a/docs/iterations/iteration-03-kanban-board.md b/docs/iterations/iteration-03-kanban-board.md
index 5f14a654..56830552 100644
--- a/docs/iterations/iteration-03-kanban-board.md
+++ b/docs/iterations/iteration-03-kanban-board.md
@@ -1,5 +1,10 @@
# Итерация 03 — Kanban Board (click-to-move) + `kanban-state.json`
+> Historical note
+> This document captures the planned Kanban scope at iteration time.
+> It is not the source of truth for the current product contract.
+> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
+
Эта итерация добавляет **kanban-доску команды** во вкладке Team и вводит **персистентное состояние** для колонок `REVIEW`/`APPROVED` через файл `~/.claude/teams/{teamName}/kanban-state.json`.
Основание:
@@ -263,4 +268,3 @@
- **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink.
- **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен).
- **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах.
-
diff --git a/docs/iterations/iteration-04-messaging-review.md b/docs/iterations/iteration-04-messaging-review.md
index 37a4763d..86f87934 100644
--- a/docs/iterations/iteration-04-messaging-review.md
+++ b/docs/iterations/iteration-04-messaging-review.md
@@ -1,5 +1,10 @@
# Итерация 04 — Messaging + Review (Inbox + ReviewDialog)
+> Historical note
+> This document captures the planned scope and assumptions at iteration time.
+> It is not the source of truth for the current product contract.
+> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
+
Эта итерация добавляет **панель активности (inbox messages)** и **отправку сообщений** тиммейтам, а также закрывает MVP review-flow: **Request Review → Approve / Request Changes**.
Основание:
@@ -293,4 +298,3 @@ Guards:
- **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`.
- **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail.
- **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05).
-
diff --git a/docs/iterations/iteration-05-testing-polish.md b/docs/iterations/iteration-05-testing-polish.md
index 750bb42a..db7f4c6f 100644
--- a/docs/iterations/iteration-05-testing-polish.md
+++ b/docs/iterations/iteration-05-testing-polish.md
@@ -1,5 +1,10 @@
# Итерация 05 — Testing + Polish (production-ready)
+> Historical note
+> This document captures iteration-era test and polish assumptions.
+> It is not the source of truth for the current product contract.
+> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md).
+
Эта итерация закрывает **качество**: тесты на критические пути (read/write), фиксация edge cases, UX-polish (empty/error/loading), и небольшие оптимизации под реальные объёмы inbox/tasks.
Основание:
@@ -204,4 +209,3 @@ test/
4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло
5) Request Changes → task.status стал `in_progress` + owner получил сообщение
6) Любое изменение файлов `~/.claude/teams/**` / `~/.claude/tasks/**` → UI обновился в пределах ~1с
-
diff --git a/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md b/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md
new file mode 100644
index 00000000..e1aa9032
--- /dev/null
+++ b/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md
@@ -0,0 +1,2630 @@
+# Iteration 07 - Task Logs + Explicit Board Task Links
+
+> Historical note
+> This document captures the planned scope and architecture at iteration time.
+> It is not the source of truth for the final runtime contract.
+
+This iteration introduces a **new explicit task activity model** for team board tasks and keeps the current session-based execution logs as a **separate legacy block**.
+
+The goal is to stop reconstructing `task -> logs` mostly from heuristics and instead persist a small, explicit, board-task-specific linkage in runtime transcripts, then build a clean read model for the task popup UI.
+
+This iteration spans **two repos**:
+- `agent_teams_orchestrator` - write-side runtime and transcript contract
+- `claude_team` - read-side task activity feed and UI integration
+
+---
+
+## Decision Record
+
+### Chosen direction
+
+- **New `Task Activity` feed**
+- **Keep old `Execution Sessions` block**, but explicitly treat it as legacy/session-centric
+- **Persist explicit board-task links in transcript JSONL**
+- **Build a read model on top of those links**
+
+### Why this was chosen
+
+- The current `Execution Logs` view is fundamentally **session-centric**
+- The new requirement is **event-centric**:
+ - "show all logs/actions related to task A"
+ - including actions performed by another actor while they were actively working on task B
+- Mixing both into one model makes both of them worse
+
+### Rejected alternatives
+
+- **Replace `Execution Logs` entirely with one new event timeline**
+ - Too risky for first rollout
+ - Would throw away useful current session features
+- **Keep only the old session logic and improve heuristics**
+ - Not reliable enough
+ - Does not solve cross-task board actions correctly
+- **Use one single `taskContext` object per message**
+ - Breaks on multi-target tools such as `task_link`
+ - Becomes ambiguous too quickly
+
+---
+
+## Goals
+
+- Add a **new explicit activity feed** for board tasks
+- Keep the current **execution session logs** available as a separate legacy block
+- Make task-log linkage **structural**, not mainly heuristic
+- Make the new feed **explicit-link only in v1**
+- Support:
+ - task lifecycle events
+ - ordinary execution logs during active task work
+ - board actions performed on a task by another actor
+ - review flow actions
+ - multi-target task tools where relevant
+
+---
+
+## Non-Goals
+
+- Replacing the existing `Workflow History` timeline
+- Deleting the current `Execution Sessions` logic
+- Rebuilding all historical logs retroactively
+- Stamping ambiguous lead free-text execution in v1
+- Reworking built-in `TaskCreate` / `TaskUpdate` into this domain
+
+This iteration is for **board-task activity only**, not generic task tooling.
+
+---
+
+## What We Fixed Before This Iteration
+
+Before implementing this iteration, we fixed a real false-negative in the current modern MCP task boundary detection:
+
+- fully-qualified tool names such as `mcp__agent-teams__task_start`
+- alternate normalized names such as `mcp__agent_teams__task_complete`
+
+The fix was intentionally narrow:
+- one canonicalization helper for agent-teams MCP tool names
+- structural boundary detection now sees modern MCP task markers
+
+This is a prerequisite hardening step, not the main solution for the new feed.
+
+---
+
+## Core Architectural Decision
+
+Use **two levels of model**, not one:
+
+### 1. Persisted wire contract
+
+The runtime writes small, explicit, additive transcript fields:
+
+- `boardTaskLinks?: BoardTaskLinkV1[]`
+- `boardTaskToolActions?: BoardTaskToolActionV1[]`
+
+Together these fields capture the **minimum durable truth**:
+- which board task(s) this message is linked to
+- what kind of link each task has to the message
+- how the actor's active task state relates to each task at that moment
+- what board-task tool action(s) the message represents, when the message contains successful tool results
+
+They are **not** UI objects.
+
+### 2. Read model
+
+`claude_team` reads transcript entries and builds:
+
+- `BoardTaskActivityEntry`
+
+This is the UI-facing model for the new task activity feed.
+
+This separation keeps the runtime contract stable while allowing the UI to evolve.
+
+---
+
+## Layering and Isolation Rules
+
+These rules are part of the design, not optional cleanup.
+
+### 1. Persisted contract is not a UI DTO
+
+`boardTaskLinks[]` must remain a small runtime fact model.
+
+It should not grow UI-only fields such as:
+- display labels
+- actor names
+- timestamps duplicated from transcript entries
+- section-level rendering hints
+
+### 2. The new feed must not depend on legacy heuristics in v1
+
+The new `Task Activity` feed should read **explicit links only**.
+
+That means:
+- no mention-based guessing
+- no owner/session overlap inference
+- no work-interval heuristics inside the new feed
+
+Legacy heuristics remain available only inside the legacy execution-sessions block.
+
+### 3. Keep the old session code, but isolate it
+
+Do **not** delete the current execution-session code.
+
+Do **not** comment it out either.
+
+Instead:
+- keep it behind a separate service boundary
+- keep it rendered in a separate UI section
+- treat it as compatibility/session-exploration logic, not as the new source of truth
+
+### 4. The popup composes two read models, not one mixed model
+
+The task popup should compose:
+- explicit event-level task activity
+- legacy session-level execution browsing
+
+It should **not** merge both into one array or one card list.
+
+---
+
+## Naming Decisions
+
+### Persisted fields
+
+Use:
+
+- `boardTaskLinks`
+- `boardTaskToolActions`
+
+Do **not** use:
+
+- `taskContext`
+- `boardTaskContext`
+
+Why:
+- one message can legitimately link to **multiple board tasks**
+- `task_link` and `task_unlink` are the clearest example
+- plural naming makes the model honest
+
+### Persisted types
+
+Use:
+
+- `BoardTaskLinkV1`
+- `BoardTaskLocator`
+- `BoardTaskToolActionV1`
+
+### Read model
+
+Use:
+
+- shared DTO: `BoardTaskActivityEntry`
+- main service: `BoardTaskActivityService`
+- transcript discovery service: `TeamTranscriptSourceLocator`
+
+### Renderer names
+
+Use:
+
+- outer section label: `Task Logs`
+- user-facing subsection label: `Task Activity`
+- renderer component: `TaskActivitySection`
+- composed container: `TaskLogsPanel`
+
+### Legacy/session block
+
+Use:
+
+- `Execution Sessions`
+
+This keeps the old block clearly separate from the new activity feed.
+
+### Why not `TaskActivityTimeline` as the main internal name
+
+The repo already has:
+- `ActivityTimeline` for team inbox/message activity
+- `Workflow History` / `StatusHistoryTimeline` for board-state history
+
+Using `TaskActivityTimeline` as the main internal component name would make the codebase harder to scan.
+
+So:
+- `Task Logs` is the better outer section label
+- `Task Activity` stays the user-facing subsection label
+- `TaskActivitySection` is the better internal renderer name
+
+---
+
+## Domain Boundaries
+
+### Included
+
+Board task domain only:
+- `task_*` MCP tools that operate on board tasks
+- `review_*` MCP tools tied to a board task
+
+### Excluded
+
+Do not include in the new core:
+- built-in `TaskCreate`
+- built-in `TaskUpdate`
+- generic inbox/message/process tools without task target
+
+Those can remain as legacy/fallback logic where needed, but they are not part of the new activity core.
+
+---
+
+## Persisted Wire Contract
+
+### Transcript field
+
+Add an optional field to transcript messages in `agent_teams_orchestrator`:
+
+```ts
+type BoardTaskLocator = {
+ ref: string
+ refKind: 'canonical' | 'display' | 'unknown'
+ canonicalId?: string
+}
+
+type BoardTaskLinkV1 = {
+ schemaVersion: 1
+
+ task: BoardTaskLocator
+
+ taskArgumentSlot?: 'taskId' | 'targetId'
+
+ toolUseId?: string
+
+ linkKind: 'execution' | 'lifecycle' | 'board_action'
+
+ actorContext: {
+ relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'
+ activeTask?: BoardTaskLocator
+ activePhase?: 'work' | 'review'
+ activeExecutionSeq?: number
+ }
+}
+
+type BoardTaskToolActionV1 = {
+ schemaVersion: 1
+ toolUseId: string
+ canonicalToolName: string
+ input?: {
+ status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
+ owner?: string | null
+ relationship?: 'blocked-by' | 'blocks' | 'related'
+ clarification?: 'lead' | 'user' | null
+ reviewer?: string
+ commentId?: string
+ }
+ resultRefs?: {
+ commentId?: string
+ attachmentId?: string
+ filename?: string
+ }
+}
+
+type TranscriptMessage = ExistingTranscriptMessage & {
+ boardTaskLinks?: BoardTaskLinkV1[]
+ boardTaskToolActions?: BoardTaskToolActionV1[]
+}
+```
+
+### Why this shape
+
+- `task.ref` instead of unconditional `taskId`
+ - runtime input may contain display IDs
+ - do not lie about canonical identity
+ - store the normalized task reference without a leading `#`
+- `schemaVersion`
+ - clearer than a generic nested `version`
+ - safer when transcript messages already contain their own top-level version fields
+- `taskArgumentSlot`
+ - needed for multi-target tools
+ - aligns the persisted contract with the actual MCP input slots (`taskId` / `targetId`)
+ - clearer than `inputRole`, which is too easy to confuse with user/assistant message roles
+ - clearer than `toolArgumentRole`, because this is specifically the task-related argument slot
+ - should be omitted for ambient execution links that do not originate from a tool argument
+- `toolUseId`
+ - needed to join task links to the exact `tool_result` block that produced them
+ - protects the contract when one transcript message contains multiple `tool_result` blocks
+- `linkKind`
+ - distinguishes execution, lifecycle, and board actions
+- `actorContext`
+ - captures the subtle "actor is currently active on another task" case
+- `boardTaskToolActions`
+ - keeps message-level tool semantics out of the per-target link object
+ - avoids repeating the same tool metadata across multiple target links
+ - must be plural because a single user message can legitimately contain multiple `tool_result` blocks
+ - gives the read-side enough stable structure for rows such as owner/status/relationship/clarification changes without parsing free text
+ - can carry stable result references such as `commentId` / `attachmentId` when the tool returns them
+ - `canonicalToolName` should store the canonical bare board tool name after `agent-teams` MCP normalization
+ - `input` / `resultRefs` should stay minimal and semantic, not a dump of raw MCP input or raw tool result
+ - do not copy long free-text payloads such as comment text, review notes, or request-change prose into transcript metadata
+ - omit orchestration-only inputs already represented elsewhere, such as `from`, `actor`, `leadSessionId`, and `notifyOwner`
+
+### Important rule
+
+Do **not** duplicate in `boardTaskLinks` or `boardTaskToolActions`:
+- timestamp
+- sessionId
+- agentId
+- memberName
+- teamName
+
+Those already exist on the transcript entry itself and should remain single-source.
+
+For read-side task popup queries, the team scope comes from the surrounding team-scoped query/file
+discovery path, so `boardTaskLinks[]` does not need to repeat it.
+
+This is especially important because not every transcript path is guaranteed to stamp `teamName`
+uniformly on every entry, particularly sidechain-oriented paths.
+
+### Metadata size budget
+
+The explicit contract must stay small enough to remain transcript-friendly.
+
+Recommended budget rules:
+- at most one `BoardTaskToolActionV1` per `toolUseId` in one message
+- keep `boardTaskLinks` to the minimal task-target set for that message
+- never persist arbitrary free-text comment bodies, review prose, or task descriptions
+- trim all persisted string identifiers
+- suggested soft caps:
+ - `task.ref` / `canonicalId` / `toolUseId` / `canonicalToolName` - at most 128 chars
+ - `filename` - at most 256 chars
+ - enum-like fields only from explicit allow-lists
+
+If a value exceeds the budget:
+- prefer omitting that optional field over truncating it into a misleading value
+- for required identifiers, skip that object and emit debug diagnostics instead of persisting junk
+
+### Omit vs null policy
+
+Use omission by default for unknown or unavailable optional fields.
+
+Rules:
+- use `undefined` / omitted for:
+ - `taskArgumentSlot`
+ - `toolUseId` on ambient execution links
+ - `canonicalId` when unresolved
+ - `actorContext.activeTask`
+ - `actorContext.activePhase`
+ - `actorContext.activeExecutionSeq`
+ - optional `input` / `resultRefs` fields that are not whitelisted for the current tool
+- use explicit `null` only when the domain itself uses null as meaningful data:
+ - `input.owner = null`
+ - `input.clarification = null`
+
+Why:
+- omission means "not available / not applicable"
+- `null` means "explicitly cleared"
+- mixing them loosely would make parser behavior and UI labels inconsistent
+
+### Invariants
+
+- every `boardTaskToolActions[*].toolUseId` should match at least one `boardTaskLinks[*].toolUseId`
+- `boardTaskToolActions` must not appear without at least one `boardTaskLink`
+- within one message, `boardTaskToolActions` should be unique by `toolUseId`
+- `linkKind = 'execution'` is reserved for ambient execution rows in v1
+- `execution` links may carry `toolUseId` when they intentionally anchor a worker `tool_result`
+ row for exact task-log reconstruction
+- therefore `execution` links should omit `taskArgumentSlot`
+- `boardTaskToolActions` should only pair with sibling links whose `linkKind` is `lifecycle` or `board_action`
+- `actorContext.activeTask` should only be set when `relation = 'other_active_task'`
+- `actorContext.activePhase` / `actorContext.activeExecutionSeq` describe the actor's active scope,
+ not the target task's own identity
+- for `linkKind = 'lifecycle'`, `actorContext` should reflect the actor state **before** the
+ lifecycle transition is applied
+- within one message, emitted links should be unique by `(toolUseId ?? 'ambient', task.ref, taskArgumentSlot ?? 'none', linkKind)`
+- ambient execution links should omit `taskArgumentSlot`
+- tool-derived links should set `taskArgumentSlot = 'taskId'` for the primary task-argument slot
+- `toolUseId` should still be omitted for ordinary conversational execution messages
+
+### Additive-safety note
+
+This is safe as additive transcript metadata because:
+- `agent_teams_orchestrator` transcript messages already tolerate optional extra fields
+- `claude_team` JSONL parsing is loose and ignores unknown fields until explicitly consumed
+
+### Version evolution policy
+
+- bump `schemaVersion` only for breaking meaning changes, not for additive optional fields
+- additive optional fields within `BoardTaskLinkV1` / `BoardTaskToolActionV1` should remain on
+ version `1`
+- a single message should not mix multiple schema versions for the same object family
+- readers should accept the current version and ignore newer unknown versions object-by-object
+- writers should emit exactly one stable version family at a time
+
+This keeps rollout and future migrations simple:
+- old readers keep working by ignoring what they do not understand
+- new readers can still salvage older transcript rows without rewriting history
+
+---
+
+## Write-Side Emission Policy
+
+The runtime should emit explicit links only when it has reliable information.
+
+### Carrier-field rule
+
+On the write side, the cleanest implementation is to carry:
+
+- `boardTaskLinks?: BoardTaskLinkV1[]`
+- `boardTaskToolActions?: BoardTaskToolActionV1[]`
+
+as internal transcript-only fields on runtime `Message` objects before persistence.
+
+Those carriers must be threaded through the message creation/normalization path for any message
+types that can legitimately receive task metadata.
+
+That implies adding optional transcript-only fields to the orchestrator's internal message types,
+not just to `TranscriptMessage`.
+
+This keeps the contract close to the message that will actually be persisted and avoids having a
+separate side registry that can drift from message ordering.
+
+### Carrier propagation checkpoints
+
+The implementation should explicitly audit the runtime paths that rebuild messages rather than
+assuming a new field on `TranscriptMessage` will survive automatically.
+
+At minimum, verify the carrier survives:
+- message factory helpers such as `createUserMessage(...)`
+- any assistant-message creation path that rebuilds plain objects
+- message normalization paths that split multi-block messages into new message objects
+- transcript logging cleanup paths before `insertMessageChain(...)`
+
+And the implementation should explicitly **not** leak transcript-only task metadata into:
+- model payload normalization
+- SDK/web message mappers
+- any API-facing serialization path not intended for transcript persistence
+
+### V1 rules
+
+- stamp explicit task links on successful board-task `tool_result` messages
+- stamp `boardTaskToolActions` only on successful board-task `tool_result` messages
+- stamp ambient `execution` links only on ordinary conversational messages when the actor has exactly one active task
+- do not rely on raw `tool_use` alone to claim lifecycle success
+- do not attach ambient execution links to progress, attachment, system, or transcript-only meta scaffolding
+- do not attach ambient execution links to assistant `tool_use` blocks or thinking-only assistant children after normalization
+- do not ambient-stamp lead free-text execution in v1
+- dedupe lifecycle/action application by `(sessionId, agentId ?? 'main', toolUseId)` before mutating actor execution state or stamping transcript fields
+
+### Carrier placement matrix
+
+Allowed carrier placement by runtime message shape:
+
+- user `tool_result` message
+ - may carry `boardTaskLinks`
+ - may carry `boardTaskToolActions`
+- ordinary user conversational message
+ - may carry ambient `boardTaskLinks`
+ - must not carry `boardTaskToolActions`
+- ordinary assistant conversational message
+ - may carry ambient `boardTaskLinks`
+ - must not carry `boardTaskToolActions`
+- assistant `tool_use` message
+ - must not carry either carrier family in v1
+- thinking-only assistant child
+ - must not carry either carrier family
+- `progress`, `attachment`, `system`, `tombstone`, compact-boundary, and other non-conversational items
+ - must not carry either carrier family
+
+Read-side simplifying assumption enabled by this rule:
+- `boardTaskToolActions` always means "this message contains a concrete successful board-tool result"
+- ambient execution links only appear on human-readable conversational rows
+
+### Tool-result success matrix
+
+For v1 explicit stamping, treat a board-tool result as successful only when all of the following hold:
+
+- the message is a real user `tool_result` message, not a synthetic placeholder
+- the `tool_result` block pairs to a real assistant `tool_use`
+- the result is not an interrupt/reject/denial synthetic recovery block
+- the execution outcome is semantically successful for that tool family
+
+Conservative success rules:
+- paired MCP board-task tool result with no synthetic/error recovery markers
+ - emit `board_action` or `lifecycle` metadata
+- paired board-task tool result that is denied, rejected, interrupted, synthetic, or otherwise unsuccessful
+ - emit no explicit board-task metadata in v1
+- unpaired `tool_result`
+ - emit no explicit board-task metadata in v1
+- ambient conversational message while one active task exists
+ - emit `execution` links only
+
+Important design choice:
+- v1 does **not** model failed board actions as task-activity rows
+- this is intentional to keep the first explicit feed highly reliable
+- if failed-action visibility becomes important later, add a separate `failed_board_action` concept
+ instead of overloading the success-only v1 contract
+
+### Why this matters
+
+Tool success semantics differ across tool families, so the observer must decide after execution
+outcome is known, not just from the attempted tool call.
+
+Also, some runtime paths - especially subagent-oriented ones - do not preserve rich structured
+`toolUseResult` / `mcpMeta` all the way to transcript persistence. The explicit transcript fields
+must therefore carry enough stable board-task semantics for the read-side to avoid reparsing
+natural-language tool output.
+
+Just as importantly, repeated tool-result handling by the same `toolUseId` would create duplicated
+lifecycle transitions and duplicated task-activity rows, so the observer has to dedupe before
+state mutation.
+
+The `toolUseId` join key is also what keeps the contract correct when a single transcript message
+contains more than one successful `tool_result` block.
+
+---
+
+## Read Model
+
+`claude_team` should build a richer UI model:
+
+```ts
+type BoardTaskActivityEntry = {
+ id: string
+ timestamp: string
+
+ actor: {
+ memberName?: string
+ role: 'member' | 'lead' | 'unknown'
+ sessionId: string
+ agentId?: string
+ }
+
+ task: {
+ locator: BoardTaskLocator
+ taskRef?: TaskRef
+ resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
+ }
+
+ linkKind: 'execution' | 'lifecycle' | 'board_action'
+ actorContext: {
+ relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'
+ activeTask?: {
+ locator: BoardTaskLocator
+ taskRef?: TaskRef
+ resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
+ }
+ activePhase?: 'work' | 'review'
+ activeExecutionSeq?: number
+ }
+
+ action: {
+ canonicalToolName?: string
+ toolUseId?: string
+ category:
+ | 'status'
+ | 'review'
+ | 'comment'
+ | 'assignment'
+ | 'read'
+ | 'attachment'
+ | 'relationship'
+ | 'clarification'
+ | 'other'
+ peerTask?: {
+ locator: BoardTaskLocator
+ taskRef?: TaskRef
+ resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
+ }
+ relationshipPerspective?: 'outgoing' | 'incoming' | 'symmetric'
+ details?: {
+ status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
+ owner?: string | null
+ relationship?: 'blocked-by' | 'blocks' | 'related'
+ clarification?: 'lead' | 'user' | null
+ reviewer?: string
+ commentId?: string
+ attachmentId?: string
+ filename?: string
+ }
+ }
+
+ source: {
+ messageUuid: string
+ filePath: string
+ }
+}
+```
+
+The read model should be derived, not persisted.
+
+`id` should be stable and deterministic, for example:
+- `${messageUuid}:${action.toolUseId ?? 'ambient'}:${task.locator.ref}:${link.taskArgumentSlot ?? 'none'}:${linkKind}`
+
+This avoids duplicate-row key problems when one transcript message yields multiple task activity rows.
+
+This read model should stay **semantic**, not presentation-coupled.
+
+It is the right place to add:
+- resolved actor identity
+- resolved task references where possible
+- action category
+- actor/task relationship state
+- relationship peer-task context derived from sibling links within the same message
+
+It is **not** the right place to hardcode:
+- final display labels
+- UI tone names
+- renderer-specific row text
+
+The read model should **not** leak raw transport details such as `taskArgumentSlot` into renderer code.
+For relationship tools, the builder should consume `taskArgumentSlot` from the persisted link and expose
+semantic information instead:
+- `peerTask`
+- `relationshipPerspective`
+
+For non-relationship rows, `taskArgumentSlot` is internal transport detail only:
+- ambient execution rows will usually have it omitted
+- ordinary single-target tool rows may have `'task'`
+- renderer code should not branch on it directly
+
+Mapping rules for relationship rows:
+- `related` -> `relationshipPerspective = 'symmetric'` on both task popups
+- `blocked-by` on the `task` side -> `incoming`
+- `blocked-by` on the `target` side -> `outgoing`
+- `blocks` on the `task` side -> `outgoing`
+- `blocks` on the `target` side -> `incoming`
+
+Whenever possible, the read-side builder should resolve persisted locators into the app's existing
+shared `TaskRef` semantics for rendering and navigation.
+
+If resolution fails, it should keep the raw locator for fallback display instead of dropping the row.
+
+### Task resolution policy
+
+This is one of the highest-risk read-side areas.
+
+The builder must never silently guess a task from a weak locator.
+
+Rules:
+- canonical identity always wins:
+ - `locator.canonicalId`
+ - then `refKind = 'canonical'`
+- display-form resolution is allowed only when it resolves to **exactly one** candidate in team scope
+- if multiple candidates share the same display-like ref, mark the row `resolution = 'ambiguous'`
+ and keep only the raw locator
+- if no candidate matches, mark the row `resolution = 'unresolved'`
+- if the best unique candidate exists only in deleted tasks, keep `taskRef` but mark
+ `resolution = 'deleted'`
+- never drop a row only because the task cannot be resolved to a live `TaskRef`
+- renderer navigation should rely on both `taskRef` and `resolution`
+- in v1, rows with `resolution = 'deleted' | 'unresolved' | 'ambiguous'` should render as
+ non-primary navigation targets even if a fallback `taskRef` exists for label purposes
+
+Lookup scope:
+- build the lookup from both active tasks and deleted tasks
+- deleted tasks are needed mainly for:
+ - historical relationship rows
+ - lifecycle/action rows targeting tasks that were later deleted
+ - peer-task rendering for old `task_link` / `task_unlink` history
+
+Anti-guessing rule:
+- do not use `Map` for display-id resolution
+- display-like refs must resolve through a candidate set, not `last wins`
+- if an `unknown` ref could be both a canonical-looking id and a display-like id, prefer exact
+ canonical-id lookup first, then unique display resolution, otherwise stay unresolved
+
+This policy should explicitly reuse existing shared task-identity rules where possible:
+- `looksLikeCanonicalTaskId(...)`
+- `getTaskDisplayId(...)`
+
+---
+
+## UI Structure
+
+In the task popup, the current `Execution Logs` section should become a composed panel:
+
+- `Task Activity`
+- `Execution Sessions`
+
+Target end state:
+- outer collapsible title = `Task Logs`
+- inner subsections = `Task Activity` and `Execution Sessions`
+
+For rollout stability, the outer collapsible title may temporarily remain `Execution Logs`,
+but the plan target should still be `Task Logs`.
+
+Inside that block, the composed content should clearly separate:
+- `Task Activity`
+- `Execution Sessions`
+
+This preserves user familiarity while still introducing the new model cleanly.
+
+### Task Activity
+
+New feed based only on explicit `boardTaskLinks` plus message-level `boardTaskToolActions`
+
+Shows:
+- lifecycle events
+- execution-linked activity
+- related board actions on this task
+
+This section complements `Workflow History`, not replaces it:
+- `Workflow History` remains the authoritative board-state timeline
+- `Task Activity` becomes the runtime provenance feed
+
+Empty-state guidance:
+- if no explicit activity exists for a task, render an explicit empty state instead of silently collapsing the section
+- the copy should explain that older sessions may still be available below in `Execution Sessions`
+
+Resolution display guidance:
+- `resolution = 'active'`
+ - render normal task label/navigation behavior
+- `resolution = 'deleted'`
+ - render deleted-state badge or muted label
+ - do not present it as a normal clickable live-task target in v1
+- `resolution = 'unresolved' | 'ambiguous'`
+ - render raw locator fallback
+ - avoid deep-link navigation because the target identity is not reliable
+
+### Execution Sessions
+
+Keep the current session-based block, powered by the existing `MemberLogsTab`
+
+Purpose:
+- full transcript viewing
+- current previews
+- chunk filtering
+- session-level exploration
+
+This block should be clearly treated as **legacy/session-centric**, not the new source of truth for task activity.
+
+Important UI rule:
+- execution-specific polling affordances such as `Updating...` / `Online` belong to the `Execution Sessions` subsection only
+- they should not be used as the loading or freshness indicator for the whole `Task Logs` panel
+
+---
+
+## Why We Are Not Replacing the Old Block
+
+The current execution-log UI is useful, but it is solving a different problem:
+
+- it groups by session
+- it sorts by work-interval overlap
+- it filters chunks by persisted work intervals
+
+That is good for execution sessions, but not enough for task activity provenance.
+
+Trying to make one model serve both purposes creates:
+- misleading activity feeds
+- hidden related actions from other actors
+- more heuristics
+- harder maintenance
+
+So the correct design is **parallel, not replacement**.
+
+---
+
+## Tool Classification
+
+All tool names in this section refer to the **canonical bare board-tool name** after `agent-teams` MCP name normalization.
+
+### Lifecycle
+
+These create `linkKind = 'lifecycle'`:
+
+- `task_start`
+- `task_complete`
+- `task_set_status`
+- `review_start`
+- `review_approve`
+- `review_request_changes`
+
+### Board actions
+
+These create `linkKind = 'board_action'`:
+
+- `task_add_comment`
+- `task_get_comment`
+- `task_set_owner`
+- `task_attach_file`
+- `task_attach_comment_file`
+- `task_link`
+- `task_unlink`
+- `task_set_clarification`
+- `review_request`
+
+### Low-signal reads
+
+These are still explicit links, but may be visually muted or collapsible:
+
+- `task_get`
+
+### Ignored in v1
+
+- `task_create`
+- `task_create_from_message`
+- `task_list`
+- `task_briefing`
+- `member_briefing`
+- broad process/message tools without explicit `taskId`
+
+---
+
+## Execution State Rules
+
+The runtime must not keep a naive single `currentTask`.
+
+Instead it should keep an execution scope per actor:
+
+- key = `(sessionId, agentId ?? 'main')`
+
+State should track:
+- open active task set
+- active phase (`work` or `review`)
+- execution sequence number
+
+### Safe stamping rules
+
+- `0` active tasks
+ - no ambient execution link
+- `1` active task
+ - ambient execution link allowed
+- `2+` active tasks
+ - relation becomes `ambiguous`
+ - do not guess
+
+### Important rule
+
+For lifecycle messages:
+- stamp the link from the explicit tool target first
+- then update the actor execution state
+
+This ensures the lifecycle message itself is always linked to the correct task.
+
+---
+
+## Review Flow Rules
+
+Review is part of the board-task activity domain and must be modeled explicitly.
+
+### Rules
+
+- `review_request`
+ - `board_action`
+ - does **not** open review execution
+- `review_start`
+ - `lifecycle`
+ - may open review execution for the reviewer
+- `review_approve`
+ - `lifecycle`
+ - closes review execution
+- `review_request_changes`
+ - `lifecycle`
+ - closes review execution
+
+This keeps reviewer activity structurally visible instead of forcing it through status heuristics.
+
+---
+
+## Multi-Target Tools
+
+### `task_link` / `task_unlink`
+
+These should emit **two links** when both task references are resolved:
+
+- one with `taskArgumentSlot = 'taskId'`
+- one with `taskArgumentSlot = 'targetId'`
+
+This is the strongest reason to use `boardTaskLinks[]` instead of a single object.
+
+On the read side, the builder should combine sibling links from the same transcript message so each
+rendered row can expose:
+- the current task
+- the peer task
+- the relationship perspective for the current task
+
+That avoids forcing renderer code to understand raw MCP input roles.
+
+The `BoardTaskToolActionV1.input.relationship` value plus the persisted `taskArgumentSlot` should be
+enough for the builder to derive relationship direction without re-reading task files.
+
+---
+
+## Edge Cases
+
+### Another actor updates a task
+
+Example:
+- Bob is actively working on task B
+- Bob calls `task_add_comment` on task A
+
+Expected result:
+- task A activity feed shows the event
+- task B can continue to show Bob's own execution session separately in the legacy block
+- event is marked as a related board action from another active task
+- it is **not** shown as execution of task A
+
+### Lead mixed stream
+
+In v1:
+- do not ambient-stamp lead free-text execution
+- do allow explicit lifecycle and board-action links from lead tool calls
+
+### Ambiguous execution state
+
+If the actor has multiple active tasks:
+- do not guess
+- stamp explicit target links only
+- use `relation = 'ambiguous'`
+
+### Idle actor
+
+If the actor is not actively executing any task but performs a task tool call:
+- use `relation = 'idle'`
+
+### Historical logs
+
+Old logs without `boardTaskLinks` remain supported through:
+- legacy execution sessions
+- existing fallback logic where still needed
+
+The new activity feed in v1 should use explicit links only.
+
+### Multi-target relationship actions
+
+For `task_link` / `task_unlink`:
+- the task popup for the `taskId` side should render the relationship from that task's perspective
+- the related task popup for the `targetId` side should render the mirrored relationship from the peer-task perspective
+- the UI label should make the relationship direction clear instead of rendering both rows identically
+
+---
+
+## Implementation Structure
+
+### `agent_teams_orchestrator`
+
+Create a dedicated feature area:
+
+- `src/services/boardTaskActivity/contract.ts`
+- `src/services/boardTaskActivity/BoardTaskToolInterpreter.ts`
+- `src/services/boardTaskActivity/BoardTaskExecutionReducer.ts`
+- `src/services/boardTaskActivity/BoardTaskTranscriptProjector.ts`
+- `src/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.ts`
+- `src/services/boardTaskActivity/QueryBoardTaskObserver.ts`
+
+Responsibilities:
+- inspect board MCP tool semantics
+- maintain actor execution state
+- produce `boardTaskLinks[]`
+- produce `boardTaskToolActions[]` where applicable
+- attach transcript-only task metadata before persistence
+
+Implementation note:
+- thread the internal carrier field through the runtime message helpers before `insertMessageChain(...)`
+- avoid computing task links late inside persistence from mutable global state
+
+### `claude_team`
+
+Create a separate task-log feature area:
+
+- `src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts`
+- `src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts`
+- `src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts`
+- `src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts`
+- `src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts`
+- `src/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.ts`
+- `src/main/ipc/teams.ts` - add a dedicated `getTaskActivity` handler
+- `src/main/ipc/handlers.ts` - register / remove the new handler with existing team IPC initialization
+
+Shared types:
+
+- `src/shared/types/team.ts` - add `BoardTaskActivityEntry` and related IPC-visible types
+- `src/shared/types/api.ts` - add `teams.getTaskActivity(...)`
+- `src/preload/constants/ipcChannels.ts` - add `TEAM_GET_TASK_ACTIVITY`
+- `src/preload/index.ts` - expose the new preload method
+- `src/renderer/api/httpClient.ts` - add browser-mode fallback for `getTaskActivity`
+
+Renderer:
+
+- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx`
+- `src/renderer/components/team/taskLogs/TaskActivitySection.tsx`
+- `src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx`
+- `src/renderer/components/team/taskLogs/taskActivityPresentation.ts`
+
+### API separation
+
+Do **not** overload the existing legacy API method.
+
+Keep:
+- `teams.getLogsForTask(...)` for legacy execution sessions
+
+Add:
+- `teams.getTaskActivity(teamName, taskId)` for the new explicit activity model
+
+This separation keeps the new model isolated from the old heuristic/session path.
+
+For the first rollout, this API can follow the same availability profile as the current
+task-log endpoints:
+- supported in Electron
+- browser-mode HTTP client can return `[]` with a warning, matching the current task-log API pattern
+
+### Contract discipline
+
+To keep both repos aligned without over-coupling them:
+
+- define JSON schemas for `BoardTaskLinkV1` and `BoardTaskToolActionV1`
+- mirror the TypeScript type locally in each repo
+- add golden fixtures for representative cases in both repos
+- keep transcript-contract mirror types main-process-only in `claude_team`
+- keep `BoardTaskActivityEntry` and other IPC-visible DTOs in shared preload/renderer types
+
+Parsing tolerance rules:
+- parse `boardTaskLinks` and `boardTaskToolActions` defensively and independently
+- if one link object is malformed, drop only that link, not the whole transcript message
+- if one action object is malformed, drop only that action, not the whole transcript message
+- if `schemaVersion` is unknown, skip that object family and keep the rest of the message readable
+- if a link references a `toolUseId` with no surviving action, the row may still be rendered from the
+ link alone
+- if an action survives but no links survive for its `toolUseId`, ignore the action for feed-building
+ and optionally emit a debug log
+
+This keeps the explicit feed resilient against partial writes, old transcripts, or future schema
+extensions that the current reader does not understand yet.
+
+Minimum fixture set:
+- same-task execution
+- one message with multiple board-task tool results joined by distinct `toolUseId`
+- lifecycle by another actor while active on a different task
+- board action by another actor while active on a different task
+- review start / review completion
+- task link dual-target emission
+- relationship row with derived peer task and relationship perspective
+- task relationship subtype payload
+- status / owner / clarification action payload
+- unresolved display-only task locator
+- display-id collision produces `resolution = 'ambiguous'`
+- deleted task locator produces `resolution = 'deleted'` without dropping the row
+- unknown refKind that looks canonical resolves by exact id before any display fallback
+- ambiguous actor context
+- legacy entry without explicit links
+
+---
+
+## Concrete Code Blueprint
+
+This section is intentionally implementation-oriented. The goal is to remove as much ambiguity as
+possible before coding starts.
+
+### `agent_teams_orchestrator` - exact touchpoints
+
+#### 1. Transcript contract types
+
+File:
+- `src/services/boardTaskActivity/contract.ts`
+- `src/types/logs.ts`
+
+Add:
+
+```ts
+export type BoardTaskLocator = {
+ ref: string
+ refKind: 'canonical' | 'display' | 'unknown'
+ canonicalId?: string
+}
+
+export type BoardTaskLinkV1 = {
+ schemaVersion: 1
+ task: BoardTaskLocator
+ taskArgumentSlot?: 'taskId' | 'targetId'
+ toolUseId?: string
+ linkKind: 'execution' | 'lifecycle' | 'board_action'
+ actorContext: {
+ relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'
+ activeTask?: BoardTaskLocator
+ activePhase?: 'work' | 'review'
+ activeExecutionSeq?: number
+ }
+}
+
+export type BoardTaskToolActionV1 = {
+ schemaVersion: 1
+ toolUseId: string
+ canonicalToolName: string
+ input?: {
+ status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
+ owner?: string | null
+ relationship?: 'blocked-by' | 'blocks' | 'related'
+ clarification?: 'lead' | 'user' | null
+ reviewer?: string
+ commentId?: string
+ }
+ resultRefs?: {
+ commentId?: string
+ attachmentId?: string
+ filename?: string
+ }
+}
+```
+
+Extend `TranscriptMessage` in `src/types/logs.ts` with:
+
+```ts
+boardTaskLinks?: BoardTaskLinkV1[]
+boardTaskToolActions?: BoardTaskToolActionV1[]
+```
+
+Preferred reusable carrier type:
+
+```ts
+export type BoardTaskCarrierFields = {
+ boardTaskLinks?: BoardTaskLinkV1[]
+ boardTaskToolActions?: BoardTaskToolActionV1[]
+}
+```
+
+Implementation preference:
+- prefer one shared `BoardTaskCarrierFields` mixin over repeating the same optional fields across
+ every helper and every runtime message type by hand
+- if the actual runtime message owner file can be updated cleanly, extend the owner types with this
+ mixin once
+- if the owner path is awkward or generated, use local intersection types at helper boundaries
+ instead of falling back to `any`
+- keep these carrier fields runtime-internal and transcript-oriented, not part of API/model payloads
+
+Preferred blast-radius-minimizing strategy:
+
+```ts
+type TaskAwareMessage = Message & BoardTaskCarrierFields
+type TaskAwareUserMessage = UserMessage & BoardTaskCarrierFields
+type TaskAwareAssistantMessage = AssistantMessage &
+ Pick
+```
+
+Use these local aliases first in:
+- `createUserMessage(...)`
+- `baseCreateAssistantMessage(...)`
+- `emitTaskAware(...)`
+- `insertMessageChain(...)`
+
+Why this is safer for v1:
+- it localizes type churn to the board-task feature path
+- it avoids blocking the whole rollout on the unresolved physical owner path for `types/message`
+- it reduces the chance of breaking unrelated call sites that only know about plain `Message`
+- it still keeps transcript persistence explicit and typed
+
+Only after the feature works end-to-end should we consider merging the mixin into the canonical
+runtime message owner types everywhere, and only if that cleanup actually reduces complexity.
+
+#### 2. Internal message carriers
+
+File:
+- `src/utils/messages.ts`
+
+Concrete changes:
+- introduce or import `BoardTaskCarrierFields`
+- extend `createUserMessage(...)` params with that mixin
+- extend the runtime `Message` / `UserMessage` / `AssistantMessage` type definitions with the same
+ mixin only if the actual owner path makes that straightforward
+- follow the actual import target used by `src/utils/messages.ts` for those runtime message types
+ instead of assuming the owner file path from memory
+- add those fields onto the returned runtime message object
+- extend the assistant message creation path with the same carrier mixin for ambient execution
+ stamping on assistant conversational messages
+- the likely concrete touchpoint is `baseCreateAssistantMessage(...)`, because assistant helpers
+ already funnel through it
+- ensure `normalizeMessages(...)` assistant split path preserves ambient `boardTaskLinks` on
+ conversational assistant text children instead of silently dropping them
+- in the user normalization path that rebuilds per-block messages, pass those fields through when
+ calling `createUserMessage(...)`
+
+Pseudo-shape:
+
+```ts
+export function createUserMessage({
+ ...,
+ boardTaskLinks,
+ boardTaskToolActions,
+}: {
+ ...
+} & BoardTaskCarrierFields): TaskAwareUserMessage {
+ return {
+ ...,
+ boardTaskLinks,
+ boardTaskToolActions,
+ }
+}
+```
+
+For assistant helpers, the concrete shape should be parallel:
+
+```ts
+function baseCreateAssistantMessage({
+ ...,
+ boardTaskLinks,
+}: {
+ ...
+ boardTaskLinks?: BoardTaskLinkV1[]
+}): TaskAwareAssistantMessage {
+ return {
+ ...,
+ boardTaskLinks,
+ }
+}
+```
+
+And in the normalization split path:
+
+```ts
+return {
+ ...createUserMessage({
+ content: [_],
+ ...,
+ boardTaskLinks: filteredBoardTaskLinksForBlock(message.boardTaskLinks, _),
+ boardTaskToolActions: filteredBoardTaskToolActionsForBlock(message.boardTaskToolActions, _),
+ }),
+ uuid: ...,
+}
+```
+
+Suggested helpers:
+
+```ts
+function filteredBoardTaskLinksForBlock(
+ links: BoardTaskLinkV1[] | undefined,
+ block: ContentBlockParam,
+): BoardTaskLinkV1[] | undefined {
+ if (!links?.length) return undefined
+ if (block.type === 'tool_result') {
+ const matching = links.filter(link => link.toolUseId === block.tool_use_id)
+ return matching.length > 0 ? matching : undefined
+ }
+ const ambient = links.filter(link => link.toolUseId === undefined)
+ return ambient.length > 0 ? ambient : undefined
+}
+
+function filteredBoardTaskToolActionsForBlock(
+ actions: BoardTaskToolActionV1[] | undefined,
+ block: ContentBlockParam,
+): BoardTaskToolActionV1[] | undefined {
+ if (!actions?.length) return undefined
+ if (block.type !== 'tool_result') return undefined
+ const matching = actions.filter(action => action.toolUseId === block.tool_use_id)
+ return matching.length > 0 ? matching : undefined
+}
+```
+
+Filtering rule for split messages:
+- if `_` is a `tool_result`, carry only links/actions whose `toolUseId` matches that block
+- if `_` is ordinary conversational content, carry only ambient execution links where `toolUseId` is absent
+- do not blindly copy the full arrays to every split child message
+
+Without this rule, one split `tool_result` child can silently inherit metadata that belongs to a
+different `tool_result` block from the same original message.
+
+Why here:
+- `normalizeMessagesForAPI(...)` rebuilds user messages
+- if the carrier is not passed through here, transcript metadata will silently disappear on
+ multi-block user messages
+- ordinary conversational task activity can also land on assistant messages, so the assistant
+ creation path must be able to carry `boardTaskLinks`
+- but the assistant split path should keep ambient execution links only on human-readable
+ conversational children, not on `tool_use` or thinking-only children
+
+#### 3. Central tool-name normalization
+
+Files:
+- `src/services/mcp/mcpStringUtils.ts`
+- `src/Tool.ts`
+
+Concrete rule:
+- do not add handwritten regexes for `mcp__agent-teams__...`
+- use `mcpInfoFromString(...)` and/or the same canonicalization semantics as `toolMatchesName(...)`
+
+Recommended helper in `BoardTaskToolInterpreter.ts`:
+
+```ts
+function canonicalizeBoardToolName(rawName: string): string | null {
+ const info = mcpInfoFromString(rawName)
+ if (!info?.toolName) {
+ return rawName.startsWith('task_') || rawName.startsWith('review_')
+ ? rawName
+ : null
+ }
+ const normalizedServer = info.serverName.replace(/[-_]+/g, '_')
+ if (normalizedServer !== 'agent_teams') return null
+ return info.toolName
+}
+```
+
+#### 4. Execution state store
+
+Files:
+- `src/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.ts`
+- `src/services/boardTaskActivity/BoardTaskExecutionReducer.ts`
+
+Suggested state:
+
+```ts
+type ActorExecutionState = {
+ openTasks: Map
+ appliedToolUseIds: Set
+}
+```
+
+Key the store by:
+
+```ts
+`${sessionId}:${agentId ?? 'main'}`
+```
+
+Reducer API:
+
+```ts
+applyLifecycle(
+ state: ActorExecutionState,
+ event: {
+ toolUseId: string
+ task: BoardTaskLocator
+ event:
+ | 'task_start'
+ | 'task_complete'
+ | 'task_set_status'
+ | 'review_start'
+ | 'review_approve'
+ | 'review_request_changes'
+ status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
+ }
+): ActorExecutionState
+```
+
+Important reducer rules:
+- no-op if `toolUseId` already applied
+- `task_start` and `task_set_status(in_progress)` open work execution
+- `task_complete` and `task_set_status(completed|pending|deleted)` close work execution
+- `review_start` opens review execution
+- `review_approve` and `review_request_changes` close review execution
+- never guess when `openTasks.size > 1`
+
+#### 5. Tool interpreter
+
+File:
+- `src/services/boardTaskActivity/BoardTaskToolInterpreter.ts`
+
+Recommended public API:
+
+```ts
+class BoardTaskToolInterpreter {
+ interpretToolResult(params: {
+ rawToolName: string
+ toolUseId: string
+ input: Record
+ result: unknown
+ }): {
+ canonicalToolName: string | null
+ links: BoardTaskLinkV1[]
+ actions: BoardTaskToolActionV1[]
+ lifecycleEvent?: LifecycleEvent
+ }
+}
+```
+
+Why `Interpreter` is the safer name:
+- this module does more than assign a category
+- it interprets raw tool name + input + result into domain semantics:
+ - canonical tool identity
+ - target task locator(s)
+ - emitted task links
+ - emitted tool actions
+ - optional lifecycle transitions
+- calling it a `Classifier` would understate responsibility and make semantic leakage into
+ neighboring modules more likely
+
+V1 source-of-truth table should follow the currently registered teammate-operational board tools
+from `agent-teams-controller/src/mcpToolCatalog.js`.
+
+Recommended v1 classification table:
+- `lifecycle`
+ - `task_start`
+ - `task_complete`
+ - `task_set_status`
+ - `review_start`
+ - `review_approve`
+ - `review_request_changes`
+- `board_action`
+ - `task_add_comment`
+ - `task_attach_comment_file`
+ - `task_attach_file`
+ - `task_get`
+ - `task_get_comment`
+ - `task_link`
+ - `task_set_clarification`
+ - `task_set_owner`
+ - `task_unlink`
+ - `review_request`
+- `ignore in v1 explicit feed`
+ - `member_briefing`
+ - `task_briefing`
+ - `task_create`
+ - `task_create_from_message`
+ - `task_list`
+- `out of domain for this feature`
+ - `message_send`
+ - all `cross_team_*`
+ - all `process_*`
+ - all `kanban_*`
+ - `team_launch`
+ - `team_stop`
+
+Guardrail:
+- add a unit test that loads the current task/review tool names from the controller source of truth
+ and fails if a new teammate-operational board tool appears without explicit interpreter mapping
+- this prevents the runtime semantics layer from silently drifting behind controller changes
+
+Concrete extraction rules:
+- task locator from `taskId`
+- second locator from `targetId` for relationship tools
+- `task_link` / `task_unlink` produce two links
+- ordinary single-target board tools should emit one link with `taskArgumentSlot = 'taskId'`
+- tool-derived links in v1 should have `linkKind = 'lifecycle'` or `linkKind = 'board_action'`, never `execution`
+- `review_request` is `board_action`, not lifecycle
+- do not copy long text fields from input/result into transcript metadata
+- capture stable ids only:
+ - `commentId`
+ - `attachmentId`
+ - `filename`
+
+Per-tool payload whitelist for `BoardTaskToolActionV1`:
+- `task_set_status`
+ - allow `input.status`
+- `task_set_owner`
+ - allow `input.owner`
+- `task_set_clarification`
+ - allow `input.clarification`
+- `review_request`
+ - allow `input.reviewer` when present
+- `task_link` / `task_unlink`
+ - allow `input.relationship`
+- `task_add_comment`
+ - allow `resultRefs.commentId`
+- `task_get_comment`
+ - allow `input.commentId`
+- `task_attach_file` / `task_attach_comment_file`
+ - allow `resultRefs.attachmentId`
+ - allow `resultRefs.filename`
+
+Everything else:
+- omit `input`
+- omit `resultRefs`
+
+This whitelist must live next to the interpreter logic, not in the UI builder.
+The renderer should never decide which raw tool payload fields were safe to persist.
+
+#### 6. Query integration point
+
+File:
+- `src/query.ts`
+
+This is the safest integration point because the loop already has:
+- `toolUseBlocks`
+- yielded `update.message`
+- normalized `tool_result` messages
+
+Implementation shape:
+
+```ts
+const boardTaskObserver = new QueryBoardTaskObserver(...)
+
+function emitTaskAware(message: Message): Message {
+ return boardTaskObserver.annotateMessage(message, {
+ sessionId: getSessionId(),
+ agentId: toolUseContext.agentId,
+ assistantToolUses: toolUseBlocks,
+ })
+}
+
+for await (const update of toolUpdates) {
+ if (update.message) {
+ const annotatedMessage = emitTaskAware(update.message)
+
+ yield annotatedMessage
+
+ toolResults.push(
+ ...normalizeMessagesForAPI([annotatedMessage], toolUseContext.options.tools).filter(
+ _ => _.type === 'user',
+ ),
+ )
+ }
+ ...
+}
+```
+
+Important integration rule:
+- do not annotate only the `getRemainingResults()` loop
+- route **all transcript-visible assistant/user yields in `query.ts`** through a small shared
+ helper like `emitTaskAware(...)`
+- that includes:
+ - streaming completed tool results
+ - remaining tool results
+ - synthetic missing tool-result messages on abort
+ - ordinary assistant conversational messages where ambient execution stamping is allowed
+- specifically verify these concrete yield sites in the current file:
+ - `yield result.message` from `streamingToolExecutor.getCompletedResults()`
+ - `yield update.message` from the main `toolUpdates` loop
+ - emitted messages from `yieldMissingToolResultBlocks(...)`
+- explicitly exclude these non-target paths from task annotation:
+ - `yield message` for `postCompactMessages`
+ - `yield { type: 'tombstone', ... }`
+ - tool-use summary and other non-conversational synthetic items
+
+Otherwise the implementation will correctly stamp board-task tool results but still miss ordinary
+assistant-side execution activity.
+
+`annotateMessage(...)` should:
+- for user `tool_result` messages:
+ - iterate all `tool_result` blocks inside the message
+ - pair each block by `tool_use_id` with the matching assistant `tool_use`
+ - interpret each result
+ - stamp `boardTaskLinks` and `boardTaskToolActions`
+ - apply lifecycle transitions after stamping pre-event actor context
+- for ordinary conversational messages:
+ - if exactly one active task exists for `(sessionId, agentId)`, stamp ambient execution link
+ - otherwise leave unstamped
+
+Pairing safety rules:
+- never create `boardTaskToolActions` or lifecycle transitions from a `tool_result` block unless its
+ `tool_use_id` resolves to a matching assistant `tool_use`
+- prefer pairing in this order:
+ 1. direct current-turn `assistantToolUses`
+ 2. `sourceToolAssistantUUID` + assistant-message lookup when available
+ 3. otherwise treat as unpaired and skip explicit board-task annotation for that block
+- if a `tool_result` block is synthetic interrupt/error recovery output, do not emit lifecycle
+ transitions even if the original tool name was a board-task tool
+- if the paired tool result is clearly unsuccessful, emit no lifecycle transition
+- missing pairing should be visible through debug diagnostics, not silently turned into guessed links
+
+Recommended observer helper:
+
+```ts
+function resolveToolUseForResultBlock(params: {
+ toolUseId: string
+ assistantToolUses: ToolUseBlock[]
+ sourceToolAssistantUUID?: string
+ assistantMessages: AssistantMessage[]
+}): ToolUseBlock | null {
+ return (
+ params.assistantToolUses.find(block => block.id === params.toolUseId) ??
+ findToolUseInAssistantMessage(params.assistantMessages, params.sourceToolAssistantUUID, params.toolUseId) ??
+ null
+ )
+}
+```
+
+#### 7. Persistence
+
+Files:
+- `src/utils/sessionStorage.ts`
+
+Concrete rule:
+- do **not** recompute task metadata in `insertMessageChain(...)`
+- only make sure the new optional fields are allowed by the type and survive the spread:
+
+```ts
+const transcriptMessage: TranscriptMessage = {
+ ...message,
+ ...
+}
+```
+
+That keeps persistence dumb and avoids late-state bugs.
+
+---
+
+### `claude_team` - exact touchpoints
+
+#### 1. Keep transcript-contract parsing local to the task-activity feature
+
+Recommended new file:
+- `src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts`
+
+Rationale:
+- do not bloat the generic JSONL parser with feature-specific activity semantics
+- keep explicit activity reading isolated from the existing session-centric parsing pipeline
+- the current generic parsed-message path does not expose all raw transcript metadata needed here,
+ especially `teamName` / `agentName`
+
+Suggested API:
+
+```ts
+class BoardTaskActivityTranscriptReader {
+ async readFile(filePath: string): Promise
+}
+```
+
+`RawTaskActivityMessage` should be local to the feature and include only:
+- `filePath`
+- `uuid`
+- `timestamp`
+- `sessionId`
+- `agentId`
+- `isSidechain`
+- `teamName`
+- `agentName`
+- `boardTaskLinks`
+- `boardTaskToolActions`
+- `sourceOrder`
+
+Implementation detail:
+- stream JSONL line-by-line, like the existing parser
+- skip entries without `uuid`
+- skip entries without `boardTaskLinks`
+- increment `sourceOrder` per accepted line so same-timestamp rows remain deterministic
+- no need to materialize full `ParsedMessage`
+
+Recommended performance guard for v1:
+- add a small per-file parse cache keyed by `(filePath, size, mtimeMs)`
+- return cloned cached `RawTaskActivityMessage[]` when the signature matches
+- dedupe concurrent reads with an in-flight map so repeated popup opens do not parse the same file twice
+- prefer mtime+size invalidation over TTL-only invalidation
+- keep the cache feature-local, similar in spirit to existing parse caches such as
+ `LeadSessionParseCache`, instead of coupling it to the legacy logs finder
+- when the discovered transcript file set changes for a team, clear cache entries for paths that
+ disappeared from the source set
+
+Suggested helper file:
+- `src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts`
+
+Suggested first-slice cache API:
+
+```ts
+type BoardTaskActivityFileSignature = {
+ size: number
+ mtimeMs: number
+}
+
+class BoardTaskActivityParseCache {
+ getIfFresh(filePath: string, signature: BoardTaskActivityFileSignature): RawTaskActivityMessage[] | null
+ getInFlight(filePath: string, signature: BoardTaskActivityFileSignature): Promise | null
+ setInFlight(filePath: string, signature: BoardTaskActivityFileSignature, promise: Promise): void
+ clearInFlight(filePath: string, signature: BoardTaskActivityFileSignature): void
+ set(filePath: string, signature: BoardTaskActivityFileSignature, rows: readonly RawTaskActivityMessage[]): void
+ clearForPath(filePath: string): void
+}
+```
+
+Why this matters:
+- the task popup may reopen repeatedly for the same task while the underlying JSONL files have not changed
+- without an mtime-aware cache, the new explicit feed would re-parse the same lead/subagent files on every open
+- this is a classic way to make a correct feature feel flaky or slow even when the domain model is sound
+
+#### 2. Main-side contract parsing
+
+Files:
+- `src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts`
+- `src/main/types/jsonl.ts` only if lightweight type guards help
+
+Recommended functions:
+
+```ts
+export function parseBoardTaskLinks(value: unknown): BoardTaskLinkV1[] | null
+export function parseBoardTaskToolActions(value: unknown): BoardTaskToolActionV1[] | null
+```
+
+Keep this contract parser feature-local and tolerant:
+- unknown fields ignored
+- invalid entries dropped, not fatal
+
+Suggested parser shape:
+
+```ts
+export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null {
+ if (!value || typeof value !== 'object') return null
+ const row = value as Record
+ const ref = typeof row.ref === 'string' ? row.ref.trim() : ''
+ const refKind =
+ row.refKind === 'canonical' || row.refKind === 'display' || row.refKind === 'unknown'
+ ? row.refKind
+ : null
+ const canonicalId =
+ typeof row.canonicalId === 'string' && row.canonicalId.trim().length > 0
+ ? row.canonicalId.trim()
+ : undefined
+ if (!ref || !refKind) return null
+ return { ref, refKind, canonicalId }
+}
+
+export function parseBoardTaskLinks(value: unknown): BoardTaskLinkV1[] | null {
+ if (!Array.isArray(value)) return null
+ const parsed = value
+ .map(item => {
+ if (!item || typeof item !== 'object') return null
+ const row = item as Record
+ if (row.schemaVersion !== 1) return null
+ const task = parseBoardTaskLocator(row.task)
+ if (!task) return null
+ const linkKind =
+ row.linkKind === 'execution' ||
+ row.linkKind === 'lifecycle' ||
+ row.linkKind === 'board_action'
+ ? row.linkKind
+ : null
+ const relation =
+ row.actorContext &&
+ typeof row.actorContext === 'object' &&
+ ['same_task', 'other_active_task', 'idle', 'ambiguous'].includes(
+ String((row.actorContext as Record).relation),
+ )
+ ? ((row.actorContext as Record).relation as
+ | 'same_task'
+ | 'other_active_task'
+ | 'idle'
+ | 'ambiguous')
+ : null
+ if (!linkKind || !relation) return null
+ return {
+ schemaVersion: 1,
+ task,
+ taskArgumentSlot:
+ row.taskArgumentSlot === 'taskId' || row.taskArgumentSlot === 'targetId'
+ ? row.taskArgumentSlot
+ : undefined,
+ toolUseId: typeof row.toolUseId === 'string' ? row.toolUseId : undefined,
+ linkKind,
+ actorContext: {
+ relation,
+ activeTask: parseBoardTaskLocator(
+ (row.actorContext as Record).activeTask,
+ ) ?? undefined,
+ activePhase:
+ (row.actorContext as Record).activePhase === 'work' ||
+ (row.actorContext as Record).activePhase === 'review'
+ ? ((row.actorContext as Record).activePhase as 'work' | 'review')
+ : undefined,
+ activeExecutionSeq:
+ typeof (row.actorContext as Record).activeExecutionSeq === 'number'
+ ? ((row.actorContext as Record).activeExecutionSeq as number)
+ : undefined,
+ },
+ } satisfies BoardTaskLinkV1
+ })
+ .filter((entry): entry is BoardTaskLinkV1 => entry !== null)
+ return parsed.length > 0 ? parsed : null
+}
+
+export function parseBoardTaskToolActions(value: unknown): BoardTaskToolActionV1[] | null {
+ if (!Array.isArray(value)) return null
+ const parsed = value
+ .map(item => {
+ if (!item || typeof item !== 'object') return null
+ const row = item as Record
+ if (row.schemaVersion !== 1) return null
+ const toolUseId = typeof row.toolUseId === 'string' ? row.toolUseId.trim() : ''
+ const canonicalToolName =
+ typeof row.canonicalToolName === 'string' ? row.canonicalToolName.trim() : ''
+ if (!toolUseId || !canonicalToolName) return null
+ return {
+ schemaVersion: 1,
+ toolUseId,
+ canonicalToolName,
+ } satisfies BoardTaskToolActionV1
+ })
+ .filter((entry): entry is BoardTaskToolActionV1 => entry !== null)
+ return parsed.length > 0 ? parsed : null
+}
+```
+
+Parser behavior rule:
+- do not throw for malformed per-object metadata
+- salvage valid siblings and continue reading
+- reserve throwing for true file-level I/O or invalid JSONL framing only
+
+#### 3. Task-activity builder
+
+Files:
+- `src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts`
+- `src/shared/types/team.ts`
+
+Add to shared IPC-visible types:
+
+```ts
+export interface BoardTaskActivityEntry {
+ id: string
+ timestamp: string
+ actor: { ... }
+ task: {
+ locator: BoardTaskLocator
+ taskRef?: TaskRef
+ resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
+ }
+ linkKind: 'execution' | 'lifecycle' | 'board_action'
+ actorContext: { ... }
+ action: {
+ canonicalToolName?: string
+ toolUseId?: string
+ category: ...
+ peerTask?: {
+ locator: BoardTaskLocator
+ taskRef?: TaskRef
+ resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
+ }
+ relationshipPerspective?: 'outgoing' | 'incoming' | 'symmetric'
+ details?: { ... }
+ }
+ source: {
+ messageUuid: string
+ filePath: string
+ }
+}
+```
+
+Concrete builder algorithm:
+
+```ts
+buildEntriesForTask(rawMessage, targetTaskId) {
+ const matchingLinks = rawMessage.boardTaskLinks.filter(link => matchesTarget(link.task, targetTaskId))
+ const actionsByToolUseId = buildActionMap(rawMessage.boardTaskToolActions ?? [])
+
+ return matchingLinks.map(link => {
+ const action = link.toolUseId ? actionsByToolUseId.get(link.toolUseId) : undefined
+ const siblingLinks = link.toolUseId
+ ? rawMessage.boardTaskLinks.filter(other => other.toolUseId === link.toolUseId)
+ : []
+ const peerLink = siblingLinks.find(other => !sameLocator(other.task, link.task))
+
+ return buildTaskActivityEntry(link, action, peerLink, rawMessage)
+ })
+}
+```
+
+Recommended action-map helper:
+
+```ts
+function buildActionMap(actions: BoardTaskToolActionV1[]): Map {
+ const map = new Map()
+ for (const action of actions) {
+ if (map.has(action.toolUseId)) {
+ logDebug('[BoardTaskActivityEntryBuilder] duplicate boardTaskToolAction toolUseId', {
+ toolUseId: action.toolUseId,
+ })
+ continue
+ }
+ map.set(action.toolUseId, action)
+ }
+ return map
+}
+```
+
+Dedupe rule:
+- do not use silent `last wins`
+- keep the first surviving action for a `toolUseId`
+- log duplicates in debug mode so broken writer-side invariants are visible during QA
+
+Builder simplification rule:
+- if `link.linkKind === 'execution'`, do not attempt to join an action object
+- `execution` rows in v1 are ambient-only and should be rendered without `BoardTaskToolActionV1`
+- only `lifecycle` and `board_action` links participate in `toolUseId -> action` joins
+
+Suggested locator-resolution helpers:
+
+```ts
+type ResolvedTaskHandle =
+ | { resolution: 'resolved' | 'deleted'; taskRef: TaskRef }
+ | { resolution: 'unresolved' | 'ambiguous' }
+
+function buildTaskLookup(
+ activeTasks: TeamTask[],
+ deletedTasks: TeamTask[],
+ teamName: string,
+): {
+ byId: Map
+ byDisplayId: Map>
+} {
+ const byId = new Map()
+ const byDisplayId = new Map<
+ string,
+ Array<{ resolution: 'resolved' | 'deleted'; taskRef: TaskRef }>
+ >()
+
+ const addTask = (task: TeamTask, resolution: 'resolved' | 'deleted') => {
+ const taskRef: TaskRef = {
+ taskId: task.id,
+ displayId: getTaskDisplayId(task),
+ teamName,
+ }
+
+ byId.set(task.id, { resolution, taskRef })
+
+ const key = taskRef.displayId.toLowerCase()
+ const bucket = byDisplayId.get(key) ?? []
+ bucket.push({ resolution, taskRef })
+ byDisplayId.set(key, bucket)
+ }
+
+ for (const task of activeTasks) addTask(task, 'active')
+ for (const task of deletedTasks) {
+ if (!byId.has(task.id)) addTask(task, 'deleted')
+ }
+
+ return { byId, byDisplayId }
+}
+
+function resolveLocator(
+ locator: BoardTaskLocator,
+ lookup: {
+ byId: Map
+ byDisplayId: Map>
+ },
+): ResolvedTaskHandle {
+ if (locator.canonicalId) {
+ return lookup.byId.get(locator.canonicalId) ?? { resolution: 'unresolved' }
+ }
+
+ if (locator.refKind === 'canonical') {
+ return lookup.byId.get(locator.ref) ?? { resolution: 'unresolved' }
+ }
+
+ if (locator.refKind === 'display') {
+ const candidates = lookup.byDisplayId.get(locator.ref.toLowerCase()) ?? []
+ if (candidates.length === 1) return candidates[0]
+ if (candidates.length > 1) return { resolution: 'ambiguous' }
+ return { resolution: 'unresolved' }
+ }
+
+ if (looksLikeCanonicalTaskId(locator.ref)) {
+ return lookup.byId.get(locator.ref) ?? { resolution: 'unresolved' }
+ }
+
+ const candidates = lookup.byDisplayId.get(locator.ref.toLowerCase()) ?? []
+ if (candidates.length === 1) return candidates[0]
+ if (candidates.length > 1) return { resolution: 'ambiguous' }
+ return { resolution: 'unresolved' }
+}
+```
+
+Matching rule for `getTaskActivity(teamName, taskId)`:
+- target matching should primarily compare against canonical `taskId`
+- if a link only has display-form identity, resolve it through the task lookup first
+- do not compare raw strings only
+- do not guess by display id when the lookup returns more than one candidate
+- do not drop a row solely because the target resolves to `deleted` or `unresolved`
+
+Suggested actor-resolution helper:
+
+```ts
+function resolveActivityActor(rawMessage: RawTaskActivityMessage): {
+ memberName?: string
+ role: 'member' | 'lead' | 'unknown'
+ sessionId: string
+ agentId?: string
+} {
+ if (rawMessage.agentName && rawMessage.agentName.trim().length > 0) {
+ return {
+ memberName: rawMessage.agentName.trim(),
+ role: rawMessage.isSidechain ? 'member' : 'lead',
+ sessionId: rawMessage.sessionId,
+ agentId: rawMessage.agentId,
+ }
+ }
+ return {
+ memberName: undefined,
+ role: rawMessage.isSidechain ? 'member' : 'unknown',
+ sessionId: rawMessage.sessionId,
+ agentId: rawMessage.agentId,
+ }
+}
+```
+
+Actor-resolution rule:
+- prefer explicit `agentName` from the raw transcript entry
+- use `isSidechain` only as a fallback hint for `role`
+- do not infer actor identity from task ownership or task history
+
+Stable ordering rule:
+- sort final `BoardTaskActivityEntry[]` by `timestamp ASC`
+- tie-break by `rawMessage.filePath`
+- then by `rawMessage.sourceOrder ASC`
+- then by `action.toolUseId ?? ''`
+- then by `id`
+
+This keeps the feed deterministic when multiple entries share the same timestamp or come from the
+same transcript message.
+
+#### 4. Dedicated service, not legacy finder reuse
+
+Files:
+- `src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts`
+- `src/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.ts`
+- `src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts`
+
+Suggested `BoardTaskActivityService` dependencies:
+- `TeamTranscriptSourceLocator`
+- `TeamTaskReader`
+- `BoardTaskActivityTranscriptReader`
+
+Suggested API:
+
+```ts
+class BoardTaskActivityService {
+ async getTaskActivity(teamName: string, taskId: string): Promise
+}
+```
+
+Concrete rule:
+- new service reads explicit links only
+- it must not call `findLogsForTask(...)` for inference
+- legacy block keeps using `TeamMemberLogsFinder`
+- task lookup for builder resolution should load both:
+ - `TeamTaskReader.getTasks(teamName)`
+ - `TeamTaskReader.getDeletedTasks(teamName)`
+- deleted tasks are part of history resolution, not an optional nice-to-have
+
+Concrete discovery rule:
+- do not make `BoardTaskActivityService` depend on `TeamMemberLogsFinder`
+- extract a small shared locator for:
+ - resolving `projectDir`
+ - current `leadSessionId`
+ - `sessionIds`
+ - enumerating lead and subagent transcript files
+- let the new explicit path depend on that lower-level discovery boundary directly
+
+Why:
+- `TeamMemberLogsFinder` is session-centric and attribution-heavy
+- the new explicit activity path does not need member-attribution heuristics
+- depending on the old finder would reintroduce the mixed-responsibility boundary we are trying to remove
+
+Suggested transcript-source locator shape:
+
+```ts
+type TeamTranscriptSourceContext = {
+ projectDir: string
+ leadSessionId?: string
+ sessionIds: string[]
+}
+
+class TeamTranscriptSourceLocator {
+ async getContext(teamName: string): Promise { ... }
+
+ async listTranscriptFiles(teamName: string): Promise {
+ const context = await this.getContext(teamName)
+ if (!context) return []
+
+ const files = new Set()
+ if (context.leadSessionId) {
+ files.add(path.join(context.projectDir, `${context.leadSessionId}.jsonl`))
+ }
+ for (const sessionId of context.sessionIds) {
+ const dir = path.join(context.projectDir, sessionId, 'subagents')
+ for (const file of await safeListAgentJsonlFiles(dir)) {
+ files.add(path.join(dir, file))
+ }
+ }
+ return [...files].sort()
+ }
+}
+```
+
+`safeListAgentJsonlFiles(...)` should mirror the existing subagent-file rules:
+- include `agent-*.jsonl`
+- exclude `agent-acompact*`
+
+Recommended main-process wiring:
+
+```ts
+// src/main/index.ts
+const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator()
+const taskActivityTranscriptReader = new BoardTaskActivityTranscriptReader()
+const taskActivityService = new BoardTaskActivityService(
+ teamTranscriptSourceLocator,
+ new TeamTaskReader(),
+ taskActivityTranscriptReader,
+)
+```
+
+Then thread the service through IPC bootstrap:
+
+```ts
+// src/main/ipc/handlers.ts
+export function initializeIpcHandlers(
+ registry: ServiceContextRegistry,
+ updater: UpdaterService,
+ sshManager: SshConnectionManager,
+ teamDataService: TeamDataService,
+ teamProvisioningService: TeamProvisioningService,
+ teamMemberLogsFinder: TeamMemberLogsFinder,
+ memberStatsComputer: MemberStatsComputer,
+ teammateToolTracker: TeammateToolTracker | undefined,
+ branchStatusService: BranchStatusService | undefined,
+ taskActivityService: BoardTaskActivityService | undefined,
+ ...
+): void {
+ initializeTeamHandlers(
+ teamDataService,
+ teamProvisioningService,
+ teamMemberLogsFinder,
+ memberStatsComputer,
+ teamBackupService,
+ teammateToolTracker,
+ branchStatusService,
+ taskActivityService,
+ )
+}
+```
+
+```ts
+// src/main/index.ts
+initializeIpcHandlers(
+ registry,
+ updater,
+ sshManager,
+ teamDataService,
+ teamProvisioningService,
+ teamMemberLogsFinder,
+ memberStatsComputer,
+ teammateToolTracker,
+ branchStatusService,
+ taskActivityService,
+ ...
+)
+```
+
+Service export note:
+- if `initializeIpcHandlers(...)` in `src/main/ipc/handlers.ts` continues importing service types from
+ `../services`, add the new service export to:
+ - `src/main/services/team/index.ts`
+ - `src/main/services/index.ts`
+- if you decide to import the new service type directly in `handlers.ts`, keep that decision local and
+ do not mix both import styles in the same patch
+
+```ts
+// src/main/ipc/teams.ts
+let taskActivityService: BoardTaskActivityService | null = null
+
+export function initializeTeamHandlers(
+ service: TeamDataService,
+ provisioningService: TeamProvisioningService,
+ logsFinder?: TeamMemberLogsFinder,
+ statsComputer?: MemberStatsComputer,
+ backupService?: TeamBackupService,
+ toolTracker?: TeammateToolTracker,
+ branchTracker?: BranchStatusService,
+ activityService?: BoardTaskActivityService,
+): void {
+ ...
+ taskActivityService = activityService ?? null
+}
+```
+
+```ts
+function getTaskActivityService(): BoardTaskActivityService {
+ if (!taskActivityService) {
+ throw new Error('Task activity service is not initialized')
+ }
+ return taskActivityService
+}
+```
+
+This keeps the new explicit path as a first-class service instead of constructing it ad hoc inside
+the IPC handler.
+
+#### 5. Implementation checkpoints before CP1
+
+These checks should happen before writing feature code.
+
+1. Resolve the real runtime owner for `Message` / `UserMessage` / `AssistantMessage`
+ - `src/utils/messages.ts` imports from `../types/message.js`
+ - the physical source file is not obvious from the current tree walk
+ - do not start patching helper signatures until the actual symbol owner is confirmed
+ - if necessary, use editor "Go to Definition" or TypeScript resolution tooling instead of guessing
+
+2. Enumerate every transcript-visible yield path in `src/query.ts`
+ - tool result updates
+ - assistant conversational updates
+ - synthetic missing tool-result recovery
+ - any other user/assistant message path that lands in transcript storage
+ - confirm all of them route through the planned annotation helper before enabling the feature
+
+3. Verify split/normalize paths in `src/utils/messages.ts`
+ - assistant split path must not duplicate ambient execution links onto every child
+ - thinking-only children must not inherit task metadata
+ - user tool-result children must retain only the links/actions that match the child block's `tool_use_id`
+
+4. Verify transcript discovery assumptions in `claude_team`
+ - `TeamTranscriptSourceLocator` should reuse the same lead/subagent file discovery rules as the legacy path
+ - subagent transcript enumeration must exclude `agent-acompact*`
+ - the first slice should not depend on worker-thread plumbing
+
+If any of these checks fail, stop and correct the plan before code changes continue.
+
+#### 6. IPC / preload / browser fallback
+
+Files:
+- `src/preload/constants/ipcChannels.ts`
+- `src/shared/types/api.ts`
+- `src/preload/index.ts`
+- `src/main/ipc/teams.ts`
+- `src/main/ipc/handlers.ts`
+- `src/renderer/api/httpClient.ts`
+
+Add:
+
+```ts
+export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity'
+```
+
+Shared API:
+
+```ts
+getTaskActivity: (teamName: string, taskId: string) => Promise
+```
+
+Main handler shape in `teams.ts`:
+
+```ts
+async function handleGetTaskActivity(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown,
+ taskId: unknown,
+): Promise> { ... }
+```
+
+Recommended first-slice handler:
+
+```ts
+async function handleGetTaskActivity(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown,
+ taskId: unknown,
+): Promise> {
+ const vTeam = validateTeamName(teamName)
+ if (!vTeam.valid) {
+ return { success: false, error: vTeam.error ?? 'Invalid teamName' }
+ }
+ const vTask = validateTaskId(taskId)
+ if (!vTask.valid) {
+ return { success: false, error: vTask.error ?? 'Invalid taskId' }
+ }
+ return wrapTeamHandler('getTaskActivity', () =>
+ getTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!),
+ )
+}
+```
+
+Recommended preload addition:
+
+```ts
+getTaskActivity: async (teamName: string, taskId: string) => {
+ return invokeIpcWithResult(
+ TEAM_GET_TASK_ACTIVITY,
+ teamName,
+ taskId,
+ )
+}
+```
+
+Important integration detail:
+- `initializeTeamHandlers(...)` should receive the new service or create/store it next to existing
+ `teamMemberLogsFinder`
+- `registerTeamHandlers(...)` should register `TEAM_GET_TASK_ACTIVITY`
+- `removeTeamHandlers(...)` should unregister it
+
+Concrete handler registration:
+
+```ts
+// registerTeamHandlers(...)
+ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity)
+```
+
+```ts
+// removeTeamHandlers(...)
+ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY)
+```
+
+Browser fallback in `HttpAPIClient` can mirror current task-log behavior:
+
+```ts
+getTaskActivity: async () => {
+ console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode')
+ return []
+}
+```
+
+#### 7. UI composition
+
+Files:
+- `src/renderer/components/team/dialogs/TaskDetailDialog.tsx`
+- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx`
+- `src/renderer/components/team/taskLogs/TaskActivitySection.tsx`
+- `src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx`
+
+Concrete change in `TaskDetailDialog.tsx`:
+- replace direct inline `MemberLogsTab` block with `TaskLogsPanel`
+
+Pseudo-shape:
+
+```tsx
+
+```
+
+`TaskLogsPanel` should internally render:
+- `TaskActivitySection`
+- `ExecutionSessionsSection`
+
+`ExecutionSessionsSection` should be a thin wrapper around the current `MemberLogsTab` props so the
+legacy block keeps its existing behavior and polling indicators.
+
+UI state rule:
+- `TaskActivitySection` should own its own loading and empty states
+- `ExecutionSessionsSection` should keep the current refreshing and online indicators
+- do not reuse `ExecutionSessionsSection` polling state as the header status for the whole `Task Logs` panel
+- fetch `Task Activity` and `Execution Sessions` independently so one slow path does not block the other
+
+Suggested panel skeleton:
+
+```tsx
+export function TaskLogsPanel(props: {
+ teamName: string
+ task: TeamTask
+ taskSince?: string
+ allowLeadExecutionPreview?: boolean
+ isLeadOwnedTask?: boolean
+}): React.JSX.Element {
+ const { teamName, task, taskSince, allowLeadExecutionPreview, isLeadOwnedTask } = props
+
+ return (
+
+
+
+
+ )
+}
+```
+
+Suggested `TaskActivitySection` fetch shape:
+
+```tsx
+const [entries, setEntries] = useState(null)
+const [error, setError] = useState(null)
+
+useEffect(() => {
+ let cancelled = false
+ setError(null)
+ setEntries(null)
+ void api.teams
+ .getTaskActivity(teamName, taskId)
+ .then(result => {
+ if (!cancelled) setEntries(result)
+ })
+ .catch(err => {
+ if (!cancelled) setError(err instanceof Error ? err.message : String(err))
+ })
+ return () => {
+ cancelled = true
+ }
+}, [teamName, taskId])
+```
+
+#### 8. Recommended tests
+
+`agent_teams_orchestrator`
+- interpreter unit tests for each board tool family
+- reducer tests for open/close/ambiguous transitions
+- observer tests for:
+ - single `tool_result`
+ - multiple `tool_result` blocks in one message
+ - ambient execution stamp
+ - duplicate `toolUseId` no-op
+
+`claude_team`
+- transcript reader tests for additive contract parsing
+- builder tests for:
+ - same-task execution
+ - external board action
+ - lifecycle with pre-event actor context
+ - `task_link` / `task_unlink` with derived `peerTask`
+ - display-id collision resolves to `ambiguous`, not first-match
+ - deleted peer task still renders a row with `resolution = 'deleted'`
+ - unresolved locator still renders fallback row without navigation
+- UI tests for:
+ - empty explicit activity + legacy sessions still visible
+ - `Task Activity` and `Execution Sessions` separated
+ - deleted or unresolved peer-task rows are visibly non-primary / non-navigable
+
+#### 9. Runtime diagnostics
+
+Add lightweight counters or debug logs around the new explicit path.
+
+Minimum writer-side diagnostics:
+- `board_task_activity.tool_result_paired`
+- `board_task_activity.tool_result_unpaired`
+- `board_task_activity.synthetic_tool_result_skipped`
+- `board_task_activity.lifecycle_emitted`
+- `board_task_activity.lifecycle_skipped_unsuccessful`
+- `board_task_activity.ambient_execution_emitted`
+- `board_task_activity.ambient_execution_skipped_ambiguous`
+
+Minimum read-side diagnostics:
+- `board_task_activity.link_parse_dropped`
+- `board_task_activity.action_parse_dropped`
+- `board_task_activity.duplicate_action_tool_use_id`
+- `board_task_activity.unresolved_locator`
+- `board_task_activity.ambiguous_locator`
+
+Rules:
+- keep diagnostics low-cardinality
+- never log full comment text, review prose, or arbitrary tool payloads
+- prefer counts and short identifiers over verbose blobs
+- debug logging is enough for v1 if metrics plumbing would slow the rollout, but the hook points
+ should still be explicit in code
+
+---
+
+## Rollout Plan
+
+### CP0 - contract and names are fixed
+
+- finalize `BoardTaskLinkV1`
+- finalize `BoardTaskToolActionV1`
+- finalize `toolUseId` join rules for links and actions
+- finalize the tool semantics table derived from `agent-teams-controller/src/mcpToolCatalog.js`
+- finalize naming across runtime contract, read model, and renderer
+- add JSON schema and fixture examples
+
+Pre-flight verification gate before leaving CP0:
+- confirm the runtime message type owner path used by `src/utils/messages.ts`
+- confirm the final transcript-discovery class name is `TeamTranscriptSourceLocator`
+- confirm `query.ts` annotate coverage list is complete
+
+### Rollout safety switches
+
+Keep the feature decomposed behind separate flags or equivalent runtime gates:
+- `boardTaskLinksWriteEnabled`
+ - enables writer-side transcript stamping only
+- `boardTaskActivityReadEnabled`
+ - enables the new `getTaskActivity(...)` read path only
+- `boardTaskActivityUiEnabled`
+ - enables the `Task Activity` subsection in the popup only
+
+Recommended staged activation:
+1. writer flag on in local/dev only
+2. read flag on after explicit transcripts are verified
+3. UI flag on after read-side QA passes
+
+Kill-switch rule:
+- any serious mismatch in transcript stamping should be recoverable by disabling only the write flag
+ without removing legacy `Execution Sessions`
+- any read-side performance or parsing issue should be recoverable by disabling only the read/UI flag
+ while keeping persisted transcripts intact
+- do not make rollout depend on a single all-or-nothing switch
+
+Shadow validation phase:
+- before exposing the new UI section broadly, run the writer + reader path in shadow mode
+- in shadow mode:
+ - write explicit transcript metadata
+ - build activity entries in the background or in targeted debug sessions
+ - compare obvious invariants:
+ - task activity rows exist for fresh lifecycle events
+ - no duplicate action rows per `toolUseId`
+ - no lifecycle rows emitted from synthetic interrupt tool results
+ - keep the user-facing UI hidden until these checks are stable
+
+### CP1 - writer-side explicit links
+
+- add `boardTaskLinks?: BoardTaskLinkV1[]` to transcript messages
+- add `boardTaskToolActions?: BoardTaskToolActionV1[]` to transcript messages where applicable
+- implement runtime tool inspection
+- implement actor execution state
+- stamp only explicit/safe links
+
+### CP2 - read-side activity feed
+
+- parse explicit transcript task metadata in `claude_team`
+- build `BoardTaskActivityEntry`
+- expose `getTaskActivity(teamName, taskId)`
+- keep `getLogsForTask(...)` unchanged for the legacy block
+
+Do not block the first slice on worker-thread support for the new feed.
+
+Do not route the new explicit activity query through the existing `getLogsForTask(...)` worker and
+fallback path. Keep it as a separate read path in v1 so the explicit model stays isolated from the
+legacy heuristic/session pipeline.
+
+If profiling later shows that explicit-link scanning is still expensive, add worker support as a
+follow-up slice instead of mixing that concern into the first correctness rollout.
+
+### CP3 - UI integration
+
+- replace direct `MemberLogsTab` usage in task popup with a composed panel
+- outer title: `Task Logs`
+- `Task Activity`
+- `Execution Sessions`
+
+### CP4 - display policy tuning
+
+- map semantic activity entries to renderer labels/badges
+- mute noisy read actions like `task_get`, especially same-task reads
+- improve labels for lifecycle and cross-task actions
+- add manual QA on real team sessions
+
+---
+
+## Definition of Done
+
+- Task popup shows **two clearly separated sections**:
+ - `Task Activity`
+ - `Execution Sessions`
+- A task can show actions from a different actor working on another task, without mislabeling them as execution of the target task
+- Review actions appear correctly in task activity
+- Multi-target tools can link to multiple tasks
+- Ambiguous actor state never triggers guessing
+- Existing execution-session viewing still works
+- Old logs remain readable
+- New logs gain explicit structural task linkage
+- Locator collisions never silently pick an arbitrary task
+- Deleted or unresolved peer tasks do not disappear from task activity history
+- `pnpm typecheck` passes in affected repos
+- targeted tests pass for:
+ - lifecycle events
+ - direct board actions
+ - other-active-task actor actions
+ - review flow
+ - multi-target tools
+ - ambiguous actor state
+ - explicit-link-only feed behavior in v1
+ - unmatched `tool_result` blocks do not create guessed links
+ - synthetic interrupt tool results do not create lifecycle rows
+
+---
+
+## Top 3 Remaining Implementation Risks
+
+- **1. Carrier propagation drift in `agent_teams_orchestrator`** - `🎯 9 🛡️ 8 🧠 8` - roughly `180-320` lines of careful edits.
+ Risk:
+ one message path in `src/utils/messages.ts` or `src/query.ts` forgets to keep or filter `boardTaskLinks` / `boardTaskToolActions`, which creates silent gaps or duplication.
+
+- **2. Partial annotate coverage in `src/query.ts`** - `🎯 8 🛡️ 8 🧠 7` - roughly `120-220` lines.
+ Risk:
+ only tool-result updates go through `emitTaskAware(...)`, while other transcript-visible assistant or user yields bypass the helper and lose ambient execution links.
+
+- **3. Read-side overcoupling to legacy discovery** - `🎯 9 🛡️ 9 🧠 5` - roughly `80-160` lines.
+ Risk:
+ the new explicit feed accidentally reuses `TeamMemberLogsFinder` logic and reintroduces heuristic/session coupling. Keeping `TeamTranscriptSourceLocator` separate avoids this.
+
+---
+
+## Manual QA Checklist
+
+- Start task A, produce normal execution logs - activity shows execution entries for A
+- While on task A, comment on task B - task B shows related board action, task A does not lose execution state
+- Request review on task A - task A shows board action
+- Start review on task A - task A shows lifecycle review event
+- Approve or request changes on task A - task A shows lifecycle completion event
+- Link task A to task B - both task activity feeds reflect the relationship action appropriately
+- Change owner / status / clarification on task A - task activity row renders without parsing free-text result output
+- Open a historical task without explicit links - legacy execution sessions still load
+
+---
+
+## Final Architectural Summary
+
+We are explicitly separating:
+
+- **runtime truth** - `boardTaskLinks[]` + `boardTaskToolActions[]`
+- **UI activity model** - `BoardTaskActivityEntry`
+- **legacy session browsing** - `Execution Sessions`
+
+This avoids:
+- overloading one contract with UI concerns
+- overloading one UI block with two different meanings
+- growing the old heuristic session finder into an even larger mixed-responsibility module
+
+This is the cleanest path that is:
+- reliable
+- understandable
+- scalable
+- compatible with the current codebase
diff --git a/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md b/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md
new file mode 100644
index 00000000..35be3ab2
--- /dev/null
+++ b/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md
@@ -0,0 +1,1768 @@
+# Iteration 08 - Exact Task Logs Reusing Existing Execution Renderer
+
+> Historical note
+> This document captures the planned scope and architecture at iteration time.
+> It is not the source of truth for the final runtime contract.
+
+This iteration adds a new **Exact Task Logs** subsection under task logs and intentionally reuses the existing execution-log renderer that already works well in the app.
+
+The goal is **not** to invent a new log UI.
+
+The real problem was never the renderer. The real problem was that the old task log discovery path was:
+- session-centric
+- heuristic-heavy
+- not strict enough about what truly belongs to a task
+
+The new explicit board-task linkage from iteration 07 already solved the **selection** problem.
+This iteration uses that explicit linkage to feed a **task-scoped transcript slice** into the existing execution renderer.
+
+That means:
+- keep `Task Activity` as the compact, explicit summary feed
+- add `Exact Task Logs` that visually looks like the current rich logs/execution cards
+- keep `Execution Sessions` as a separate legacy/session-centric block
+
+---
+
+## Decision Record
+
+### Top 3 options
+
+1. **Reuse the existing execution renderer, but feed it a new explicit task-scoped filtered message slice** - `🎯 10 🛡️ 9 🧠 6` - примерно `550-950` строк
+ This is the chosen direction.
+
+2. **Keep `Task Activity` only as summary, and add inline tool-details drawers per row** - `🎯 8 🛡️ 9 🧠 5` - примерно `350-650` строк
+ Simpler, but still not the same browsing experience the user already likes.
+
+3. **Build a new custom task log renderer from scratch** - `🎯 3 🛡️ 5 🧠 9` - примерно `900-1600` строк
+ Rejected. This is a bicycle. It is slower, riskier, and likely worse than the existing renderer.
+
+### Chosen direction
+
+- Keep `Task Activity` as the compact explicit summary
+- Add `Exact Task Logs`
+- Render `Exact Task Logs` using the same existing execution-log rendering pipeline
+- Build a new explicit task-scoped message-selection layer
+- Reuse renderer primitives only, not legacy session-browsing containers
+- Do **not** reuse the old heuristic session-finding logic as the source of truth
+
+### Why this is the right direction
+
+- The renderer already solves:
+ - tool call cards
+ - tool-result pairing
+ - text output display
+ - expandable items
+ - ordering and visual hierarchy
+- The existing UX is already liked by the user
+- Reusing the renderer lowers design risk
+- The new explicit metadata gives us a reliable source for task scoping
+
+The correct architecture is:
+- **reuse the renderer**
+- **replace the selection logic**
+
+Not:
+- reuse the old selection logic
+- or rewrite the renderer
+
+---
+
+## Core UX Goal
+
+Inside the task popup:
+
+1. `Task Activity`
+ - short explicit summary rows
+ - compact semantic view
+
+2. `Exact Task Logs`
+ - rich task-scoped transcript rendering
+ - same visual style as the current logs/execution UI
+ - exact tools, outputs, and grouped items
+
+3. `Execution Sessions`
+ - current legacy/session-centric browser
+ - still useful for exploration
+ - no longer treated as the primary truth for task scoping
+
+This gives users:
+- a fast summary
+- exact readable logs
+- a fallback exploration view
+
+---
+
+## Important Clarification: Which Renderer We Actually Reuse
+
+The correct renderer to reuse is **not** `CliLogsRichView`.
+
+`CliLogsRichView` is for:
+- stream-json CLI tails
+- provisioning / live runtime logs
+
+It expects a different source model.
+
+The renderer path that matches the desired UX in task/session views is:
+
+- `MemberExecutionLog`
+- `transformChunksToConversation(...)`
+- `enhanceAIGroup(...)`
+- `DisplayItemList`
+- `LastOutputDisplay`
+
+That is the execution/session renderer family the user is referring to.
+
+So the plan is:
+- **reuse the execution renderer path**
+- **not** the CLI stream-json renderer path
+
+---
+
+## Main Architectural Insight
+
+The new exact log view must reuse the old renderer **without reintroducing old selection bugs**.
+
+That means we cannot simply:
+- ask `TeamMemberLogsFinder` for sessions
+- reuse `MemberLogsTab`
+- or render whole sessions again
+
+We also should **not** blindly render entire AI response groups from the transcript.
+
+Why:
+- the same AI response can contain both relevant and unrelated tools
+- if we render the entire unfiltered group, we leak unrelated actions back into the task view
+- that would partially recreate the same problem we just solved
+
+So the right architecture is:
+
+1. Find exact task-linked source refs using explicit metadata
+2. Resolve those refs into message-level anchors
+3. Build a **filtered transcript slice** that contains only task-relevant messages/blocks
+4. Convert that filtered slice into `EnhancedChunk[]`
+5. Render with the existing execution renderer
+
+The renderer stays the same.
+The message-selection layer becomes explicit and strict.
+
+---
+
+## Scope
+
+### Goals
+
+- Add `Exact Task Logs` under `Task Logs`
+- Reuse the current execution renderer style
+- Build exact logs only from explicit task-linked transcript metadata
+- Support:
+ - board-task tools
+ - lifecycle rows
+ - explicit board actions
+ - ambient execution text/output already linked to the task
+- Avoid showing unrelated tools from the same session/AI response
+
+### Non-Goals
+
+- Replacing `Task Activity`
+- Deleting `Execution Sessions`
+- Retroactively fixing all historical logs without explicit metadata
+- Reusing heuristic session overlap as primary selection
+- Building a brand-new renderer
+
+---
+
+## Key Product Rules
+
+### Rule 1 - `Task Activity` stays
+
+`Task Activity` remains the compact summary feed.
+
+It is still valuable because:
+- it is fast to scan
+- it shows actor/task relation cleanly
+- it keeps the event-level summary readable
+
+### Rule 2 - `Exact Task Logs` is the readable drill-down
+
+`Exact Task Logs` is where users read the actual tool/output flow.
+
+It should look and feel like the existing execution/log UI.
+
+### Rule 3 - `Execution Sessions` remains legacy
+
+`Execution Sessions` still exists because:
+- it is useful for broad exploration
+- it has previews and session browsing
+- it can still show context the exact feed intentionally omits
+
+But it is no longer the primary source for task scoping.
+
+---
+
+## Naming Decisions
+
+### UI names
+
+Use:
+
+- outer section: `Task Logs`
+- subsection 1: `Task Activity`
+- subsection 2: `Exact Task Logs`
+- subsection 3: `Execution Sessions`
+
+This naming is explicit and easy to understand:
+- summary
+- exact logs
+- session browser
+
+### Service names
+
+Use:
+
+- `BoardTaskActivityRecordSource`
+- `BoardTaskExactLogsService`
+- `BoardTaskExactLogSummarySelector`
+- `BoardTaskExactLogDetailSelector`
+- `BoardTaskExactLogChunkBuilder`
+
+### Shared DTO names
+
+Use:
+
+- `BoardTaskExactLogSummary`
+- `BoardTaskExactLogDetail`
+- `BoardTaskExactLogActor`
+- `BoardTaskExactLogSource`
+
+### Why this naming
+
+- `Exact Task Logs` is user-facing and immediately understandable
+- `BoardTaskActivityRecordSource` is more honest than `...Service` because this layer only supplies internal records
+- `BoardTaskExactLogsService` is specific enough to avoid mixing with legacy task logs
+- `Summary` + `Detail` is better than a single eager `Bundle` DTO because the renderer should load heavy exact details lazily
+
+---
+
+## Layered Design
+
+This slice must preserve separation of concerns.
+
+### 1. Explicit activity source layer
+
+Responsibility:
+- read explicit task-linked transcript metadata
+- produce internal task activity records
+
+Suggested main-only type:
+
+```ts
+type BoardTaskActivityRecord = {
+ timestamp: string
+ task: {
+ locator: BoardTaskLocator
+ resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
+ taskId?: string
+ displayId?: string
+ }
+ linkKind: 'execution' | 'lifecycle' | 'board_action'
+ targetRole: 'subject' | 'related'
+ actor: {
+ memberName?: string
+ role: 'member' | 'lead' | 'unknown'
+ sessionId: string
+ agentId?: string
+ isSidechain: boolean
+ }
+ actorContext: {
+ relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'
+ activeTask?: BoardTaskLocator
+ activePhase?: 'work' | 'review'
+ activeExecutionSeq?: number
+ }
+ action?: ParsedBoardTaskToolAction
+ source: {
+ filePath: string
+ messageUuid: string
+ toolUseId?: string
+ sourceOrder: number
+ }
+}
+```
+
+This is **main-only** and not an IPC DTO.
+
+Why this shape is better than `taskId: string`:
+
+- it preserves unresolved and deleted states
+- it avoids forcing early loss of locator semantics
+- it lets both summary and exact-log readers consume the same lower-level record source
+
+### 2. Exact-log summary selection layer
+
+Responsibility:
+- start from explicit activity records
+- build lightweight exact-log summaries
+- never parse transcript messages
+
+This is the most important new layer in iteration 08 because it keeps initial popup load cheap and removes transcript parsing from the summary path entirely.
+
+### 3. Exact-log detail selection layer
+
+Responsibility:
+- start from one summary + explicit activity records
+- parse only the referenced transcript messages
+- build one filtered task-scoped message slice for one requested exact detail
+
+### 4. Chunk-building layer
+
+Responsibility:
+- turn the filtered message slice into `EnhancedChunk[]`
+- keep the existing execution renderer happy
+
+### 5. UI rendering layer
+
+Responsibility:
+- render exact bundle details with the current execution renderer
+- not decide task membership
+
+---
+
+## Why We Need an Internal Record Layer First
+
+It is tempting to let `BoardTaskExactLogsService` depend directly on `BoardTaskActivityEntry`.
+
+That would be simpler in the short term, but it is the wrong dependency direction.
+
+`BoardTaskActivityEntry` is a shared UI-facing DTO.
+`Exact Task Logs` needs a lower-level source model.
+
+So the better architecture is:
+
+- `BoardTaskActivityRecordSource`
+ - main-only
+ - internal source of explicit task-linked facts
+
+- `BoardTaskActivityService`
+ - maps records -> `BoardTaskActivityEntry`
+
+- `BoardTaskExactLogsService`
+ - maps records -> lightweight exact-log summaries
+
+- `BoardTaskExactLogDetailService`
+ - maps one exact summary + parsed transcript -> one renderable exact detail
+
+This avoids coupling a new main-side service to a renderer DTO.
+
+This is a strong SRP / DIP move and worth doing now.
+
+### Critical reuse boundary
+
+The new exact path must **not** introduce a second competing low-level reader for board-task transcript metadata.
+
+That means:
+
+- `BoardTaskActivityTranscriptReader` remains the single owner of:
+ - `boardTaskLinks[]` parsing
+ - `boardTaskToolActions[]` parsing
+ - file-level metadata parse caching for explicit board-task transcript metadata
+- `BoardTaskActivityRecordSource` is extracted from the current summary path and becomes the single owner of:
+ - transcript metadata discovery
+ - task lookup and target-task filtering
+ - resolved internal activity records
+- all of:
+ - `BoardTaskActivityService`
+ - `BoardTaskExactLogsService`
+ - `BoardTaskExactLogDetailService`
+ depend on the same `BoardTaskActivityRecordSource`
+
+This is the desired dependency graph:
+
+```ts
+BoardTaskActivityTranscriptReader
+ -> BoardTaskActivityRecordSource
+ -> BoardTaskActivityService
+ -> BoardTaskExactLogsService
+ -> BoardTaskExactLogDetailService
+```
+
+This is explicitly **not** the desired graph:
+
+```ts
+BoardTaskActivityTranscriptReader -> BoardTaskActivityService
+parseBoardTaskLinks again elsewhere -> BoardTaskExactLogsService
+```
+
+Why this matters:
+
+- summary and exact views must agree on what explicit task-linked records exist
+- task-resolution behavior must not drift between two separate low-level readers
+- metadata parsing bugs must be fixed once
+- caches should stay shared where possible
+
+So iteration 08 should extract and reuse the existing explicit-record path.
+It should not create another parallel JSONL-metadata reader just for exact logs.
+
+---
+
+## Data Flow
+
+### End-to-end flow
+
+1. Renderer asks for exact task logs:
+
+```ts
+api.teams.getTaskExactLogSummaries(teamName, taskId)
+```
+
+2. IPC calls:
+
+```ts
+BoardTaskExactLogsService.getTaskExactLogSummaries(teamName, taskId)
+```
+
+3. Service gets:
+- active + deleted tasks from `TeamTaskReader`
+- activity records from `BoardTaskActivityRecordSource`
+
+4. Service derives exact-log summaries **from activity records only**
+
+5. Renderer shows exact-log summary cards first
+
+6. On expand, renderer asks for one exact detail:
+
+```ts
+api.teams.getTaskExactLogDetail(teamName, taskId, exactLogId, sourceGeneration)
+```
+
+7. Detail service:
+- reloads the matching explicit summary anchor
+- derives the minimal referenced file set for that one summary
+- parses only those transcript files into strict `ParsedMessage[]`
+- builds one filtered bundle slice
+- converts it into `EnhancedChunk[]`
+
+8. Renderer reuses `MemberExecutionLog`
+
+---
+
+## New Shared DTOs
+
+### IPC DTOs
+
+```ts
+type BoardTaskExactLogActor = {
+ memberName?: string
+ role: 'member' | 'lead' | 'unknown'
+ sessionId: string
+ agentId?: string
+ isSidechain: boolean
+}
+
+type BoardTaskExactLogSource = {
+ filePath: string
+ messageUuid: string
+ toolUseId?: string
+ sourceOrder: number
+}
+
+type BoardTaskExactLogSummary =
+ {
+ id: string
+ timestamp: string
+ actor: BoardTaskExactLogActor
+ source: BoardTaskExactLogSource
+ linkKinds: ('execution' | 'lifecycle' | 'board_action')[]
+ } & (
+ | { canLoadDetail: true; sourceGeneration: string }
+ | { canLoadDetail: false }
+ )
+
+type BoardTaskExactLogDetail = {
+ id: string
+ chunks: EnhancedChunk[]
+}
+```
+
+### Why summaries + lazy detail is the safer v1 design
+
+Repo-local finding:
+
+- `Execution Sessions` already uses a lazy expand-to-load-details interaction model
+- `EnhancedChunk[]` is an accepted IPC shape in this app
+- but returning `EnhancedChunk[]` eagerly for every exact bundle would be materially heavier than the current execution-session path
+
+So the safer v1 direction is:
+
+- initial load -> lightweight `BoardTaskExactLogSummary[]`
+- expand one row -> fetch one `BoardTaskExactLogDetail`
+
+This keeps:
+
+- initial popup payload smaller
+- refresh cost lower
+- parity with the existing interaction model the user already likes
+
+### Why `canLoadDetail` is better than `hasRenderableDetail`
+
+Summary stage no longer parses transcript content.
+
+That is a feature, not a limitation:
+
+- it keeps summary load cheap
+- it prevents summary-stage parser drift
+- it avoids lying with overconfident renderability claims
+
+So the summary flag should be capability-oriented:
+
+- `canLoadDetail = true` means the app has enough explicit anchor/source information to attempt detail loading
+- it does **not** guarantee that strict detail reconstruction will succeed
+- if `canLoadDetail = false`, the summary must not carry a meaningless `sourceGeneration`
+
+If detail later fails because the transcript row is malformed or missing, returning `missing` is still correct.
+
+### Source-generation coherence contract
+
+Lazy summaries + detail introduce one real risk:
+
+- summaries are loaded at time `T1`
+- transcript files change
+- detail is requested at time `T2`
+- the same `exactLogId` may now refer to a different filtered slice or to nothing at all
+
+So exact logs need an explicit coherence token.
+
+Preferred response shape:
+
+```ts
+type BoardTaskExactLogSummariesResponse = {
+ items: BoardTaskExactLogSummary[]
+}
+```
+
+Preferred detail result shape:
+
+```ts
+type BoardTaskExactLogDetailResult =
+ | { status: 'ok'; detail: BoardTaskExactLogDetail }
+ | { status: 'stale' }
+ | { status: 'missing' }
+```
+
+Why this is better than `null`:
+
+- renderer can distinguish stale summary data from a genuinely missing bundle
+- UI can refresh summaries automatically on `stale`
+- debugging is easier than with a single ambiguous nullish path
+
+### Why `sourceGeneration` belongs on each summary, not on the whole response
+
+Earlier drafts used one response-level generation token for the whole task.
+That is weaker.
+
+Why:
+
+- exact detail is loaded one bundle at a time
+- one task can reference many transcript files
+- one unrelated file mutation should not stale every open summary card
+
+So the safer contract is:
+
+- each `BoardTaskExactLogSummary` carries its own `sourceGeneration`
+- detail validates against that per-summary generation
+- the summaries response does not need a single coarse global generation token in v1
+
+This narrows stale invalidation to the actual files that back one summary.
+
+### Why not reuse global `TeamLogSourceTracker.logSourceGeneration` directly
+
+Repo-local finding:
+
+- `TeamLogSourceTracker` already computes a broad project-level `logSourceGeneration`
+- that generation changes for any tracked transcript source movement
+
+That pattern is useful, but it is too broad as the primary exact-log coherence token.
+
+If exact logs reuse the global generation directly, then:
+
+- an unrelated transcript file change can invalidate all open exact-log details
+- exact detail requests become noisier and more frequently stale than necessary
+
+So exact logs should use a **narrower source generation**:
+
+- derive `sourceGeneration` from the exact summary source set used for one requested summary
+- typically hash normalized `(filePath, size, mtimeMs)` for the referenced transcript files
+
+### Why `linkKinds` is an array
+
+One exact-log summary/detail can legitimately originate from multiple explicit links that collapse into the same rendered bundle.
+
+Example:
+- same tool call produced both `subject` and `related` links
+- same transcript message had both an execution link and a board-action link relevant to the target task
+
+The bundle should render once, not duplicate.
+
+### File-local exact-detail boundary
+
+Repo-local finding:
+
+- existing tool/result linking in `SessionParser`, `ToolExecutionBuilder`, and the execution renderer pipeline works over one provided message slice
+- bundle identity already includes `filePath`
+- `MemberExecutionLog` itself only consumes `EnhancedChunk[]` and a display `memberName`
+
+So v1 should keep a strict boundary:
+
+- one exact summary belongs to one transcript file
+- one exact detail request parses at most that summary's referenced file set
+- no cross-file hunt for a missing paired `tool_use` or `tool_result`
+
+This is the safer rule because cross-file pairing would immediately reintroduce guesswork and drift.
+
+If a future transcript shape ever truly requires cross-file pairing, that should be a separate iteration with its own invariants and tests.
+
+---
+
+## Exact Selection Rules
+
+This is the most critical part of the design.
+
+### Principle
+
+Select only what is explicitly attributable to the target task.
+
+Never reintroduce broad session heuristics as the exact-log source.
+
+### Critical anti-bug rule
+
+The selector must work on **explicit source refs first**, and only then read transcript content.
+
+It must never scan a transcript file first and try to rediscover task relevance from nearby content.
+
+### Step 1 - Start from explicit activity records
+
+Only records whose resolved target task matches the requested task are eligible.
+
+### Step 2 - Derive exact message anchors
+
+Each eligible record becomes one anchor candidate.
+
+Suggested internal shape:
+
+```ts
+type BoardTaskExactLogAnchor =
+ | {
+ kind: 'tool'
+ filePath: string
+ sessionId: string
+ toolUseId: string
+ sourceMessageUuid: string
+ }
+ | {
+ kind: 'message'
+ filePath: string
+ sessionId: string
+ messageUuid: string
+ }
+```
+
+### Step 3 - Collapse multiple records into stable bundles
+
+Deduplicate anchors aggressively:
+
+- same `filePath + toolUseId` -> one tool bundle
+- same `filePath + messageUuid` -> one message bundle
+
+### Anchor precedence rule
+
+If both anchors exist for the same source:
+
+- tool anchor: `filePath + toolUseId`
+- message anchor: `filePath + messageUuid`
+
+then the **tool anchor wins** and the message anchor must not create a second bundle for the same tool execution.
+
+This is required because one task-linked tool result can also carry an explicitly linked message UUID.
+Without precedence, the same action can render twice:
+- once as a tool bundle
+- once as a message bundle
+
+That would be a real regression.
+
+This avoids duplicate rendering when:
+- multiple links point to the same tool
+- link/unlink emits both subject + related rows
+- one activity message contains multiple links for the same target task
+
+### Step 4 - Build summaries from anchors only
+
+Summary stage must stop here.
+
+For each surviving anchor:
+- compute stable summary identity
+- aggregate `linkKinds`
+- derive actor label and source metadata
+- compute per-summary `sourceGeneration`
+- set `canLoadDetail` conservatively
+
+⚠️ Summary stage must **not** parse transcript content.
+
+That keeps:
+- popup open cheaper
+- correctness easier to reason about
+- stale invalidation scoped to one summary
+
+### Step 5 - Build filtered message slice only on detail request
+
+This is where the old bugs must not come back.
+
+#### For tool bundles
+
+Include only:
+- the assistant `tool_use` block with the matching `toolUseId`
+- the internal user `tool_result` block with the same `toolUseId`
+- explicit assistant text output only when the same assistant message is itself explicitly linked to the task
+
+Do **not** automatically include every other tool in the same AI response.
+
+#### For ambient execution/message bundles
+
+Include only:
+- the explicitly linked message itself
+- optionally, paired assistant output blocks from the same message if the linked message is assistant content
+
+Do **not** expand to unrelated neighboring transcript messages by default.
+
+### Why this stricter filtering is necessary
+
+If we simply render the whole AI response group, we can leak:
+- unrelated board tools
+- unrelated read/search tools
+- unrelated support actions from the same response
+
+That would make the task logs look rich, but wrong.
+
+Exact logs must be:
+- rich
+- but still task-scoped
+
+---
+
+## Exact Filtering Strategy
+
+The filtered slice should use **synthetic filtered `ParsedMessage` copies**, not raw original messages unchanged.
+
+That means:
+- copy the original message metadata
+- keep only the relevant content blocks
+- preserve `uuid`, `timestamp`, `requestId`, sidechain flags, session metadata
+- drop unrelated blocks
+
+### Critical consistency rule for synthetic messages
+
+After block filtering, derived message fields must be **recomputed**, not blindly copied.
+
+That includes:
+- `toolCalls`
+- `toolResults`
+- `sourceToolUseID`
+- `sourceToolAssistantUUID`
+- `toolUseResult`
+
+If we keep the original derived fields after dropping unrelated blocks, the renderer can silently reintroduce unrelated tool cards even though the filtered content looked correct.
+
+That is one of the highest-risk implementation mistakes in this iteration.
+
+### Research-backed note: what the renderer actually reads
+
+From the current code:
+
+- assistant-side tool cards are derived primarily from assistant content blocks (`tool_use`)
+- internal user tool results are derived primarily from `msg.toolResults`
+- `ChunkBuilder` and `SemanticStepExtractor` do **not** rely on exactly the same fields on both sides
+
+Implication:
+
+- assistant filtered messages must preserve correct assistant content blocks
+- internal user filtered messages must rebuild `toolResults[]` correctly
+- copying stale derived fields is especially dangerous on the internal user side
+- `toolUseResult` needs explicit handling because renderer/tool-content helpers use it for richer cards
+
+Suggested helper:
+
+```ts
+function filterParsedMessageForTaskAnchor(args: {
+ message: ParsedMessage
+ anchor: BoardTaskExactLogAnchor
+ explicitlyLinkedMessageIds: Set
+}): ParsedMessage | null
+```
+
+Rules:
+
+- assistant message:
+ - keep `tool_use` blocks only when `block.id === anchor.toolUseId`
+ - keep `text` blocks only when the message UUID is explicitly linked for the same target task
+ - drop unrelated `tool_use` blocks
+ - drop unrelated thinking blocks in v1
+
+- internal user message:
+ - keep `tool_result` blocks only when `block.tool_use_id === anchor.toolUseId`
+ - rebuild `toolResults[]` only for that tool
+ - keep `sourceToolUseID` only when it matches
+ - keep `sourceToolAssistantUUID` only when the paired assistant message is present in the same bundle
+ - keep `toolUseResult` only when it can be proven to belong to the same surviving `toolUseId`
+ - if that proof is missing, drop `toolUseResult` instead of risking leaked payload from another tool
+
+- ordinary user/system message:
+ - keep only if explicitly linked by `messageUuid`
+
+This preserves correctness and still allows the renderer to work.
+
+### `toolUseResult` preservation policy
+
+Repo-local finding:
+
+- `displayItemBuilder` uses `toolUseResult` while building linked tool items
+- `toolContentChecks` uses `toolUseResult` to decide whether richer content exists for read/write/edit-style tools
+- `ToolResultExtractor` also treats `toolUseResult` as an alternate result carrier
+
+So `toolUseResult` is not optional sugar.
+It can materially affect what the renderer shows.
+
+Safe v1 rule:
+
+- keep `toolUseResult` only when:
+ - the filtered internal-user message still points to exactly one surviving `toolUseId`
+ - that `toolUseId` matches `sourceToolUseID` or an equivalent explicit enriched field
+- otherwise:
+ - drop `toolUseResult`
+
+Why this is safer:
+
+- false negatives only degrade richness for one tool card
+- false positives can leak payload from a different tool execution into the current exact bundle
+
+For exact task logs, false negative is preferable to false positive.
+
+### Streaming assistant dedupe rule
+
+Another repo-local finding:
+
+- `parseJsonlFile(...)` parses streaming assistant entries as separate `ParsedMessage`s
+- `deduplicateByRequestId(...)` exists, but it is not automatically applied by the general renderer pipeline
+- if exact logs do nothing, the same assistant response can survive more than once inside one bundle
+
+That can cause:
+
+- duplicated output rows
+- duplicated tool-use blocks from intermediate streaming entries
+- unstable exact bundles for the same task over time
+
+So the exact-log path must add an explicit dedupe step:
+
+- after synthetic filtering
+- before chunk building
+- per bundle candidate
+- keep only the last surviving assistant message for a given `requestId`
+
+Important:
+
+- do not dedupe across different bundles
+- do not dedupe by `requestId` before filtering, because different streaming snapshots may survive differently after block filtering
+
+The safe sequence is:
+
+1. parse strict file-local `ParsedMessage[]`
+2. build one filtered synthetic bundle slice
+3. dedupe assistant streaming entries by `requestId` inside that slice
+4. build chunks from that deduped bundle slice
+
+This should be pinned with tests.
+
+### Strict timestamp and source-fidelity rule
+
+The exact-log path must not become looser than the summary path about malformed transcript rows.
+
+Important repo-local finding:
+
+- the current explicit activity reader already skips rows without a real transcript `timestamp`
+- the generic `parseJsonlFile(...)` path currently falls back to `new Date()` when raw transcript `timestamp` is missing
+
+That fallback is acceptable for broad session utilities, but it is **not** acceptable for exact task logs.
+
+If exact logs silently synthesize “now” for malformed transcript rows, we get:
+
+- unstable ordering across reads
+- bundles that appear newer than they really are
+- drift between `Task Activity` and `Exact Task Logs`
+
+So the exact-log path must use a **strict timestamp policy**:
+
+- missing or malformed raw transcript timestamp -> drop the exact-log row or exact-log message
+- never synthesize current time
+
+Preferred implementation direction:
+
+- add a small exact-log-specific strict parser wrapper
+- optionally, only if it stays clearly isolated, extend low-level JSONL parsing with an opt-in strict mode used exclusively by exact logs
+
+Rejected shortcut:
+
+- parse with the permissive default path and try to detect synthetic timestamps later
+
+That shortcut is not reliable because the fallback timestamp becomes indistinguishable from a valid parsed timestamp after parsing.
+
+Important repo-local constraint:
+
+- `parseJsonlFile(...)` is used broadly across the app
+- changing its default permissive behavior would create unrelated blast radius
+
+So the safer v1 direction is:
+
+- keep the global permissive parser unchanged
+- add an exact-log-specific strict wrapper or opt-in exact mode
+- contain the stricter behavior inside the exact-log path only
+
+### Classification rule for synthetic filtered messages
+
+The plan relies on the current `MessageClassifier` behavior:
+
+- filtered internal user tool-result messages are still classified into the AI path
+- they are not rendered as user bubbles as long as they remain internal/meta user messages
+
+This is good for the chosen design, but it is a dependency that must be pinned with tests.
+
+If this classifier behavior changes later, exact logs can silently degrade.
+
+---
+
+## Chunk Building Strategy
+
+### Chosen direction
+
+Reuse:
+
+- `ChunkBuilder.buildChunks(...)`
+- `transformChunksToConversation(...)`
+- `enhanceAIGroup(...)`
+- `MemberExecutionLog`
+
+### Pre-flight checkpoint
+
+Before coding the bundle builder, confirm with tests that:
+
+- filtered internal user messages still classify into the expected AI path in `MessageClassifier`
+- filtered assistant + internal user slices still produce the expected tool cards in `MemberExecutionLog`
+- filtered tool-result-only bundles still render meaningfully even when no paired assistant tool-use survives
+- filtered bundles with multiple assistant streaming snapshots collapse to one stable assistant row per `requestId`
+- `toolUseResult`-backed richer tool cards still work when the surviving bundle truly owns that tool result
+- `toolUseResult` is dropped when ownership is ambiguous
+
+This must be verified, not assumed.
+
+### Important rule
+
+Build chunks from the **filtered slice**, not from the entire session.
+
+### Bundle isolation rule
+
+Build chunks **per requested exact bundle detail**, not from a concatenated multi-bundle slice.
+
+Why:
+
+- `ChunkBuilder` buffers adjacent AI-category messages together
+- if two anchors are concatenated before chunk building, separate exact bundles can accidentally merge into one AI chunk
+- that would produce unstable visual grouping and leak unrelated context between bundles
+
+So the correct sequence is:
+
+1. derive one exact detail candidate
+2. build one filtered message slice for that candidate
+3. build chunks for that candidate only
+4. map to one `BoardTaskExactLogDetail`
+
+Suggested builder:
+
+```ts
+class BoardTaskExactLogChunkBuilder {
+ constructor(private readonly chunkBuilder: ChunkBuilder = new ChunkBuilder()) {}
+
+ buildBundleChunks(messages: ParsedMessage[]): EnhancedChunk[] {
+ return this.chunkBuilder.buildChunks(messages, [], { includeSidechain: true })
+ }
+}
+```
+
+### Why not pass subagents/processes in v1
+
+The exact log slice is already strict and synthetic.
+
+Passing full process linkage into this slice creates extra coupling and raises contamination risk.
+
+In v1:
+- pass no additional processes
+- render only what is explicitly in the filtered message slice
+
+That is safer and easier to reason about.
+
+### Why no `SessionParser` as the main entrypoint
+
+`SessionParser` is useful for whole-session views, but it is not the ideal entrypoint here.
+
+For exact logs we want:
+- file-local parsed messages
+- no whole-session grouping assumptions
+- no extra session-level work unless needed
+
+So the preferred path in v1 is:
+
+- parse raw transcript files into `ParsedMessage[]`
+- then run exact-bundle selection on top
+
+Do not start from a full `SessionDetail` pipeline unless implementation proves it is actually simpler without correctness cost.
+
+---
+
+## Why We Should Not Reuse `MemberLogsTab`
+
+`MemberLogsTab` is valuable, but it is the wrong source layer for exact logs.
+
+It still depends on:
+- session summaries
+- session overlap
+- task work intervals
+- preview logic
+- owner-session assumptions
+
+That logic remains useful for `Execution Sessions`, but should not be reused as the source for exact task logs.
+
+Correct reuse target:
+- renderer primitives
+
+Wrong reuse target:
+- legacy session discovery
+
+### Renderer reuse boundary
+
+Reusing the existing renderer means reusing its current visual behavior too.
+
+That is intentional in v1:
+
+- exact details render through `MemberExecutionLog`
+- item ordering follows that component's existing behavior
+- no ongoing/session-status affordances are added
+- no extra subagent/process enrichment is injected beyond what exists in the filtered chunk slice
+
+This keeps iteration 08 focused on the hard problem - correct task-scoped selection - instead of accidentally starting a parallel renderer redesign.
+
+---
+
+## New Main-Side Services
+
+### 1. `BoardTaskActivityRecordSource`
+
+Responsibility:
+- read transcript metadata
+- resolve task-linked records
+- expose internal activity records
+
+Potential implementation:
+- extract common lower-level logic from current `BoardTaskActivityService`
+- keep `BoardTaskActivityService` as record -> DTO mapper
+
+### 2. `BoardTaskExactLogSummarySelector`
+
+Responsibility:
+- take activity records only
+- group them by exact-log anchor
+- produce lightweight exact-log summaries
+
+Important:
+- this selector owns anchor precedence
+- this selector must not parse transcript files
+- this selector computes per-summary `sourceGeneration`
+- computing `sourceGeneration` may stat referenced files, but it must not parse transcript content
+- this selector decides `canLoadDetail` conservatively from anchor shape and record fidelity
+
+### 3. `BoardTaskExactLogDetailSelector`
+
+Responsibility:
+- take one exact-log summary + strict parsed transcript messages
+- produce one filtered message slice for one requested exact detail
+
+Important:
+- this selector owns derived-field recomputation requirements for filtered messages
+- this selector must not return raw original `ParsedMessage` arrays when block filtering happened
+
+### 4. `BoardTaskExactLogChunkBuilder`
+
+Responsibility:
+- convert filtered message bundles into `EnhancedChunk[]`
+
+Important:
+- one bundle in, one bundle out
+- no cross-bundle chunk building
+
+### 5. `BoardTaskExactLogsService`
+
+Responsibility:
+- orchestrate the exact-log summary flow
+- expose IPC-facing `BoardTaskExactLogSummariesResponse`
+
+Important:
+- this service consumes `BoardTaskActivityRecordSource`
+- it must not directly parse `boardTaskLinks[]` from JSONL lines itself
+- it must not parse transcript messages in the summary path
+- it may read file metadata needed for per-summary `sourceGeneration`
+- it should not own a second explicit-metadata parser
+
+### 6. `BoardTaskExactLogDetailService`
+
+Responsibility:
+- resolve one exact bundle summary into one renderable exact detail
+- expose IPC-facing `BoardTaskExactLogDetailResult`
+
+Important:
+- this service consumes `BoardTaskActivityRecordSource`
+- this service consumes `BoardTaskExactLogDetailSelector`
+- this service owns strict per-bundle filtering
+- this service owns per-bundle assistant `requestId` dedupe before chunk building
+- this service returns `stale` or `missing` instead of guessing when a requested bundle can no longer be rendered safely
+
+---
+
+## Proposed File Touchpoints
+
+### `claude_team` main
+
+Add:
+
+- `src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts`
+- `src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts`
+- `src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts`
+- `src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts`
+- `src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts`
+- `src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts`
+- `src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts`
+
+Touch:
+
+- `src/main/ipc/teams.ts`
+- `src/main/ipc/handlers.ts`
+- `src/main/index.ts`
+- `src/preload/index.ts`
+- `src/preload/constants/ipcChannels.ts`
+- `src/shared/types/api.ts`
+- `src/shared/types/team.ts`
+
+### `claude_team` renderer
+
+Add:
+
+- `src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx`
+- `src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx`
+
+Touch:
+
+- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx`
+
+### `agent_teams_orchestrator`
+
+No new write-side contract is required for this iteration if iteration 07 metadata is already present.
+
+Only touch write-side if a concrete missing field is discovered during implementation.
+
+That is an explicit scope guard.
+
+---
+
+## IPC Plan
+
+Add:
+
+```ts
+teams.getTaskExactLogSummaries(
+ teamName: string,
+ taskId: string
+): Promise
+teams.getTaskExactLogDetail(
+ teamName: string,
+ taskId: string,
+ exactLogId: string,
+ expectedSourceGeneration: string
+): Promise
+```
+
+Suggested IPC channel:
+
+```ts
+TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries'
+TEAM_GET_TASK_EXACT_LOG_DETAIL = 'team:getTaskExactLogDetail'
+```
+
+These methods must be:
+- independent from `getLogsForTask(...)`
+- independent from the legacy worker fallback path
+- explicit-metadata only in v1
+- browser-safe in the same way as other team-only methods:
+ - summaries -> `{ items: [] }`
+ - detail -> `{ status: 'missing' }`
+
+### Return shape rule
+
+The API should:
+
+- return lightweight summaries from the summary endpoint
+- return already-built `EnhancedChunk[]` only from the detail endpoint
+- never return raw messages plus renderer-side building instructions
+
+Why:
+
+- chunk building belongs to the main-side service layer
+- renderer should stay simple
+- this keeps exact-log selection and filtering logic out of the renderer
+- this keeps the initial popup payload materially smaller
+
+### Ordering rule
+
+Returned summaries must be sorted deterministically by:
+
+1. explicit source timestamp
+2. `filePath`
+3. `sourceOrder`
+4. `toolUseId`
+5. `id`
+
+This avoids UI drift when multiple transcript rows share the same minute/second bucket.
+
+---
+
+## Renderer Plan
+
+### `TaskLogsPanel`
+
+Target composition:
+
+```tsx
+
+
+
+```
+
+### `ExactTaskLogsSection`
+
+Responsibilities:
+- fetch `teams.getTaskExactLogSummaries(...)`
+- load independently from `ExecutionSessionsSection`
+- show loading / error / empty state
+- render one card per exact log summary
+
+### Exact-log loading policy
+
+Exact logs are materially heavier than summary rows.
+
+So the safe v1 loading policy is:
+
+- load when the task popup opens and the section becomes visible
+- if the section is collapsed, do not keep a blind frequent poll running
+- if the task is active and the section is expanded, a slower revalidation loop is acceptable
+- manual refresh is acceptable and should be easy to add
+
+This is better than unconditional frequent polling because exact logs require:
+
+- explicit record lookup
+- transcript file parsing for exact detail
+- synthetic message filtering
+- chunk building
+
+Those costs are much higher than the summary feed.
+
+### `ExactTaskLogCard`
+
+Responsibilities:
+- show timestamp + actor label
+- show source metadata if helpful
+- lazy-load detail on expand
+- render the loaded detail via `MemberExecutionLog`
+- keep the expand control disabled when `canLoadDetail === false`
+
+Example:
+
+```tsx
+if (summary.canLoadDetail) {
+ const detail = await api.teams.getTaskExactLogDetail(
+ teamName,
+ taskId,
+ summary.id,
+ summary.sourceGeneration
+ )
+ if (detail.status === 'ok') {
+ return
+ }
+}
+```
+
+### Actor label rule
+
+Fix the current weak UX:
+
+- if `memberName` exists -> show it
+- else if `isSidechain === false` -> show `lead session`
+- else -> show `unknown actor`
+
+This is much safer and more readable than the current fallback.
+
+---
+
+## Empty State Policy
+
+If there are explicit activity rows but no exact renderable summaries:
+
+- do **not** silently disappear
+- show a clear empty state such as:
+
+`Exact task-scoped transcript groups are not available for these activity rows yet.`
+
+If no explicit activity exists:
+
+`No explicit task-linked logs found in transcript metadata.`
+
+This matters because:
+- summary-only history is still useful
+- users should not assume the feature is broken
+
+---
+
+## Performance Plan
+
+This slice can get expensive if implemented naively.
+
+### Required v1 protections
+
+1. Parse cache by `filePath + mtimeMs + size`
+2. In-flight dedupe for concurrent reads
+3. Deduplicate anchors before building summaries
+4. In the summary path, do not parse transcript content at all
+5. In the detail path, do not parse the same file repeatedly inside one request
+6. In the detail path, derive referenced file paths from explicit activity records first, then parse only that subset
+7. Avoid unconditional high-frequency polling for exact logs
+8. Share the explicit metadata reader/record source with the summary path instead of re-reading metadata in a second pipeline
+9. Keep exact detail lazy, not eager, in v1
+
+### Nice-to-have only if needed later
+
+- per-task result cache
+- cross-service parsed transcript cache reuse
+
+Do not over-engineer that before profiling.
+
+---
+
+## Consistency Rules
+
+### Rule 1 - Exact logs are explicit-link only
+
+Do not add:
+- work-interval fallback
+- mention matching
+- owner fallback
+- “close enough” neighboring tool inference
+
+### Rule 2 - Exact logs and summary use the same explicit source
+
+`Task Activity` and `Exact Task Logs` should derive from the same underlying explicit activity records, not from separate competing interpretations.
+
+That means:
+
+- same `BoardTaskActivityRecordSource`
+- same explicit transcript metadata semantics
+- same target-task resolution rules
+
+The two views may diverge in presentation.
+They must not diverge in their low-level notion of “this transcript source is explicitly linked to this task”.
+
+### Rule 3 - Summary selector is the single source of truth for summary identity
+
+`exactLogId` and per-summary `sourceGeneration` must come from one place only:
+
+- `BoardTaskExactLogSummarySelector`
+
+That means:
+
+- `BoardTaskExactLogsService` uses it to emit summaries
+- `BoardTaskExactLogDetailService` uses the same selector to rebuild summaries before loading detail
+- detail service must not recompute ids with its own string concatenation rules
+- detail service must not recompute generations with a different file-ordering rule
+
+Why this matters:
+
+- summary/detail drift is otherwise easy to introduce silently
+- one tiny id-format change can turn every detail request into `missing`
+- one tiny generation-ordering change can turn valid detail requests into false `stale`
+
+If a helper is extracted, it should stay below both services and be reused by both.
+
+### Rule 4 - Exact logs may be stricter than summary
+
+This is acceptable.
+
+Some summary rows may not yield rich exact summaries or rich exact details if:
+- the row is too minimal
+- the source message is malformed
+- the source message is non-renderable in the existing pipeline
+
+That is better than rendering the wrong thing.
+
+### Rule 5 - Exact detail reconstruction is file-local in v1
+
+Exact detail reconstruction must stay file-local.
+
+That means:
+
+- one summary anchor resolves to one `source.filePath`
+- detail service only parses that summary's referenced files
+- missing pair data in another transcript file is treated as absent, not searched globally
+
+Why this matters:
+
+- it matches the current execution renderer and tool-linking assumptions
+- it keeps `sourceGeneration` honest
+- it avoids a hidden return of broad transcript heuristics
+
+---
+
+## Edge Cases
+
+### 1. Same tool call linked to two tasks
+
+Example:
+- `task_link`
+- `task_unlink`
+
+Behavior:
+- both tasks may show the same exact tool bundle
+- the bundle must render once per task, not duplicate within one task
+
+### 2. One transcript message contains multiple relevant links
+
+Behavior:
+- collapse into one exact log bundle
+- preserve all relevant `linkKinds` in metadata
+
+### 2b. One tool execution has both a tool anchor and a message anchor
+
+Behavior:
+- render exactly one exact bundle
+- the tool anchor wins
+- the message anchor is absorbed into the same bundle metadata
+
+### 3. Same AI response contains relevant and irrelevant tools
+
+Behavior:
+- render only the filtered relevant blocks
+- do not include the whole raw AI response
+
+### 3b. Same assistant message contains both relevant text and unrelated tool calls
+
+Behavior:
+- keep the explicitly linked text
+- drop unrelated tool calls
+- rebuild derived assistant-side tool structures from the surviving blocks only
+
+### 4. Lead-session row without actor name
+
+Behavior:
+- show `lead session`
+- not `unknown actor`
+
+### 5. Missing paired `tool_use`
+
+Behavior:
+- if `tool_result` exists but paired assistant `tool_use` cannot be found, render what is available
+- do not guess missing tool input
+- do not search other transcript files for the missing pair in v1
+
+### 6. Missing timestamp / malformed row
+
+Behavior:
+- skip malformed rows
+- do not synthesize “current time”
+
+### 7. Execution-only ambient rows
+
+Behavior:
+- may render as exact text/output-only bundles
+- no fake tool payload should be attached
+
+---
+
+## Suggested Internal Helper Shapes
+
+### Bundle source model
+
+```ts
+type BoardTaskExactLogBundleCandidate = {
+ id: string
+ timestamp: string
+ actor: BoardTaskExactLogActor
+ source: BoardTaskExactLogSource
+ records: BoardTaskActivityRecord[]
+} & (
+ | { canLoadDetail: true; sourceGeneration: string }
+ | { canLoadDetail: false }
+)
+
+type BoardTaskExactLogDetailCandidate = {
+ id: string
+ timestamp: string
+ actor: BoardTaskExactLogActor
+ source: BoardTaskExactLogSource
+ records: BoardTaskActivityRecord[]
+ filteredMessages: ParsedMessage[]
+}
+```
+
+### Bundle identity rule
+
+Use:
+
+- tool bundle id: `tool:${filePath}:${toolUseId}`
+- message bundle id: `message:${filePath}:${messageUuid}`
+
+Do not use timestamps as the primary identity.
+Timestamps are for ordering, not identity.
+
+### Summary source-of-truth rule for actor label
+
+`MemberExecutionLog` only receives `chunks` plus one optional `memberName`.
+
+So v1 should not try to rediscover actor identity from filtered exact-detail messages.
+The authoritative actor label for the exact-log card should come from the summary/record side:
+
+- exact summary owns the visible actor label
+- exact detail rendering reuses that summary actor label
+- detail reconstruction should not override it based on incidental filtered message content
+
+### Selector skeleton
+
+```ts
+class BoardTaskExactLogSummarySelector {
+ selectSummaries(args: {
+ records: BoardTaskActivityRecord[]
+ }): BoardTaskExactLogBundleCandidate[] {
+ // 1. derive anchors from explicit records
+ // 2. apply tool-anchor-over-message precedence
+ // 3. dedupe anchors
+ // 4. compute per-summary sourceGeneration
+ // 5. return one candidate per summary
+ }
+}
+
+class BoardTaskExactLogDetailSelector {
+ selectDetail(args: {
+ summary: BoardTaskExactLogSummary
+ records: BoardTaskActivityRecord[]
+ parsedMessagesByFile: Map
+ }): BoardTaskExactLogDetailCandidate | null {
+ // 1. rebuild the matching anchor from explicit records
+ // 2. parse only the files referenced by that summary
+ // 3. build filtered synthetic ParsedMessage[] for that one anchor
+ // 4. return one detail candidate or null
+ }
+}
+```
+
+### Service skeleton
+
+```ts
+class BoardTaskExactLogsService {
+ async getTaskExactLogSummaries(
+ teamName: string,
+ taskId: string
+ ): Promise {
+ // 1. get explicit activity records
+ // 2. build exact summaries from records only
+ // 3. sort deterministically
+ // 4. map summary response
+ }
+}
+
+class BoardTaskExactLogDetailService {
+ async getTaskExactLogDetail(
+ teamName: string,
+ taskId: string,
+ exactLogId: string,
+ expectedSourceGeneration: string
+ ): Promise {
+ // 1. rebuild the matching summary from explicit records
+ // 2. if summary.canLoadDetail !== true -> return { status: 'missing' }
+ // 3. compare expectedSourceGeneration with recomputed summary.sourceGeneration
+ // 4. if mismatch -> return { status: 'stale' }
+ // 5. parse only the summary's referenced files via strict parser
+ // 6. build one filtered detail candidate
+ // 7. dedupe assistant streaming rows by requestId
+ // 8. build chunks
+ // 9. return one detail DTO or { status: 'missing' }
+ }
+}
+```
+
+---
+
+## Rollout Plan
+
+### Feature gates
+
+Use separate read/UI gates:
+
+- `CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED`
+- `VITE_BOARD_TASK_EXACT_LOGS_UI_ENABLED`
+
+Do not reuse the iteration 07 gates directly.
+
+This lets us:
+- validate main-side behavior first
+- then enable renderer independently
+
+### Rollout stages
+
+#### Stage 1 - Main-side exact bundle service
+
+- build record source
+- build exact summaries
+- add tests
+- no UI yet
+
+#### Stage 2 - IPC + preload
+
+- expose `getTaskExactLogSummaries(...)`
+- expose `getTaskExactLogDetail(...)`
+- add integration tests
+
+#### Stage 3 - Renderer section
+
+- add `ExactTaskLogsSection`
+- wire into task popup
+- keep disabled by UI flag initially
+
+#### Stage 4 - Manual shadow validation
+
+Compare:
+- `Task Activity`
+- `Exact Task Logs`
+- `Execution Sessions`
+
+for several real teams and transcript shapes.
+
+---
+
+## Testing Plan
+
+### Main tests
+
+Add focused tests for:
+
+1. `BoardTaskActivityRecordSource`
+ - explicit record extraction matches existing activity semantics
+
+2. Exact-log selectors
+ become two focused test units:
+
+ `BoardTaskExactLogSummarySelector`
+ - dedupes repeated refs
+ - applies tool-anchor-over-message precedence
+ - computes stable per-summary `sourceGeneration`
+ - does not parse transcript content
+ - sets `canLoadDetail` conservatively
+ - omits `sourceGeneration` when `canLoadDetail === false`
+
+ `BoardTaskExactLogDetailSelector`
+ - filters unrelated tools from same AI response
+ - keeps filtered internal-user results in the AI rendering path
+ - keeps paired tool_use + tool_result
+ - preserves explicit assistant text when linked
+ - rebuilds derived fields after block filtering
+ - keeps `toolUseResult` only for the surviving matching tool result
+ - dedupes assistant streaming entries by `requestId` after filtering
+ - never searches outside the summary's file-local source set for missing pairs
+
+3. `BoardTaskExactLogChunkBuilder`
+ - builds renderable `EnhancedChunk[]`
+ - never merges adjacent candidates into one cross-bundle AI chunk
+ - no crash on minimal bundles
+
+4. `BoardTaskExactLogsService`
+ - returns sorted summaries
+ - empty when feature disabled
+ - returns `{ items: [] }` for unknown task
+ - does not invoke transcript parsing in the summary path
+ - does not touch the exact-log strict parser or transcript parse cache in the summary path
+ - emits stable per-summary `sourceGeneration` values
+ - never emits `sourceGeneration` for non-expandable summaries
+
+5. `BoardTaskExactLogDetailService`
+ - returns `status: 'missing'` immediately for non-expandable summaries
+ - returns `status: 'stale'` when requested generation no longer matches
+ - returns `status: 'missing'` for unknown bundle
+ - returns `status: 'ok'` with renderable detail for valid bundle id
+ - does not guess missing tool ownership
+ - reuses the summary actor label instead of re-deriving actor identity from filtered detail messages
+
+### IPC tests
+
+- `teams.getTaskExactLogSummaries(...)` happy path
+- `teams.getTaskExactLogDetail(...)` happy path
+- `teams.getTaskExactLogDetail(...)` stale-generation path
+- browser fallback shape
+- disabled flag path
+- malformed transcript path
+
+### Renderer tests
+
+- `ExactTaskLogsSection`
+ - loading
+ - error
+ - empty
+ - renders one or more exact summaries
+ - reloads summaries on `stale` detail response
+
+### Manual validation
+
+Use real scenarios:
+
+1. normal owner task with lifecycle + comments + review
+2. external actor touches another task
+3. `task_link` / `task_unlink`
+4. lead-session rows without `agentName`
+5. task with explicit summary rows but no exact renderable detail
+6. summary/detail drift after transcript update
+
+---
+
+## Definition of Done
+
+This iteration is done when:
+
+- task popup shows:
+ - `Task Activity`
+ - `Exact Task Logs`
+ - `Execution Sessions`
+- `Exact Task Logs` visually uses the same execution-log renderer family the user already likes
+- exact logs are sourced from explicit task-linked transcript selection
+- exact logs do **not** depend on legacy heuristic task/session discovery
+- unrelated tools from the same AI response are not leaked into the exact view
+- exact-log details are lazy-loaded, not eagerly transferred for every summary row
+- main-side and renderer tests pass
+- old `Execution Sessions` remains intact and isolated
+
+---
+
+## Final Decision Summary
+
+The best path is:
+
+- **reuse the existing execution renderer**
+- **do not reuse the old heuristic log discovery**
+- **insert a strict explicit task-scoped transcript selection layer**
+
+This preserves the good UX while finally making task log attribution reliable.
diff --git a/docs/iterations/schemas/board-task-transcript-v1.schema.json b/docs/iterations/schemas/board-task-transcript-v1.schema.json
new file mode 100644
index 00000000..d997ee5c
--- /dev/null
+++ b/docs/iterations/schemas/board-task-transcript-v1.schema.json
@@ -0,0 +1,192 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://claude-team.local/schemas/board-task-transcript-v1.schema.json",
+ "title": "Board Task Transcript V1",
+ "type": "object",
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "minLength": 1
+ },
+ "timestamp": {
+ "type": "string",
+ "minLength": 1
+ },
+ "sessionId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "boardTaskLinks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/boardTaskLink"
+ }
+ },
+ "boardTaskToolActions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/boardTaskToolAction"
+ }
+ }
+ },
+ "$defs": {
+ "boardTaskLocator": {
+ "type": "object",
+ "required": ["ref", "refKind"],
+ "properties": {
+ "ref": {
+ "type": "string",
+ "minLength": 1
+ },
+ "refKind": {
+ "type": "string",
+ "enum": ["canonical", "display", "unknown"]
+ },
+ "canonicalId": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": false
+ },
+ "actorContext": {
+ "type": "object",
+ "required": ["relation"],
+ "properties": {
+ "relation": {
+ "type": "string",
+ "enum": ["same_task", "other_active_task", "idle", "ambiguous"]
+ },
+ "activeTask": {
+ "$ref": "#/$defs/boardTaskLocator"
+ },
+ "activePhase": {
+ "type": "string",
+ "enum": ["work", "review"]
+ },
+ "activeExecutionSeq": {
+ "type": "number"
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "relation": {
+ "enum": ["same_task", "idle", "ambiguous"]
+ }
+ },
+ "required": ["relation"]
+ },
+ "then": {
+ "not": {
+ "anyOf": [
+ { "required": ["activeTask"] },
+ { "required": ["activePhase"] },
+ { "required": ["activeExecutionSeq"] }
+ ]
+ }
+ }
+ }
+ ],
+ "additionalProperties": false
+ },
+ "boardTaskLink": {
+ "type": "object",
+ "required": ["schemaVersion", "task", "targetRole", "linkKind", "actorContext"],
+ "properties": {
+ "schemaVersion": {
+ "const": 1
+ },
+ "toolUseId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "task": {
+ "$ref": "#/$defs/boardTaskLocator"
+ },
+ "targetRole": {
+ "type": "string",
+ "enum": ["subject", "related"]
+ },
+ "linkKind": {
+ "type": "string",
+ "enum": ["execution", "lifecycle", "board_action"]
+ },
+ "taskArgumentSlot": {
+ "type": "string",
+ "enum": ["taskId", "targetId"]
+ },
+ "actorContext": {
+ "$ref": "#/$defs/actorContext"
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "linkKind": {
+ "const": "execution"
+ }
+ },
+ "required": ["linkKind"]
+ },
+ "then": {
+ "not": {
+ "anyOf": [
+ { "required": ["taskArgumentSlot"] }
+ ]
+ }
+ }
+ }
+ ],
+ "additionalProperties": false
+ },
+ "boardTaskToolAction": {
+ "type": "object",
+ "required": ["schemaVersion", "toolUseId", "canonicalToolName"],
+ "properties": {
+ "schemaVersion": {
+ "const": 1
+ },
+ "toolUseId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "canonicalToolName": {
+ "type": "string",
+ "minLength": 1
+ },
+ "input": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": ["pending", "in_progress", "completed", "deleted"]
+ },
+ "owner": { "type": ["string", "null"] },
+ "clarification": { "type": ["string", "null"], "enum": ["lead", "user", null] },
+ "reviewer": { "type": "string" },
+ "relationship": {
+ "type": "string",
+ "enum": ["blocked-by", "blocks", "related"]
+ },
+ "commentId": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "resultRefs": {
+ "type": "object",
+ "properties": {
+ "commentId": { "type": "string" },
+ "attachmentId": { "type": "string" },
+ "filename": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/docs/research/real-competitors-comparison.md b/docs/research/real-competitors-comparison.md
new file mode 100644
index 00000000..b966a535
--- /dev/null
+++ b/docs/research/real-competitors-comparison.md
@@ -0,0 +1,484 @@
+# Реальные конкуренты для Comparison в README
+
+> Дата проверки: 2026-04-13
+> Статус: внутренний comparison draft
+> Цель: заменить в нашем внутреннем thinking `Vibe Kanban` и `Aperant` на реальные ориентиры - `Gastown`, `Claude Code Agent Teams`, `GoClaw`
+
+## Что именно сравнивается
+
+В этом документе "мы" = не только README-маркетинг, а текущий продуктовый стек:
+
+- `claude_team` как frontend/workbench
+- `agent_teams_orchestrator` как локальный runtime и task/review/log pipeline
+
+Сравнение идёт по тем же строкам, что уже есть в `Comparison` секции README, но с реальными конкурентами.
+
+## Как сравнивал
+
+- `✅` - фича есть как явная продуктовая возможность
+- `⚠️` - фича есть частично, экспериментально, только вручную, только через CLI/TUI, или без сильного UI/UX
+- `❌` - фича не задокументирована как продуктовая возможность или явно отсутствует
+
+Правило важное:
+
+- если capability есть только "под капотом" или через обходной workflow, это не `✅`
+- для нашей стороны я учитывал не только README, но и реальный frontend/code surface
+- для конкурентов брал только первичные источники: official docs, official GitHub repo, official releases
+
+## Короткий snapshot
+
+| Система | Позиционирование | GitHub / живость | Самое важное |
+|---|---|---|---|
+| **Claude Agent Teams UI** | local-first coding-team cockpit | `577★`, push `2026-04-12` | сильнейший UI для task logs, review, editor, live processes |
+| **Gastown** | process-model multi-agent workspace manager | `13,931★`, latest `v1.0.0` от `2026-04-03` | сильный orchestration runtime, mailboxes, handoffs, git worktrees |
+| **Claude Code Agent Teams** | нативные team lead + teammates внутри Claude Code | `113,180★` у `anthropics/claude-code`, latest `v2.1.104` от `2026-04-13` | самый нативный Claude-first team runtime, но без нашего UI-слоя |
+| **GoClaw** | self-hosted multi-tenant agent platform | `2,634★`, latest `v3.6.0` от `2026-04-13` | самый широкий platform surface: kanban, approvals, providers, channels |
+
+## Feature matrix
+
+| Feature | Claude Agent Teams UI | Gastown | Claude Code Agent Teams | GoClaw |
+|---|---|---|---|---|
+| **Cross-team communication** | ✅ Native cross-team messaging between teams | ⚠️ Cross-rig coordination exists, but not a polished team-to-team chat surface | ❌ No documented team-to-team concept | ❌ Team-local messaging, no documented cross-team agent comms |
+| **Agent-to-agent messaging** | ✅ Native mailbox-style teammate and lead messaging | ✅ Built-in mailboxes, identities, handoffs | ✅ Shared mailbox + direct teammate messaging | ✅ Team messaging, member-to-member messages |
+| **Linked tasks** | ✅ `#task-id` references + task dependencies | ⚠️ Beads, convoys and deps exist, but linking UX is more operational than productized | ⚠️ Shared task list + dependencies, but minimal linking UX | ✅ Task numbers, search, `blocked_by`, comments, audit trail |
+| **Session analysis** | ✅ Task-specific logs, exact task log matching, deep session analysis, token tracking | ⚠️ Event stream, seance, OTLP logs, but no rich per-session analytics UI | ❌ No dedicated session analysis surface | ⚠️ Traces, audit events and task detail exist, but not our depth of per-task session analysis |
+| **Task attachments** | ✅ Task and comment attachments in team workflow | ❌ Not documented as a task feature | ❌ Not documented | ✅ Task attachments + media auto-copy into team workspace |
+| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ❌ |
+| **Built-in code editor** | ✅ Built-in editor with Git support | ❌ | ❌ | ❌ Workspace browser exists, but not a real built-in code editor |
+| **Full autonomy** | ✅ Agents can create, assign, review and progress tasks end-to-end with human override | ✅ Mayor + convoy + witness/deacon orchestration | ⚠️ Strong autonomy, but feature is still experimental | ✅ Strong autonomous team/task orchestration |
+| **Task dependencies (blocked by)** | ✅ Explicit task dependencies and ordering | ✅ Beads deps / blocked work exist | ✅ Dependencies unblock automatically | ✅ `blocked_by`, blocked lifecycle, retry, stale handling |
+| **Review workflow** | ✅ Agent peer review + human review UI | ⚠️ Merge/review workflows exist, but not as a productized task review cockpit | ⚠️ Plan approval + hooks, but no rich review board | ✅ `in_review`, approve/reject, reviewer-agent gates |
+| **Zero setup** | ✅ Claude Code install + auth from the app | ❌ Many prerequisites and workspace bootstrap steps | ❌ Claude Code install + experimental flag required | ❌ Standard setup needs infra/provider config; Lite is easier but still not zero-setup |
+| **Kanban board** | ✅ Real-time board | ❌ Dashboard overview, not Kanban | ❌ Shared task list, no Kanban board | ✅ Dashboard Kanban board |
+| **Execution log viewer** | ✅ Task log panels, exact logs, stream, timeline | ⚠️ Feed/dashboard/event logs exist, but not a task log cockpit | ❌ No dedicated log viewer | ⚠️ Trace spans + task events/comments, but not strong raw per-task execution logs |
+| **Live processes** | ✅ View, stop, inspect, open URLs | ⚠️ Agent/session monitoring exists, but not a developer process cockpit | ⚠️ Split panes let you watch sessions, but there is no processes dashboard | ❌ No comparable live-process UI surfaced like ours |
+| **Per-task code review** | ✅ Per-task diff review with accept / reject / comment flow | ❌ | ❌ | ⚠️ Task approval exists, but not inline code diff review |
+| **Flexible autonomy** | ✅ Granular approvals, notifications, autonomy controls | ✅ Strong human gates, escalation and intervention, mostly via CLI/TUI | ⚠️ Plan approval, hooks and permissions exist, but control plane is thin | ✅ Team settings, approval workflows, exec approval, task approval |
+| **Git worktree isolation** | ✅ Optional per-agent worktree strategy | ✅ Core architectural primitive | ⚠️ Manual worktrees exist in Claude Code, but not as the native team model | ❌ Not a core team isolation model |
+| **Multi-agent backend** | ⚠️ Claude is mature; Codex/Gemini plumbing exists in code but is still emerging as product surface | ✅ Claude Code, Codex, Gemini, Copilot and other runtimes | ❌ Claude-first only, models per teammate but no real multi-provider backend | ✅ 20+ providers including Claude CLI and ChatGPT OAuth |
+| **Price** | Free OSS UI, but a Claude Code plan is still needed today | Free OSS, but you still pay for the underlying runtime plans/seats you use | Claude subscription | Free self-hosted OSS, but infra + provider/API/subscription costs remain |
+
+## Самые важные выводы по matrix
+
+### 1. Наше главное отличие - мы сильнее именно как coding workbench
+
+По frontend/product surface у нас очень большой отрыв в четырёх местах:
+
+- task-scoped logs
+- hunk-level review
+- built-in editor
+- live processes
+
+Это и есть та часть, которую README сейчас продаёт лучше всего, и она реально подтверждается кодом.
+
+### 2. Gastown - реальный конкурент по orchestration, но не по UI
+
+Gastown нельзя сравнивать с нами как с "kanban app". Это скорее process-model orchestrator:
+
+- Mayor
+- mailboxes
+- handoffs
+- witness/deacon monitoring
+- convoys
+- git worktree isolation
+
+Но по UX для review, editor, per-task logs и task attachments он заметно слабее.
+
+### 3. Claude Code Agent Teams - это ближайший конкурент именно по runtime-модели
+
+Если смотреть на core idea:
+
+- team lead
+- teammates
+- mailbox
+- shared task list
+- dependencies
+- direct teammate messaging
+
+то это самый близкий конкурент нашему runtime foundation. Но у них почти нет того UI-слоя, который у нас уже есть как продукт: kanban, per-task review, logs, attachments, processes, editor.
+
+### 4. GoClaw - сильнейший platform competitor, но не лучший coding cockpit
+
+GoClaw выигрывает у нас по:
+
+- multi-provider breadth
+- self-hosted platform maturity
+- Kanban + approvals + task lifecycle
+- OAuth/provider surface
+- multi-tenant / channels / ops
+
+Но проигрывает в IDE-like coding surfaces:
+
+- hunk review
+- per-task code review UX
+- built-in editor
+- live process control
+- task-scoped raw logs as a strong developer cockpit
+
+## Более глубокое чтение каждого конкурента
+
+### Gastown
+
+Что после более глубокого чтения видно особенно ясно:
+
+- Это не просто "ещё один agent manager", а очень осознанная process-model система.
+- Самые load-bearing примитивы у них - `Mayor`, `Witness`, `Deacon`, `Refinery`, `Convoy`, `Hooks`, `Beads`.
+- У них сильный recovery story:
+ - persistent identity
+ - session handoff
+ - recovery mail protocol
+ - watchdog chain
+ - capacity-controlled dispatch
+- Они явно думают не как "чат с LLM", а как "операционная система для swarm of coding agents".
+
+Что тянет вниз:
+
+- setup тяжёлый
+- UI мониторинговый, не IDE-like
+- per-task review/log/editor surfaces слабее
+- часть силы живёт в терминах и process model, а не в простой product UX
+
+### Claude Code Agent Teams
+
+После более глубокого чтения видно:
+
+- Это лучший нативный Claude-first фундамент для team lead + teammates.
+- Shared task list, mailbox, direct teammate messaging и automatic dependency unblocking у них реальные.
+- Есть plan approval loop и hooks-based quality gates.
+- Но feature всё ещё experimental, и docs сами предупреждают про limits around resumption / coordination / shutdown.
+
+Что это значит practically:
+
+- как native runtime foundation это сильная штука
+- как самостоятельный продукт для управления coding team это пока тонко
+- без нашего UI-слоя там очень мало operator ergonomics
+
+### GoClaw
+
+После более глубокого чтения и docs, и кода:
+
+- Это самый сбалансированный platform product в сравнении.
+- У него сильный task engine, approvals, Kanban, workspace, provider layer, OAuth paths, traces, channels.
+- Он лучше остальных выглядит как "готовая self-hosted platform", а не как набор сильных primitives.
+
+Что тянет вниз:
+
+- слабее IDE-like coding workbench
+- infra/setup тяжелее нашего и Claude Code path
+- non-commercial license очень сильно режет "open source leverage"
+
+## Scorecards
+
+Ниже уже не просто feature presence, а моя независимая оценка по 10-балльной шкале.
+
+### 1. Чисто как orchestration engine
+
+| Проект | Оценка | Почему |
+|---|---:|---|
+| **Gastown** | **9.2** | Самый сильный process-model orchestration для coding swarms: mailboxes, handoffs, convoys, witness/deacon, worktrees, merge queue, recovery |
+| **GoClaw** | **8.9** | Самый зрелый durable workflow-state engine: board lifecycle, approvals, `blocked_by`, retry, stale, traces, provider-agnostic task system |
+| **Claude Agent Teams UI + orchestrator** | **7.8** | Сильный local orchestrator и deterministic bootstrap, но task/state engine менее durable и менее mature |
+| **Claude Code Agent Teams** | **7.7** | Хороший native runtime foundation, но lifecycle проще и feature всё ещё experimental |
+
+### 2. Как coding cockpit / agentic IDE
+
+| Проект | Оценка | Почему |
+|---|---:|---|
+| **Claude Agent Teams UI + orchestrator** | **9.4** | Лучший review, per-task logs, built-in editor, live processes, operator control |
+| **GoClaw** | **7.2** | Хороший dashboard/workspace/product UI, но не настолько сильный coding workbench |
+| **Claude Code Agent Teams** | **6.0** | Живые teammate sessions и direct messaging есть, но это всё ещё CLI-native control, не полноценный cockpit |
+| **Gastown** | **5.7** | Сильный TUI/dashboard monitoring, но IDE-like surfaces почти нет |
+
+### 3. Setup / onboarding
+
+| Проект | Оценка | Почему |
+|---|---:|---|
+| **Claude Agent Teams UI + orchestrator** | **8.5** | Самый сильный zero-setup путь для Claude Code сценария |
+| **Claude Code Agent Teams** | **7.2** | Относительно просто, если пользователь уже живёт в Claude Code, но нужен install + experimental flag |
+| **GoClaw** | **6.2** | Lite заметно упрощает вход, но standard edition всё ещё тяжёлая |
+| **Gastown** | **4.6** | Сильный toolchain tax: Go, Git, Dolt, beads, sqlite3, tmux, CLI runtimes, HQ bootstrap |
+
+### 4. Provider flexibility / subscription paths
+
+| Проект | Оценка | Почему |
+|---|---:|---|
+| **GoClaw** | **9.6** | 20+ providers, Claude CLI, ChatGPT OAuth, channels, pooling |
+| **Gastown** | **8.8** | Очень хороший multi-runtime story: Claude Code, Codex, Gemini, Copilot и др. |
+| **Claude Agent Teams UI + orchestrator** | **5.8** | Путь на multi-provider проступает в коде, но продукт всё ещё Claude-first |
+| **Claude Code Agent Teams** | **4.2** | Claude-first by design |
+
+### 5. Maturity / engineering confidence
+
+Это уже composite signal по docs + releases + tests + architectural surface.
+
+| Проект | Оценка | Что учитывал |
+|---|---:|---|
+| **Gastown** | **8.6** | `13.9k★`, `v1.0.0`, `492` `*test.go`, глубокая design-doc surface |
+| **GoClaw** | **8.5** | `v3.6.0`, `351` `*test.go`, очень широкая docs surface, частая релизная активность |
+| **Claude Code Agent Teams** | **7.5** | Огромный repo и релизный cadence сильные, но сама feature experimental |
+| **Claude Agent Teams UI + orchestrator** | **6.9** | UI очень силён, но stars/coverage/maturity пока заметно слабее; у frontend сейчас `0` test files |
+
+## Архитектурный deep-dive
+
+### Coordination topology
+
+| Проект | Топология | Сильная сторона | Ограничение |
+|---|---|---|---|
+| **Наш стек** | lead-centered orchestration + rich operator UI | человек очень хорошо держит команду руками | engine менее durable, много ценности живёт в operator loop |
+| **Gastown** | process-model roles + externalized state via beads/hooks/mail | лучшая декомпозиция swarm как операционной системы | высокая когнитивная и инфраструктурная сложность |
+| **Claude Code Agent Teams** | lead + teammates + peer messaging + shared task list | максимально нативная Claude-first team модель | experimental state machine и тонкий control plane |
+| **GoClaw** | DB-backed task engine + team tools + orchestration modes | самый продуктово цельный runtime | менее выразительный IDE/workbench слой |
+
+### Persistence model
+
+| Проект | Persistence | Что это даёт | Комментарий |
+|---|---|---|---|
+| **Наш стек** | local app state + Claude logs + runtime stores + bootstrap state | сильный session/task visibility для local work | меньше durable workflow truth, чем у `Gastown`/`GoClaw` |
+| **Gastown** | Git worktrees + Beads ledger + Dolt + mail protocol | crash-surviving coordination и сильная work history | сложнее понять и сопровождать |
+| **Claude Code Agent Teams** | local files in `~/.claude/teams` and `~/.claude/tasks` | surprisingly practical lightweight persistence | проще и слабее, чем полноценный DB-backed engine |
+| **GoClaw** | PostgreSQL in standard, SQLite in Lite | самый сильный durable task/store foundation | инфраструктурная цена выше |
+
+### Observability model
+
+| Проект | Лучшее в observability | Что слабее |
+|---|---|---|
+| **Наш стек** | лучший task-scoped log visibility и review-oriented debugging | слабее общий durable ops/trace plane |
+| **Gastown** | сильные OTLP logs, activity feed, structured runtime events | слабее productized per-task log cockpit |
+| **Claude Code Agent Teams** | visibility через sessions and split panes | почти нет отдельного observability product layer |
+| **GoClaw** | traces, audit logs, approvals, task events, activity pages | raw per-task coding logs ощущаются слабее, чем у нас |
+
+### Review / merge model
+
+| Проект | Review model | Practical impact |
+|---|---|---|
+| **Наш стек** | per-task diff review + hunks + comments + approvals | лучший human review loop |
+| **Gastown** | refinery / merge queue / PR-oriented review flow | сильный integration discipline, но слабый UI review cockpit |
+| **Claude Code Agent Teams** | plan approval + hooks quality gates | хороший gate mechanism, но не review product |
+| **GoClaw** | task `in_review` + approve/reject + reviewer agent gates | сильный workflow review, но слабее code-review UX |
+
+## Weighted verdicts
+
+Здесь самый важный момент: **"лучший проект" зависит от весов**.
+Ниже три независимые линзы, каждая со своими весами.
+
+### Lens A - Self-hosted multi-agent product
+
+Веса:
+
+- orchestration engine - 30%
+- product/UI breadth - 25%
+- setup/onboarding - 10%
+- provider flexibility - 15%
+- maturity/confidence - 15%
+- license leverage - 5%
+
+| Проект | Итоговый балл |
+|---|---:|
+| **GoClaw** | **8.1** |
+| **Gastown** | **7.6** |
+| **Наш стек** | **7.5** |
+| **Claude Code Agent Teams** | **6.7** |
+
+Вывод:
+
+- если смотреть на проект как на **самый полноценный self-hosted продукт**, побеждает `GoClaw`
+
+### Lens B - Coding team workstation / agentic IDE
+
+Веса:
+
+- coding cockpit - 35%
+- review/log/debug surfaces - 20%
+- local operator control - 15%
+- setup friction - 10%
+- orchestration engine - 10%
+- maturity/confidence - 10%
+
+| Проект | Итоговый балл |
+|---|---:|
+| **Наш стек** | **8.5** |
+| **GoClaw** | **7.4** |
+| **Claude Code Agent Teams** | **6.8** |
+| **Gastown** | **6.6** |
+
+Вывод:
+
+- если смотреть на проект как на **лучший инструмент для реальной работы над кодом**, побеждаем мы
+
+### Lens C - Open-source orchestration leverage
+
+Веса:
+
+- orchestration engine - 30%
+- engineering confidence - 20%
+- license leverage - 20%
+- provider/runtime flexibility - 15%
+- observability/recovery - 15%
+
+| Проект | Итоговый балл |
+|---|---:|
+| **Gastown** | **8.6** |
+| **GoClaw** | **7.9** |
+| **Наш стек** | **7.0** |
+| **Claude Code Agent Teams** | **5.9** |
+
+Вывод:
+
+- если смотреть на проект как на **наиболее ценный open-source фундамент для серьёзной orchestration-системы**, побеждает `Gastown`
+
+## Независимый итоговый verdict
+
+Если заставить меня выбрать **одного общего победителя как продукта**, то это сейчас:
+
+### **1 место overall - GoClaw**
+
+Почему:
+
+- самый сбалансированный проект
+- сильный engine
+- сильный platform UI
+- сильный provider story
+- сильный self-hosted story
+- сильный docs/release surface
+
+Моя оценка:
+
+- overall: **8.5 / 10**
+- 🎯 8.8 🛡️ 8.6 🧠 5
+
+### **2 место overall - Gastown**
+
+Почему:
+
+- как orchestrator для fleets of coding agents он очень силён
+- архитектурно у него самый яркий process-model характер
+- по recovery / work persistence / worktree isolation он реально впечатляет
+
+Почему не первое место:
+
+- тяжёлый вход
+- слабее product UX
+- слабее review/log/editor cockpit
+
+Моя оценка:
+
+- overall: **8.2 / 10**
+- 🎯 8.6 🛡️ 8.8 🧠 7
+
+### **3 место overall - наш стек**
+
+Почему:
+
+- лучший coding cockpit
+- лучший human-in-the-loop control plane
+- лучший UI для лида coding-команды
+
+Почему не выше:
+
+- orchestration engine менее зрелый, чем у `Gastown` и `GoClaw`
+- maturity signals слабее
+- frontend test surface сейчас объективно плохой
+- multi-provider story пока не настолько продуктово зрелая
+
+Моя оценка:
+
+- overall: **7.9 / 10**
+- 🎯 8.4 🛡️ 7.4 🧠 5
+
+### **4 место overall - Claude Code Agent Teams**
+
+Почему:
+
+- это сильная native runtime функция, но ещё не лучший самостоятельный продукт
+- слишком много experimental caveats
+- почти нет product/UI advantage по сравнению с остальными
+
+Моя оценка:
+
+- overall: **7.1 / 10**
+- 🎯 8.2 🛡️ 6.8 🧠 3
+
+## Кто лучший по конкретным сценариям
+
+| Сценарий | Победитель | Почему |
+|---|---|---|
+| **Лучший overall product** | **GoClaw** | Самый ровный баланс engine + UI + providers + self-hosted maturity |
+| **Лучший pure orchestrator для coding swarms** | **Gastown** | Самый сильный process-model orchestration core |
+| **Лучший native Claude runtime foundation** | **Claude Code Agent Teams** | Самая нативная реализация team lead + teammates внутри Claude Code |
+| **Лучший coding cockpit / agentic IDE** | **наш стек** | Лучшие review, logs, editor, processes, human control |
+
+## Что особенно важно помнить для README
+
+Если мы когда-нибудь будем переписывать публичный `Comparison` в README, то главный честный framing такой:
+
+- против `Gastown` надо продавать `UI/workbench`, а не пытаться спорить, что мы сильнее как process-model orchestrator
+- против `Claude Code Agent Teams` надо продавать "native runtime + настоящий product UI сверху"
+- против `GoClaw` надо продавать "agentic IDE / coding cockpit", а не "более широкий platform product"
+
+## Где у нас реально подтверждён сильный frontend
+
+Это ключевые локальные опоры, на которые можно смело ссылаться внутри команды:
+
+- review cockpit - [ChangeReviewDialog](../../src/renderer/components/team/review/ChangeReviewDialog.tsx)
+- task detail + attachments + comments - [TaskDetailDialog](../../src/renderer/components/team/dialogs/TaskDetailDialog.tsx)
+- task logs - [TaskLogsPanel](../../src/renderer/components/team/taskLogs/TaskLogsPanel.tsx)
+- built-in editor - [ProjectEditorOverlay](../../src/renderer/components/team/editor/ProjectEditorOverlay.tsx)
+- live processes - [ProcessesSection](../../src/renderer/components/team/ProcessesSection.tsx)
+- tool approvals - [ToolApprovalSheet](../../src/renderer/components/team/ToolApprovalSheet.tsx)
+
+Есть и важная продуктовая нюансировка:
+
+- cross-team communication у нас реально есть
+- task attachments у нас реально есть
+- multimodel/provider surface у нас уже проступает в коде
+- но публично и продуктово мы всё ещё остаёмся в первую очередь Claude-first
+
+## Места, где надо быть особенно честными про нас
+
+- `Multi-agent backend` у нас пока не так зрел, как это можно прочитать из одной строки README. В коде есть мосты и статусы для `Anthropic`, `Codex`, `Gemini`, но продуктово основной путь всё ещё Claude-first.
+- `Zero setup` у нас честно сильный именно для Claude Code path.
+- `Cross-team communication` у нас сильнее, чем у этих конкурентов, но cross-team attachments не выглядят как полностью общий happy path.
+
+## Источники
+
+### Наша сторона
+
+- README: [README.md](../../README.md)
+- review UI: [ChangeReviewDialog](../../src/renderer/components/team/review/ChangeReviewDialog.tsx)
+- logs UI: [TaskLogsPanel](../../src/renderer/components/team/taskLogs/TaskLogsPanel.tsx)
+- editor UI: [ProjectEditorOverlay](../../src/renderer/components/team/editor/ProjectEditorOverlay.tsx)
+- processes UI: [ProcessesSection](../../src/renderer/components/team/ProcessesSection.tsx)
+- task workflow UI: [TaskDetailDialog](../../src/renderer/components/team/dialogs/TaskDetailDialog.tsx)
+- approvals UI: [ToolApprovalSheet](../../src/renderer/components/team/ToolApprovalSheet.tsx)
+
+### Gastown
+
+- Official repo:
+- README:
+- Latest release:
+
+### Claude Code Agent Teams
+
+- Agent Teams docs:
+- CLI auth docs:
+- Claude Code repo:
+- Latest release:
+
+### GoClaw
+
+- Official repo:
+- README:
+- Full docs export:
+- Latest release:
+
+## Bottom line
+
+Если брать реальные продукты, то текущая внутренняя картина такая:
+
+- **Gastown** - конкурент по orchestration runtime
+- **Claude Code Agent Teams** - конкурент по базовой runtime-модели team lead + teammates
+- **GoClaw** - конкурент по platform orchestration product
+- **мы** - сильнее как agentic IDE / coding-team cockpit
+
+То есть наш главный moat сейчас не "самый широкий agent platform".
+Он в том, что мы уже собрали более сильное рабочее место для лида coding-команды, чем у этих трёх систем.
diff --git a/docs/team-management/README.md b/docs/team-management/README.md
index 8778577a..514fd0ae 100644
--- a/docs/team-management/README.md
+++ b/docs/team-management/README.md
@@ -5,9 +5,9 @@
## Что делает
- Видеть состав команды и роли участников
-- Kanban-доска с 5 колонками (TODO → IN PROGRESS → DONE → REVIEW → APPROVED)
+- Kanban-доска с 5 колонками: TODO, IN PROGRESS, REVIEW, DONE, APPROVED
- Отправка сообщений тиммейтам через inbox-файлы
-- Review flow: автоматическое назначение ревьюверов или ручное ревью
+- Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE
- Live updates через file watcher
## Документация
@@ -23,6 +23,8 @@
## Ключевые решения
+⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md).
+
### 1. Messaging: Inbox-файлы
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
@@ -36,8 +38,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
### 3. Review Flow: Approve / Request Changes
- Есть ревьюверы в команде → автоматическое назначение через inbox
+- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW`
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
-- При Request Changes → юзер описывает проблему (опционально) → задача к исходному owner
+- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix`
### 4. Atomic Write
Все записи через tmp + rename для предотвращения corrupted JSON.
@@ -62,6 +65,10 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
### Review Flow: Approve / Request Changes
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
- Комментарий при Request Changes — опционален
+- Manual UI допускает два valid path:
+ - `DONE -> REVIEW -> APPROVED`
+ - `DONE -> APPROVED` как быстрый manual approval
+- `Request Changes` снимает kanban-state запись и возвращает задачу в `pending` с `needsFix`
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP
### Members: полный список через union
@@ -75,8 +82,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
- `IDLE`: idle > 5 минут
- `TERMINATED`: получен `shutdown_response` с `approve: true`
-### @dnd-kit: click-to-move для MVP
-- MVP: выбор колонки через select/dropdown (click-to-move) — проще и надёжнее
+### @dnd-kit and review transitions
+- Переходы между review-колонками делаются через card actions в UI
+- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки
- Phase 2: полноценный D&D через `@dnd-kit`
---
diff --git a/docs/team-management/implementation.md b/docs/team-management/implementation.md
index 5cde66e3..4f82b9ec 100644
--- a/docs/team-management/implementation.md
+++ b/docs/team-management/implementation.md
@@ -1,5 +1,9 @@
# Implementation Plan (v7 — Production-Ready Architecture)
+> Historical note
+> This is a planning and architecture document, not the source of truth for the current shipped product behavior.
+> For the current review flow, see [README.md](./README.md) and [kanban-design.md](./kanban-design.md).
+
## Обзор
~34 новых файлов + 18 модификаций + 18 тестов. Vertical slices (не backend-first).
diff --git a/docs/team-management/kanban-design.md b/docs/team-management/kanban-design.md
index e0459668..568e3f0e 100644
--- a/docs/team-management/kanban-design.md
+++ b/docs/team-management/kanban-design.md
@@ -3,9 +3,11 @@
## Flow
```
-TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPROVED
- ↑ |
- └── Fix (error) ←──┘
+TODO → IN PROGRESS → DONE ───────────────→ APPROVED
+ │ ↑
+ └→ REVIEW ───────────┘
+ │
+ └→ pending + needsFix
```
## Колонки
@@ -15,8 +17,8 @@ TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPRO
| **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя |
| **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает |
| **DONE** | task.status = completed | Автоматически | Агент завершил |
-| **REVIEW** | kanban-state.json | Юзер (drag-and-drop) | На проверке |
-| **APPROVED** | kanban-state.json | Юзер (drag-and-drop) | Одобрено |
+| **REVIEW** | kanban-state.json | Юзер/UI actions | На проверке |
+| **APPROVED** | kanban-state.json | Юзер/UI actions | Одобрено |
---
@@ -90,9 +92,20 @@ const tasks = await getAllTasks(teamName);
## Review Flow
+⚠️ Этот файл описывает текущий продуктовый contract review flow. Исторические iteration-доки могут расходиться с ним.
+
+### Manual actions from DONE
+
+Из `DONE` сейчас есть два валидных пользовательских сценария:
+
+1. **Request Review** - отправить задачу в `REVIEW`
+2. **Approve** - сразу перевести задачу в `APPROVED` как manual shortcut
+
+`REVIEW` нужен, когда пользователь хочет отдельный шаг проверки на доске, включая reviewer-driven flow или ручную проверку через UI. Но `REVIEW` не является обязательным промежуточным шагом для каждого manual approval.
+
### Перемещение DONE → REVIEW
-1. Юзер перетаскивает карточку из DONE в REVIEW
+1. Юзер переводит карточку из DONE в REVIEW через UI action
2. Проверяем `kanbanState.reviewers[]`
3. **Есть ревьюверы**:
- Берём первого свободного (round-robin с балансировкой по количеству активных ревью)
@@ -109,7 +122,14 @@ const tasks = await getAllTasks(teamName);
```
4. **Нет ревьюверов**:
- Записываем в kanban-state: `{ column: "review", reviewStatus: "pending" }`
- - Юзер сам ревьювит через UI (кнопки OK / Error)
+ - Юзер сам ревьювит через UI (кнопки Approve / Request Changes)
+
+### Прямое DONE → APPROVED
+
+Юзер может сразу нажать **Approve** на карточке в `DONE`:
+- kanban-state: `{ column: "approved" }`
+- отдельный заход в `REVIEW` не требуется
+- это manual shortcut и текущее допустимое поведение UI
### Review Result
@@ -129,8 +149,10 @@ const tasks = await getAllTasks(teamName);
2. Появляется ReviewDialog — textarea для описания проблемы (опционально)
3. Юзер нажимает "Отправить"
4. Действия:
- - kanban-state: удаляем запись для этой задачи (вернётся в IN PROGRESS по status)
- - task file: `status = "in_progress"` (atomic write)
+ - kanban-state: удаляем запись для этой задачи
+ - task file: `status = "pending"`
+ - reviewState становится `needsFix`
+ - в UI задача возвращается в TODO/backlog path с маркером Needs Fixes
- Inbox к исходному owner:
```json
{
@@ -150,30 +172,31 @@ const tasks = await getAllTasks(teamName);
### MVP: Click-to-Move
-Для MVP вместо drag-and-drop используется **click-to-move**: каждая карточка имеет кнопку или select-dropdown для смены колонки. Это проще реализовать и достаточно для первой версии.
+Для текущего UI переходы между review-колонками делаются через **card actions** на карточке. Отдельный DnD сейчас используется для перестановки задач внутри колонки, а не для review state transitions.
```
[Task Card]
Subject: Rename package in pubspec.yaml
Owner: worker-1
- [Move to: REVIEW ▼] ← dropdown или кнопка
+ [Approve] [Request review]
```
-Разрешённые переходы через click-to-move:
+Разрешённые review-переходы через UI actions:
| Откуда → Куда | Действие |
|----------------|----------|
| DONE → REVIEW | kanban-state: review + reviewStatus: pending. Inbox ревьюверу если есть |
+| DONE → APPROVED (Approve) | kanban-state: approved |
| REVIEW → APPROVED (Approve) | kanban-state: approved |
-| REVIEW → DONE (Request Changes) | Dialog → task: in_progress, kanban: remove, inbox к owner |
+| REVIEW → TODO/Needs Fixes (Request Changes) | Dialog → task: pending + needsFix, kanban: remove, inbox к owner |
| APPROVED → DONE | kanban-state: remove (возвращается в DONE по status) |
Не разрешено:
- TODO → IN PROGRESS (агент берёт сам через TaskUpdate)
- IN PROGRESS → DONE (агент завершает сам через TaskUpdate)
-### Phase 2: Полноценный D&D через @dnd-kit
+### Phase 2: Полноценный D&D для state transitions
-`@dnd-kit` уже есть в зависимостях проекта (используется для перетаскивания табов). В Phase 2 добавить drag-and-drop для всех разрешённых переходов.
+`@dnd-kit` уже используется для ordering. В Phase 2 можно добавить drag-and-drop и для самих state transitions, если это понадобится по UX.
---
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 69c197c5..67727de3 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -11,9 +11,18 @@ import type { Plugin } from 'vite'
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'))
const prodDeps = Object.keys(pkg.dependencies || {})
+// Fastify and its plugins rely on runtime module resolution that breaks when bundled.
+const runtimeExternalDeps = new Set([
+ 'node-pty',
+ 'agent-teams-controller',
+ 'fastify',
+ '@fastify/cors',
+ '@fastify/static',
+])
+
// node-pty is a native addon that cannot be bundled by Rollup.
// It must remain external and be loaded at runtime via require().
-const bundledDeps = prodDeps.filter(d => d !== 'node-pty' && d !== 'agent-teams-controller')
+const bundledDeps = prodDeps.filter(d => !runtimeExternalDeps.has(d))
// Rollup plugin: stub out native .node addon imports with empty modules.
// ssh2 and cpu-features use optional native bindings that can't be bundled,
@@ -122,7 +131,8 @@ export default defineConfig({
},
renderer: {
optimizeDeps: {
- include: ['@codemirror/language-data']
+ include: ['@codemirror/language-data'],
+ exclude: ['@claude-teams/agent-graph']
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
@@ -133,7 +143,8 @@ export default defineConfig({
alias: {
'@renderer': resolve(__dirname, 'src/renderer'),
'@shared': resolve(__dirname, 'src/shared'),
- '@main': resolve(__dirname, 'src/main')
+ '@main': resolve(__dirname, 'src/main'),
+ '@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts')
}
},
plugins: [react(), ...sentryPlugins],
diff --git a/landing/content/ar.json b/landing/content/ar.json
index 1cb5e5ee..22b1f44d 100644
--- a/landing/content/ar.json
+++ b/landing/content/ar.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "ما هو Claude Agent Teams؟",
- "answer": "تطبيق سطح مكتب يتيح لك تشكيل فرق وكلاء ذكاء اصطناعي مدعومة بـ Claude Code. لكل وكيل دور، يعمل بشكل مستقل، ويتعاون مع زملائه — يُدار كله من لوحة كانبان."
+ "answer": "تطبيق سطح مكتب لتنظيم فرق وكلاء الذكاء الاصطناعي عبر طبقة تنسيق محلية خاصة بنا. لكل وكيل دور، يعمل بشكل مستقل، ويتعاون عبر لوحة كانبان، ويمكن تشغيله مع Anthropic أو Codex."
},
{
"id": "isFree",
"question": "هل هو مجاني فعلاً؟",
- "answer": "نعم. التطبيق نفسه مجاني 100% ومفتوح المصدر. تحتاج فقط اشتراك Claude (خطة Max أو Pro) — هذا كل شيء. يتم تثبيت وإعداد Claude Code تلقائياً من خلال تطبيقنا، بدون الحاجة للطرفية."
+ "answer": "نعم. التطبيق مجاني 100% ومفتوح المصدر. التطبيق نفسه لا يملك خطة مدفوعة. لتشغيل الوكلاء تحتاج فقط إلى وصول إلى مزود أو runtime مدعوم مثل Anthropic أو Codex."
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "ماذا أحتاج للبدء؟",
- "answer": "فقط ثبّت التطبيق — يتضمن تثبيت ومصادقة Claude Code المدمجة. البدء بدون إعداد يجعلك تعمل في دقائق."
+ "answer": "فقط ثبّت التطبيق - وسيرشدك من الواجهة لاكتشاف الـ runtime وتسجيل دخول المزود. البدء بدون إعداد يجعلك تعمل في دقائق."
}
],
"download": {
diff --git a/landing/content/de.json b/landing/content/de.json
index 6acb8c05..c5bc894e 100644
--- a/landing/content/de.json
+++ b/landing/content/de.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "Was ist Claude Agent Teams?",
- "answer": "Eine Desktop-App, mit der Sie KI-Agenten-Teams mit Claude Code zusammenstellen können. Jeder Agent hat eine Rolle, arbeitet autonom und kollaboriert mit Teammitgliedern — alles über ein Kanban-Board verwaltet."
+ "answer": "Eine Desktop-App zur Orchestrierung von KI-Agententeams mit unserer eigenen lokalen Koordinationsschicht. Agenten haben Rollen, arbeiten autonom, kollaborieren über ein Kanban-Board und können mit Anthropic oder Codex laufen."
},
{
"id": "isFree",
"question": "Ist es wirklich kostenlos?",
- "answer": "Ja. Die App ist 100% kostenlos und Open Source. Sie brauchen nur ein Claude-Abo (Max oder Pro) — das war's. Claude Code wird automatisch installiert und eingerichtet."
+ "answer": "Ja. Die App ist 100% kostenlos und Open Source. Die App selbst hat kein Bezahlmodell. Um Agenten auszuführen, brauchen Sie nur Zugriff auf einen unterstützten Provider bzw. Runtime wie Anthropic oder Codex."
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "Was brauche ich zum Start?",
- "answer": "Einfach die App installieren — Claude Code Installation und Authentifizierung sind integriert. Zero-Setup-Onboarding bringt Sie in Minuten zum Laufen."
+ "answer": "Einfach die App installieren - sie führt Sie in der UI durch Runtime-Erkennung und Provider-Authentifizierung. Zero-Setup-Onboarding bringt Sie in Minuten zum Laufen."
}
],
"download": {
diff --git a/landing/content/en.json b/landing/content/en.json
index 3f3c8529..f7aaa071 100644
--- a/landing/content/en.json
+++ b/landing/content/en.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "What is Claude Agent Teams?",
- "answer": "A desktop app that lets you assemble AI agent teams powered by Claude Code. Each agent has a role, works autonomously, and collaborates with teammates — all managed through a kanban board."
+ "answer": "A desktop app for orchestrating AI agent teams with our own local coordination layer. Agents have roles, work autonomously, collaborate through a kanban board, and can run on Anthropic or Codex."
},
{
"id": "isFree",
"question": "Is it really free?",
- "answer": "Yes. The app itself is 100% free and open source. You just need a Claude subscription (Max or Pro plan) — that's it. Claude Code is installed and set up automatically through our app, no terminal required."
+ "answer": "Yes. The app itself is 100% free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex."
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "What do I need to get started?",
- "answer": "Just install the app — it includes built-in Claude Code installation and authentication. Zero-setup onboarding gets you running in minutes."
+ "answer": "Just install the app - it guides runtime detection and provider authentication from the UI. Zero-setup onboarding gets you running in minutes."
}
],
"download": {
diff --git a/landing/content/es.json b/landing/content/es.json
index 994a1fe8..f60a587b 100644
--- a/landing/content/es.json
+++ b/landing/content/es.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "¿Qué es Claude Agent Teams?",
- "answer": "Una app de escritorio que te permite montar equipos de agentes IA con Claude Code. Cada agente tiene un rol, trabaja de forma autónoma y colabora con compañeros — todo gestionado desde un tablero kanban."
+ "answer": "Una app de escritorio para orquestar equipos de agentes IA con nuestra propia capa local de coordinación. Cada agente tiene un rol, trabaja de forma autónoma, colabora a través de un tablero kanban y puede ejecutarse con Anthropic o Codex."
},
{
"id": "isFree",
"question": "¿Es realmente gratis?",
- "answer": "Sí. La app es 100% gratuita y de código abierto. Solo necesitas una suscripción a Claude (plan Max o Pro) — eso es todo. Claude Code se instala y configura automáticamente a través de nuestra app, sin necesidad de terminal."
+ "answer": "Sí. La app es 100% gratuita y de código abierto. La app no tiene un plan de pago propio. Para ejecutar agentes solo necesitas acceso a un proveedor/runtime compatible, como Anthropic o Codex."
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "¿Qué necesito para empezar?",
- "answer": "Solo instala la app — incluye instalación y autenticación integrada de Claude Code. El onboarding sin configuración te pone en marcha en minutos."
+ "answer": "Solo instala la app - te guía en la detección del runtime y la autenticación del proveedor desde la interfaz. El onboarding sin configuración te pone en marcha en minutos."
}
],
"download": {
diff --git a/landing/content/fr.json b/landing/content/fr.json
index 77eb1ec2..2febf60c 100644
--- a/landing/content/fr.json
+++ b/landing/content/fr.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "Qu'est-ce que Claude Agent Teams ?",
- "answer": "Une application de bureau qui vous permet d'assembler des équipes d'agents IA propulsés par Claude Code. Chaque agent a un rôle, travaille de manière autonome et collabore avec ses coéquipiers — le tout géré via un tableau kanban."
+ "answer": "Une application de bureau pour orchestrer des équipes d'agents IA avec notre propre couche de coordination locale. Les agents ont des rôles, travaillent de façon autonome, collaborent sur un tableau kanban et peuvent s'exécuter avec Anthropic ou Codex."
},
{
"id": "isFree",
"question": "C'est vraiment gratuit ?",
- "answer": "Oui. L'application est 100% gratuite et open source. Vous avez juste besoin d'un abonnement Claude (Max ou Pro) — c'est tout. Claude Code est installé et configuré automatiquement, sans terminal."
+ "answer": "Oui. L'application est 100% gratuite et open source. L'application n'a pas d'offre payante. Pour exécuter des agents, vous avez seulement besoin d'un accès à un provider/runtime pris en charge, comme Anthropic ou Codex."
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "De quoi ai-je besoin pour commencer ?",
- "answer": "Installez simplement l'application — elle inclut l'installation et l'authentification intégrées de Claude Code. L'onboarding zéro-configuration vous fait démarrer en quelques minutes."
+ "answer": "Installez simplement l'application - elle vous guide pour la détection du runtime et l'authentification du provider depuis l'interface. L'onboarding zéro-configuration vous fait démarrer en quelques minutes."
}
],
"download": {
diff --git a/landing/content/hi.json b/landing/content/hi.json
index 531999cc..d13fc263 100644
--- a/landing/content/hi.json
+++ b/landing/content/hi.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "Claude Agent Teams क्या है?",
- "answer": "एक डेस्कटॉप ऐप जो Claude Code द्वारा संचालित AI एजेंट टीमें बनाने देता है। हर एजेंट की एक भूमिका है, स्वायत्त रूप से काम करता है, और साथियों के साथ सहयोग करता है — सब कानबन बोर्ड से मैनेज होता है।"
+ "answer": "यह एक डेस्कटॉप ऐप है जो हमारी अपनी लोकल coordination layer के साथ AI agent teams को orchestrate करता है। हर agent की एक भूमिका होती है, वह स्वायत्त रूप से काम करता है, kanban board पर सहयोग करता है, और Anthropic या Codex पर चल सकता है।"
},
{
"id": "isFree",
"question": "क्या यह सच में मुफ़्त है?",
- "answer": "हाँ। ऐप खुद 100% मुफ़्त और ओपन सोर्स है। आपको बस Claude सब्सक्रिप्शन (Max या Pro प्लान) चाहिए — बस इतना ही। Claude Code हमारे ऐप के ज़रिए अपने आप इंस्टॉल और सेटअप हो जाता है, टर्मिनल की ज़रूरत नहीं।"
+ "answer": "हाँ। ऐप 100% मुफ़्त और ओपन सोर्स है। ऐप का अपना कोई paid plan नहीं है। agents चलाने के लिए आपको सिर्फ़ किसी supported provider/runtime, जैसे Anthropic या Codex, का access चाहिए।"
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "शुरू करने के लिए क्या चाहिए?",
- "answer": "बस ऐप इंस्टॉल करें — इसमें Claude Code इंस्टॉलेशन और ऑथेंटिकेशन बिल्ट-इन है। शून्य-सेटअप ऑनबोर्डिंग से मिनटों में शुरू हो जाएँगे।"
+ "answer": "बस ऐप इंस्टॉल करें - यह UI से runtime detection और provider authentication में गाइड करता है। zero-setup onboarding आपको कुछ ही मिनटों में शुरू करा देता है।"
}
],
"download": {
diff --git a/landing/content/ja.json b/landing/content/ja.json
index 695eef57..7b5beac9 100644
--- a/landing/content/ja.json
+++ b/landing/content/ja.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "Claude Agent Teamsとは?",
- "answer": "Claude Codeを活用したAIエージェントチームを編成できるデスクトップアプリです。各エージェントは役割を持ち、自律的に作業し、チームメイトと連携します — すべてカンバンボードで管理。"
+ "answer": "独自のローカル協調レイヤーでAIエージェントチームをオーケストレーションできるデスクトップアプリです。各エージェントは役割を持ち、自律的に動き、カンバン上で連携し、Anthropic または Codex で実行できます。"
},
{
"id": "isFree",
"question": "本当に無料ですか?",
- "answer": "はい。アプリは100%無料でオープンソースです。Claudeサブスクリプション(MaxまたはProプラン)があればOK。Claude Codeは自動でインストール・セットアップされます。"
+ "answer": "はい。アプリは100%無料のオープンソースです。アプリ自体に有料プランはありません。エージェントを実行するには、Anthropic や Codex など対応する provider/runtime へのアクセスだけが必要です。"
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "始めるには何が必要ですか?",
- "answer": "アプリをインストールするだけ — Claude Codeのインストールと認証が組み込まれています。ゼロ設定のオンボーディングで数分で開始できます。"
+ "answer": "アプリをインストールするだけです - UI 上で runtime の検出と provider 認証を案内します。ゼロ設定のオンボーディングで数分で開始できます。"
}
],
"download": {
diff --git a/landing/content/pt.json b/landing/content/pt.json
index 87e17e23..cee9580a 100644
--- a/landing/content/pt.json
+++ b/landing/content/pt.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "O que é Claude Agent Teams?",
- "answer": "Um app desktop que permite montar equipes de agentes IA alimentadas por Claude Code. Cada agente tem um papel, trabalha de forma autônoma e colabora com colegas — tudo gerenciado por um quadro kanban."
+ "answer": "Um app desktop para orquestrar equipes de agentes IA com nossa própria camada local de coordenação. Os agentes têm papéis, trabalham de forma autônoma, colaboram em um quadro kanban e podem rodar com Anthropic ou Codex."
},
{
"id": "isFree",
"question": "É realmente grátis?",
- "answer": "Sim. O app em si é 100% gratuito e open source. Você só precisa de uma assinatura Claude (plano Max ou Pro) — só isso. O Claude Code é instalado e configurado automaticamente pelo nosso app, sem precisar do terminal."
+ "answer": "Sim. O app é 100% gratuito e open source. O app não tem plano pago próprio. Para rodar agentes, você só precisa de acesso a um provider/runtime compatível, como Anthropic ou Codex."
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "O que preciso para começar?",
- "answer": "Apenas instale o app — inclui instalação e autenticação integrada do Claude Code. O onboarding sem configuração te coloca em ação em minutos."
+ "answer": "Basta instalar o app - ele guia a detecção do runtime e a autenticação do provider pela interface. O onboarding sem configuração coloca você em ação em minutos."
}
],
"download": {
diff --git a/landing/content/ru.json b/landing/content/ru.json
index c4767a50..9f91b1ef 100644
--- a/landing/content/ru.json
+++ b/landing/content/ru.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "Что такое Claude Agent Teams?",
- "answer": "Десктопное приложение, которое позволяет собирать команды ИИ-агентов на базе Claude Code. Каждый агент имеет роль, работает автономно и взаимодействует с тиммейтами — всё управляется через канбан-доску."
+ "answer": "Десктопное приложение для оркестрации команд ИИ-агентов с нашей собственной локальной координацией. У агентов есть роли, они работают автономно, взаимодействуют через канбан-доску и могут запускаться на Anthropic или Codex."
},
{
"id": "isFree",
"question": "Это действительно бесплатно?",
- "answer": "Да. Само приложение полностью бесплатное и с открытым кодом. Вам нужна только подписка Claude (Max или Pro план) — и всё. Claude Code устанавливается и настраивается автоматически через наше приложение, без терминала."
+ "answer": "Да. Само приложение полностью бесплатное и с открытым кодом. У него нет собственного платного тарифа. Для запуска агентов нужен только доступ к поддерживаемому провайдеру/рантайму, например Anthropic или Codex."
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "Что нужно для начала?",
- "answer": "Просто установите приложение — оно включает встроенную установку и аутентификацию Claude Code. Онбординг без настройки запустит вас за минуты."
+ "answer": "Просто установите приложение - оно проведёт вас через определение рантайма и аутентификацию провайдера прямо в интерфейсе. Онбординг без настройки запустит вас за минуты."
}
],
"download": {
diff --git a/landing/content/zh.json b/landing/content/zh.json
index 6a34c701..c02dc42c 100644
--- a/landing/content/zh.json
+++ b/landing/content/zh.json
@@ -39,12 +39,12 @@
{
"id": "whatIsIt",
"question": "什么是 Claude Agent Teams?",
- "answer": "一个桌面应用,让你组建由 Claude Code 驱动的 AI 智能体团队。每个智能体有自己的角色,自主工作,与队友协作——一切通过看板管理。"
+ "answer": "一个桌面应用,通过我们自己的本地协调层来编排 AI 智能体团队。每个智能体都有角色,可自主工作,在看板上协作,并且可以运行在 Anthropic 或 Codex 上。"
},
{
"id": "isFree",
"question": "真的免费吗?",
- "answer": "是的。应用本身 100% 免费且开源。你只需要 Claude 订阅(Max 或 Pro 方案)就够了。Claude Code 通过我们的应用自动安装和配置,无需使用终端。"
+ "answer": "是的。应用本身 100% 免费且开源。应用没有自己的付费方案。要运行智能体,你只需要接入受支持的 provider/runtime,例如 Anthropic 或 Codex。"
},
{
"id": "platforms",
@@ -64,7 +64,7 @@
{
"id": "requirements",
"question": "开始需要什么?",
- "answer": "只需安装应用——内置 Claude Code 安装和认证功能。零配置启动,几分钟即可运行。"
+ "answer": "只需安装应用 - 它会在界面中引导你完成 runtime 检测和 provider 认证。零配置上手,几分钟即可运行。"
}
],
"download": {
diff --git a/package.json b/package.json
index e5defa4b..9c9c5cdc 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
]
},
"dependencies": {
+ "@claude-teams/agent-graph": "workspace:*",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp": "^6.0.3",
@@ -121,7 +122,6 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"agent-teams-controller": "workspace:*",
- "@claude-teams/agent-graph": "workspace:*",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -139,12 +139,14 @@
"lucide-react": "^0.577.0",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.12.3",
+ "motion": "12.38.0",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2",
"react-markdown": "^10.1.0",
+ "react-modal-sheet": "5.6.0",
"react-resizable": "^3.1.3",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
diff --git a/packages/agent-graph/package.json b/packages/agent-graph/package.json
index 0144aa33..fef2c312 100644
--- a/packages/agent-graph/package.json
+++ b/packages/agent-graph/package.json
@@ -12,8 +12,8 @@
}
},
"peerDependencies": {
- "react": "^18.0.0",
- "react-dom": "^18.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0",
"lucide-react": ">=0.300.0"
},
"dependencies": {
diff --git a/packages/agent-graph/src/canvas/background-layer.ts b/packages/agent-graph/src/canvas/background-layer.ts
index ea181392..b560fd0f 100644
--- a/packages/agent-graph/src/canvas/background-layer.ts
+++ b/packages/agent-graph/src/canvas/background-layer.ts
@@ -15,38 +15,200 @@ export interface DepthParticle {
brightness: number;
speed: number;
depth: number;
+ twinkleOffset: number;
+ twinkleSpeed: number;
+ twinkleAmount: number;
+ flickerOffset: number;
+ flickerSpeed: number;
+ flickerAmount: number;
+ haloStrength: number;
}
export function createDepthParticles(w: number, h: number): DepthParticle[] {
const particles: DepthParticle[] = [];
for (let i = 0; i < BACKGROUND.starCount; i++) {
- particles.push({
- x: Math.random() * w,
- y: Math.random() * h,
- size: 0.3 + Math.random() * 1.2,
- brightness: 0.15 + Math.random() * 0.4,
- speed: 0.05 + Math.random() * 0.15,
- depth: Math.random(),
- });
+ particles.push(createDepthParticle(w, h));
}
return particles;
}
+function createDepthParticle(w: number, h: number, spawnAbove = false): DepthParticle {
+ const depth = Math.random();
+ const sizeBias = Math.pow(Math.random(), 2.35);
+ const size = 0.34 + sizeBias * 1.18 + (1 - depth) * 0.16;
+ const brightness = 0.22 + sizeBias * 0.34 + (1 - depth) * 0.08;
+
+ return {
+ x: Math.random() * w,
+ y: spawnAbove ? -8 - Math.random() * Math.max(12, h * 0.12) : Math.random() * h,
+ size,
+ brightness,
+ speed: 0.04 + Math.random() * 0.09 + (1 - depth) * 0.04,
+ depth,
+ twinkleOffset: Math.random() * Math.PI * 2,
+ twinkleSpeed: 0.18 + Math.random() * 0.4,
+ twinkleAmount: 0.05 + Math.random() * 0.08,
+ flickerOffset: Math.random() * Math.PI * 2,
+ flickerSpeed: 0.9 + Math.random() * 2.2,
+ flickerAmount: 0.015 + Math.random() * 0.04,
+ haloStrength: 0.08 + Math.random() * 0.14,
+ };
+}
+
export function updateDepthParticles(
particles: DepthParticle[],
w: number,
h: number,
- dt: number,
+ dt: number
): void {
for (const p of particles) {
p.y += p.speed * dt * 20;
if (p.y > h + 5) {
- p.y = -5;
- p.x = Math.random() * w;
+ Object.assign(p, createDepthParticle(w, h, true));
}
}
}
+// ─── Shooting Stars ────────────────────────────────────────────────────────
+
+export interface ShootingStar {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ travel: number;
+ maxTravel: number;
+ length: number;
+ thickness: number;
+ brightness: number;
+}
+
+export interface ShootingStarField {
+ active: ShootingStar[];
+ spawnCooldown: number;
+}
+
+export function createShootingStarField(): ShootingStarField {
+ return {
+ active: [],
+ spawnCooldown: randomShootingStarCooldown(),
+ };
+}
+
+export function updateShootingStarField(
+ field: ShootingStarField,
+ w: number,
+ h: number,
+ dt: number
+): void {
+ field.spawnCooldown -= dt;
+ if (field.spawnCooldown <= 0 && field.active.length < 1) {
+ field.active.push(createShootingStar(w, h));
+ field.spawnCooldown = randomShootingStarCooldown();
+ }
+
+ for (let i = field.active.length - 1; i >= 0; i--) {
+ const star = field.active[i];
+ star.travel += Math.hypot(star.vx, star.vy) * dt;
+ star.x += star.vx * dt;
+ star.y += star.vy * dt;
+
+ if (star.travel >= star.maxTravel) {
+ field.active.splice(i, 1);
+ }
+ }
+}
+
+function createShootingStar(w: number, h: number): ShootingStar {
+ const margin = 60;
+ const sizeBias = Math.pow(Math.random(), 1.9);
+ const sizeScale = 0.68 + sizeBias * 0.92;
+ const { x, y, angle } = createShootingStarSpawn(w, h, margin);
+ const speed = 58 + sizeScale * 18 + Math.random() * 10;
+ const vx = Math.cos(angle) * speed;
+ const vy = Math.sin(angle) * speed;
+ const maxTravel = speed * computeShootingStarExitTime(x, y, vx, vy, w, h, margin + 28);
+
+ return {
+ x,
+ y,
+ vx,
+ vy,
+ travel: 0,
+ maxTravel,
+ length: 14 + sizeScale * (10 + Math.random() * 8),
+ thickness: 0.34 + sizeScale * 0.34,
+ brightness: 0.14 + sizeScale * 0.09 + Math.random() * 0.03,
+ };
+}
+
+function randomShootingStarCooldown(): number {
+ return 16 + Math.random() * 14;
+}
+
+function createShootingStarSpawn(
+ w: number,
+ h: number,
+ margin: number
+): { x: number; y: number; angle: number } {
+ const edgeOffset = Math.random() * Math.max(24, Math.min(w, h) * 0.06);
+ const variant = Math.floor(Math.random() * 4);
+
+ switch (variant) {
+ case 0:
+ return {
+ x: w + margin + edgeOffset,
+ y: h * (0.06 + Math.random() * 0.22),
+ angle: Math.PI - (0.3 + Math.random() * 0.12),
+ };
+ case 1:
+ return {
+ x: -margin - edgeOffset,
+ y: h * (0.06 + Math.random() * 0.22),
+ angle: 0.3 + Math.random() * 0.12,
+ };
+ case 2:
+ return {
+ x: w * (0.08 + Math.random() * 0.34),
+ y: -margin - edgeOffset,
+ angle: 0.96 + Math.random() * 0.18,
+ };
+ default:
+ return {
+ x: w * (0.58 + Math.random() * 0.34),
+ y: -margin - edgeOffset,
+ angle: Math.PI - (0.96 + Math.random() * 0.18),
+ };
+ }
+}
+
+function computeShootingStarExitTime(
+ x: number,
+ y: number,
+ vx: number,
+ vy: number,
+ w: number,
+ h: number,
+ margin: number
+): number {
+ const exitTimes: number[] = [];
+
+ if (vx > 0.001) {
+ exitTimes.push((w + margin - x) / vx);
+ } else if (vx < -0.001) {
+ exitTimes.push((-margin - x) / vx);
+ }
+
+ if (vy > 0.001) {
+ exitTimes.push((h + margin - y) / vy);
+ } else if (vy < -0.001) {
+ exitTimes.push((-margin - y) / vy);
+ }
+
+ const positiveExitTimes = exitTimes.filter((time) => time > 0);
+ return Math.max(positiveExitTimes.length > 0 ? Math.min(...positiveExitTimes) : 0.001, 0.001);
+}
+
// ─── Background Drawing ─────────────────────────────────────────────────────
/**
@@ -57,9 +219,10 @@ export function drawBackground(
w: number,
h: number,
particles: DepthParticle[],
+ shootingStars: ShootingStarField,
camera: { x: number; y: number; zoom: number },
time: number,
- options?: { showHexGrid?: boolean; showStarField?: boolean },
+ options?: { showHexGrid?: boolean; showStarField?: boolean }
): void {
const showStars = options?.showStarField ?? true;
const showHex = options?.showHexGrid ?? true;
@@ -70,23 +233,22 @@ export function drawBackground(
// Depth star field
if (showStars) {
- for (const p of particles) {
- const parallax = 1 - p.depth * 0.3;
- const sx = p.x + camera.x * parallax * 0.02;
- const sy = p.y + camera.y * parallax * 0.02;
- const twinkle = 0.7 + 0.3 * Math.sin(time * 2 + p.x * 0.01);
- const alpha = p.brightness * twinkle;
+ const centerX = w * 0.5;
+ const centerY = h * 0.5;
- ctx.fillStyle = COLORS.holoBright + alphaHex(alpha);
- ctx.beginPath();
- ctx.arc(
- ((sx % w) + w) % w,
- ((sy % h) + h) % h,
- p.size,
- 0,
- Math.PI * 2,
- );
- ctx.fill();
+ for (const p of particles) {
+ const parallax = 1.06 - p.depth * 0.5;
+ const sx = p.x + camera.x * parallax * 0.068;
+ const sy = p.y + camera.y * parallax * 0.068;
+ const positionScale = getStarPositionScale(camera.zoom, p.depth);
+ const starX = projectZoomedWrappedCoord(sx, w, centerX, positionScale);
+ const starY = projectZoomedWrappedCoord(sy, h, centerY, positionScale);
+ const primaryTwinkle = Math.sin(time * p.twinkleSpeed + p.twinkleOffset) * p.twinkleAmount;
+ const secondaryTwinkle = Math.sin(time * p.flickerSpeed + p.flickerOffset) * p.flickerAmount;
+ const twinkle = clamp(1 + primaryTwinkle + secondaryTwinkle, 0.82, 1.22);
+ const zoomScale = getStarZoomScale(camera.zoom, p.depth);
+ const alpha = p.brightness * twinkle * (0.98 + (zoomScale - 1) * 0.35) * 0.52;
+ drawDepthParticle(ctx, starX, starY, p, alpha, zoomScale, twinkle);
}
}
@@ -94,6 +256,148 @@ export function drawBackground(
if (showHex) {
drawHexGrid(ctx, w, h, camera, time);
}
+
+ if (showStars) {
+ for (const shootingStar of shootingStars.active) {
+ drawShootingStar(ctx, shootingStar);
+ }
+ }
+}
+
+function drawDepthParticle(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ particle: DepthParticle,
+ alpha: number,
+ zoomScale: number,
+ twinkle: number
+): void {
+ const size = Math.max(0.68, particle.size * zoomScale * (0.985 + (twinkle - 1) * 0.22));
+ const coreRadius = Math.max(0.48, size * (0.48 + (twinkle - 1) * 0.08));
+ const coreAlpha = clamp(Math.min(1, alpha * 1.08 + 0.04), 0.16, 0.72);
+
+ if (size > 0.8) {
+ const glowRadius = size * (1.45 + particle.haloStrength * 0.8);
+ const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius);
+ glow.addColorStop(0, COLORS.holoHot + alphaHex(coreAlpha * particle.haloStrength * 0.72));
+ glow.addColorStop(0.42, COLORS.holoBright + alphaHex(coreAlpha * particle.haloStrength * 0.34));
+ glow.addColorStop(1, COLORS.holoBright + alphaHex(0));
+ ctx.fillStyle = glow;
+ ctx.beginPath();
+ ctx.arc(x, y, glowRadius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ ctx.fillStyle = COLORS.holoBright + alphaHex(coreAlpha * 0.12);
+ ctx.beginPath();
+ ctx.arc(x, y, coreRadius * 1.7, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = COLORS.holoHot + alphaHex(coreAlpha);
+ ctx.beginPath();
+ ctx.arc(x, y, coreRadius, 0, Math.PI * 2);
+ ctx.fill();
+}
+
+function drawShootingStar(ctx: CanvasRenderingContext2D, shootingStar: ShootingStar): void {
+ const progress = clamp01(shootingStar.travel / Math.max(shootingStar.maxTravel, 0.001));
+ const fadeIn = clamp01(progress / 0.06);
+ const alpha = shootingStar.brightness * fadeIn;
+ if (alpha <= 0) return;
+
+ const speed = Math.hypot(shootingStar.vx, shootingStar.vy) || 1;
+ const dirX = shootingStar.vx / speed;
+ const dirY = shootingStar.vy / speed;
+ const tailX = shootingStar.x - dirX * shootingStar.length;
+ const tailY = shootingStar.y - dirY * shootingStar.length;
+
+ const trailGradient = ctx.createLinearGradient(shootingStar.x, shootingStar.y, tailX, tailY);
+ trailGradient.addColorStop(0, COLORS.holoHot + alphaHex(alpha));
+ trailGradient.addColorStop(0.24, COLORS.holoBright + alphaHex(alpha * 0.28));
+ trailGradient.addColorStop(1, COLORS.holoBright + alphaHex(0));
+
+ ctx.save();
+ ctx.globalCompositeOperation = 'screen';
+ ctx.lineCap = 'round';
+ ctx.strokeStyle = COLORS.holoBright + alphaHex(alpha * 0.1);
+ ctx.lineWidth = shootingStar.thickness * 2.1;
+ ctx.beginPath();
+ ctx.moveTo(tailX, tailY);
+ ctx.lineTo(shootingStar.x, shootingStar.y);
+ ctx.stroke();
+
+ ctx.strokeStyle = trailGradient;
+ ctx.lineWidth = shootingStar.thickness;
+ ctx.beginPath();
+ ctx.moveTo(tailX, tailY);
+ ctx.lineTo(shootingStar.x, shootingStar.y);
+ ctx.stroke();
+
+ const glowRadius = shootingStar.thickness * 3.4;
+ const headGlow = ctx.createRadialGradient(
+ shootingStar.x,
+ shootingStar.y,
+ 0,
+ shootingStar.x,
+ shootingStar.y,
+ glowRadius
+ );
+ headGlow.addColorStop(0, COLORS.holoHot + alphaHex(alpha * 0.34));
+ headGlow.addColorStop(0.4, COLORS.holoBright + alphaHex(alpha * 0.12));
+ headGlow.addColorStop(1, COLORS.holoBright + alphaHex(0));
+ ctx.fillStyle = headGlow;
+ ctx.beginPath();
+ ctx.arc(shootingStar.x, shootingStar.y, glowRadius, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = COLORS.holoHot + alphaHex(Math.min(1, alpha * 1.3 + 0.06));
+ ctx.beginPath();
+ ctx.arc(
+ shootingStar.x,
+ shootingStar.y,
+ Math.max(0.52, shootingStar.thickness * 0.7),
+ 0,
+ Math.PI * 2
+ );
+ ctx.fill();
+ ctx.restore();
+}
+
+function clamp01(value: number): number {
+ return Math.max(0, Math.min(1, value));
+}
+
+function getStarZoomScale(zoom: number, depth: number): number {
+ const zoomDelta = clamp(zoom, 0.45, 2.2) - 1;
+ const influence = 0.03 + (1 - depth) * 0.03;
+ return clamp(1 + zoomDelta * influence, 0.96, 1.07);
+}
+
+function getStarPositionScale(zoom: number, depth: number): number {
+ const zoomDelta = clamp(zoom, 0.45, 2.2) - 1;
+ const influence = 0.075 + (1 - depth) * 0.075;
+ return clamp(1 + zoomDelta * influence, 0.86, 1.18);
+}
+
+function projectZoomedWrappedCoord(
+ value: number,
+ size: number,
+ center: number,
+ scale: number
+): number {
+ const repeatSize = size / Math.max(scale, 0.0001);
+ const repeatOffset = center - repeatSize * 0.5;
+ const normalized = wrapCoord(value - repeatOffset, repeatSize) + repeatOffset;
+ return wrapCoord(center + (normalized - center) * scale, size);
+}
+
+function wrapCoord(value: number, size: number): number {
+ return ((value % size) + size) % size;
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
}
// ─── Hex Grid ───────────────────────────────────────────────────────────────
@@ -110,10 +414,24 @@ function drawHexGrid(
w: number,
h: number,
camera: { x: number; y: number; zoom: number },
- time: number,
+ time: number
): void {
- const size = BACKGROUND.hexSize;
- const pulse = BACKGROUND.hexAlpha * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed));
+ const lodScale =
+ camera.zoom < 0.24
+ ? 0
+ : camera.zoom < 0.34
+ ? 4
+ : camera.zoom < 0.46
+ ? 3
+ : camera.zoom < 0.62
+ ? 2
+ : 1;
+ if (lodScale === 0) return;
+
+ const zoomFade = clamp((camera.zoom - 0.22) / 0.4, 0, 1);
+ const size = BACKGROUND.hexSize * lodScale;
+ const pulse =
+ BACKGROUND.hexAlpha * zoomFade * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed));
// Visible region in world space (expanded a bit for edge cells)
const worldX0 = -camera.x / camera.zoom - size * 2;
diff --git a/packages/agent-graph/src/canvas/draw-activity-lanes.ts b/packages/agent-graph/src/canvas/draw-activity-lanes.ts
new file mode 100644
index 00000000..ab257442
--- /dev/null
+++ b/packages/agent-graph/src/canvas/draw-activity-lanes.ts
@@ -0,0 +1,250 @@
+import { COLORS } from '../constants/colors';
+import { MIN_VISIBLE_OPACITY, TASK_PILL } from '../constants/canvas-constants';
+import {
+ ACTIVITY_LANE,
+ getActivityAnchorTarget,
+ getActivityLaneBounds,
+ getVisibleActivityWindow,
+ isActivityOwner,
+ resolveActivityLaneSide,
+} from '../layout/activityLane';
+import type { GraphActivityItem, GraphNode } from '../ports/types';
+import { truncateText } from './draw-misc';
+import { drawPillShell } from './draw-pill-shell';
+import { hexWithAlpha, measureTextCached } from './render-cache';
+
+export function drawActivityLanes(
+ ctx: CanvasRenderingContext2D,
+ nodes: GraphNode[],
+ selectedNodeId: string | null,
+ hoveredNodeId: string | null,
+ focusNodeIds?: ReadonlySet | null,
+ zoom = 1
+): void {
+ if (zoom < 0.16) {
+ return;
+ }
+
+ const leadNode = nodes.find((node) => node.kind === 'lead' && node.x != null);
+ const leadX = leadNode?.x ?? null;
+
+ for (const node of nodes) {
+ if (!isActivityOwner(node) || node.x == null || node.y == null) {
+ continue;
+ }
+
+ const opacity = getLaneOpacity(node, focusNodeIds);
+ if (opacity < MIN_VISIBLE_OPACITY) {
+ continue;
+ }
+
+ const window = getVisibleActivityWindow(node.activityItems);
+ const overflowCount = node.activityOverflowCount ?? window.overflowCount;
+ if (window.items.length === 0 && overflowCount <= 0) {
+ continue;
+ }
+
+ const anchor = getActivityAnchorTarget({
+ nodeX: node.x,
+ nodeY: node.y,
+ nodeKind: node.kind,
+ leadX,
+ });
+ const bounds = getActivityLaneBounds(anchor.x, anchor.y);
+ const left = bounds.left;
+ const top = bounds.top;
+ const side = resolveActivityLaneSide({
+ nodeKind: node.kind,
+ nodeX: node.x,
+ leadX,
+ });
+
+ ctx.save();
+ ctx.globalAlpha = opacity;
+
+ drawLaneHeader(ctx, side, left, top);
+
+ const itemsTop = top + ACTIVITY_LANE.headerHeight;
+ for (let index = 0; index < window.items.length; index += 1) {
+ const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
+ drawActivityPill(ctx, {
+ item: window.items[index],
+ x: left + ACTIVITY_LANE.width / 2,
+ y: itemTop + ACTIVITY_LANE.itemHeight / 2,
+ isOwnerSelected: node.id === selectedNodeId,
+ isOwnerHovered: node.id === hoveredNodeId,
+ });
+ }
+
+ if (overflowCount > 0) {
+ const overflowTop = itemsTop + window.items.length * ACTIVITY_LANE.rowHeight;
+ drawOverflowPill(
+ ctx,
+ left + ACTIVITY_LANE.width / 2,
+ overflowTop + ACTIVITY_LANE.overflowHeight / 2,
+ overflowCount
+ );
+ }
+
+ ctx.restore();
+ }
+}
+
+function getLaneOpacity(node: GraphNode, focusNodeIds?: ReadonlySet | null): number {
+ if (focusNodeIds && !focusNodeIds.has(node.id)) {
+ return 0.25;
+ }
+ return 1;
+}
+
+function drawLaneHeader(
+ ctx: CanvasRenderingContext2D,
+ side: 'left' | 'right',
+ left: number,
+ top: number
+): void {
+ ctx.font = 'bold 8px monospace';
+ ctx.textAlign = side === 'left' ? 'right' : 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.46);
+ ctx.fillText('Activity', side === 'left' ? left + ACTIVITY_LANE.width : left, top);
+}
+
+function drawActivityPill(
+ ctx: CanvasRenderingContext2D,
+ params: {
+ item: GraphActivityItem;
+ x: number;
+ y: number;
+ isOwnerSelected: boolean;
+ isOwnerHovered: boolean;
+ }
+): void {
+ const { item, x, y, isOwnerSelected, isOwnerHovered } = params;
+ const accent = getActivityAccentColor(item.kind);
+ const badgeText = getActivityBadgeText(item.kind);
+
+ ctx.save();
+ ctx.translate(x, y);
+
+ drawPillShell(ctx, {
+ width: ACTIVITY_LANE.width,
+ height: ACTIVITY_LANE.itemHeight,
+ radius: TASK_PILL.borderRadius,
+ fillStyle: isOwnerSelected
+ ? COLORS.cardBgSelected
+ : isOwnerHovered
+ ? 'rgba(15, 20, 40, 0.72)'
+ : COLORS.cardBg,
+ borderColor: hexWithAlpha(accent, isOwnerSelected ? 0.76 : 0.46),
+ borderWidth: isOwnerSelected ? 1.7 : 1,
+ shadowColor: hexWithAlpha(accent, 0.22),
+ shadowBlur: isOwnerSelected ? 10 : 6,
+ accentColor: hexWithAlpha(accent, 0.72),
+ });
+
+ ctx.font = 'bold 7px monospace';
+ const badgeWidth = Math.max(30, Math.ceil(measureTextCached(ctx, ctx.font, badgeText) + 14));
+ ctx.fillStyle = hexWithAlpha(accent, 0.16);
+ ctx.beginPath();
+ ctx.roundRect(
+ ACTIVITY_LANE.width / 2 - badgeWidth - 8,
+ -ACTIVITY_LANE.itemHeight / 2 + 4,
+ badgeWidth,
+ 11,
+ 5
+ );
+ ctx.fill();
+
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = hexWithAlpha(accent, 0.96);
+ ctx.fillText(
+ badgeText,
+ ACTIVITY_LANE.width / 2 - badgeWidth / 2 - 8,
+ -ACTIVITY_LANE.itemHeight / 2 + 9.5
+ );
+
+ ctx.textAlign = 'left';
+ ctx.font = 'bold 8px monospace';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = COLORS.textPrimary;
+ const title = truncateText(ctx, item.title, ACTIVITY_LANE.width - badgeWidth - 26, ctx.font);
+ ctx.fillText(title, -ACTIVITY_LANE.width / 2 + 10, -3.5);
+
+ const preview = item.preview?.trim();
+ if (preview) {
+ ctx.font = '7px monospace';
+ ctx.fillStyle = COLORS.textDim;
+ const previewText = truncateText(ctx, preview, ACTIVITY_LANE.width - 18, ctx.font);
+ ctx.fillText(previewText, -ACTIVITY_LANE.width / 2 + 10, 6.5);
+ } else if (item.authorLabel) {
+ ctx.font = '7px monospace';
+ ctx.fillStyle = COLORS.textDim;
+ const authorText = truncateText(ctx, item.authorLabel, ACTIVITY_LANE.width - 18, ctx.font);
+ ctx.fillText(authorText, -ACTIVITY_LANE.width / 2 + 10, 6.5);
+ }
+
+ ctx.restore();
+}
+
+function drawOverflowPill(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ overflowCount: number
+): void {
+ const width = TASK_PILL.width;
+ const height = ACTIVITY_LANE.overflowHeight;
+ ctx.save();
+ ctx.translate(x, y);
+
+ drawPillShell(ctx, {
+ width,
+ height,
+ radius: 6,
+ fillStyle: 'rgba(10, 15, 30, 0.42)',
+ borderColor: hexWithAlpha(COLORS.holoBright, 0.22),
+ borderWidth: 1,
+ });
+
+ ctx.font = 'bold 7px monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.66);
+ ctx.fillText(`+${overflowCount} more`, 0, 0.5);
+
+ ctx.restore();
+}
+
+function getActivityAccentColor(kind: GraphActivityItem['kind']): string {
+ switch (kind) {
+ case 'task_comment':
+ return COLORS.particleTaskComment;
+ case 'task_assign':
+ return COLORS.particleTaskAssign;
+ case 'review_request':
+ return COLORS.particleReviewRequest;
+ case 'review_response':
+ return COLORS.particleReviewResponse;
+ case 'inbox_message':
+ default:
+ return COLORS.particleInboxMessage;
+ }
+}
+
+function getActivityBadgeText(kind: GraphActivityItem['kind']): string {
+ switch (kind) {
+ case 'task_comment':
+ return 'COMMENT';
+ case 'task_assign':
+ return 'TASK';
+ case 'review_request':
+ return 'REV';
+ case 'review_response':
+ return 'DONE';
+ case 'inbox_message':
+ default:
+ return 'MSG';
+ }
+}
diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts
index 97c4c27c..21a1d228 100644
--- a/packages/agent-graph/src/canvas/draw-agents.ts
+++ b/packages/agent-graph/src/canvas/draw-agents.ts
@@ -24,11 +24,14 @@ export function drawAgents(
nodes: GraphNode[],
time: number,
selectedId: string | null,
- hoveredId: string | null
+ hoveredId: string | null,
+ focusNodeIds?: ReadonlySet | null,
+ zoom = 1
): void {
+ const simplify = zoom < 0.19;
for (const node of nodes) {
if (node.kind !== 'member' && node.kind !== 'lead') continue;
- const opacity = getNodeOpacity(node);
+ const opacity = getNodeOpacity(node) * getFocusOpacity(node.id, focusNodeIds);
if (opacity < MIN_VISIBLE_OPACITY) continue;
const x = node.x ?? 0;
@@ -41,23 +44,39 @@ export function drawAgents(
ctx.save();
ctx.globalAlpha = opacity;
- // Depth shadow
- drawDepthShadow(ctx, x, y, r);
+ if (simplify) {
+ drawHexagon(ctx, x, y, r);
+ ctx.fillStyle = isSelected ? 'rgba(100, 200, 255, 0.15)' : COLORS.nodeInterior;
+ ctx.fill();
+ drawHexagon(ctx, x, y, r);
+ ctx.strokeStyle = hexWithAlpha(color, isHovered ? 0.8 : 0.5);
+ ctx.lineWidth = isSelected ? 2 : 1;
+ ctx.stroke();
- // Outer glow
- drawGlow(ctx, x, y, r, color);
+ ctx.beginPath();
+ ctx.arc(x, y, Math.max(3, r * 0.16), 0, Math.PI * 2);
+ ctx.fillStyle = hexWithAlpha(color, 0.8);
+ ctx.fill();
+ } else {
+ // Depth shadow
+ drawDepthShadow(ctx, x, y, r);
- // Hexagonal body with interior fill
- drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
+ // Outer glow
+ drawGlow(ctx, x, y, r, color);
- // Avatar: robohash image or fallback letter
- drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
+ // Hexagonal body with interior fill
+ drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
- // Breathing animation + spawn/waiting effects
- drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel);
+ // Avatar: robohash image or fallback letter
+ drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
+
+ // Breathing animation + launch-stage effects
+ drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel);
+ drawLaunchStage(ctx, x, y, r, node.launchVisualState, time);
+ }
// Pending approval indicator: pulsing amber ring
- if (node.pendingApproval) {
+ if (!simplify && node.pendingApproval) {
const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 7);
const ringR = r + 5;
ctx.beginPath();
@@ -79,6 +98,7 @@ export function drawAgents(
// Working indicator: subtle spinning arc when member has active task
if (
+ !simplify &&
node.currentTaskId &&
(node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')
) {
@@ -91,13 +111,19 @@ export function drawAgents(
ctx.stroke();
}
- if (node.activeTool) {
+ if (!simplify && node.activeTool) {
drawToolCard(ctx, x, y, r, node.activeTool, time);
}
- // Name + role label (single line: "jack · developer")
- const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
- drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
+ if (!simplify && node.exceptionTone) {
+ drawExceptionPip(ctx, x, y, r, node.exceptionTone);
+ }
+
+ if (!simplify) {
+ // Name + role label (single line: "jack · developer")
+ const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
+ drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
+ }
// TODO: Context ring disabled — LeadContextUsage.percent is unreliable
// (jumps due to cache_read variance, contextWindow mismatch with actual model).
@@ -123,7 +149,8 @@ export function drawCrossTeamNodes(
nodes: GraphNode[],
time: number,
selectedId: string | null,
- hoveredId: string | null
+ hoveredId: string | null,
+ focusNodeIds?: ReadonlySet | null
): void {
for (const node of nodes) {
if (node.kind !== 'crossteam') continue;
@@ -136,7 +163,7 @@ export function drawCrossTeamNodes(
const isHovered = node.id === hoveredId;
ctx.save();
- ctx.globalAlpha = isHovered ? 0.7 : 0.5;
+ ctx.globalAlpha = (isHovered ? 0.7 : 0.5) * getFocusOpacity(node.id, focusNodeIds);
// Subtle glow
const glowR = r + AGENT_DRAW.glowPadding;
@@ -188,6 +215,111 @@ function getNodeOpacity(node: GraphNode): number {
return 1;
}
+function getFocusOpacity(nodeId: string, focusNodeIds?: ReadonlySet | null): number {
+ return focusNodeIds && !focusNodeIds.has(nodeId) ? 0.25 : 1;
+}
+
+function drawExceptionPip(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ r: number,
+ tone: NonNullable
+): void {
+ const pipX = x + r * 0.58;
+ const pipY = y - r * 0.58;
+ const pipColor = tone === 'error' ? '#ef4444' : '#f59e0b';
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(pipX, pipY, 4.5, 0, Math.PI * 2);
+ ctx.fillStyle = pipColor;
+ ctx.fill();
+ ctx.lineWidth = 1.5;
+ ctx.strokeStyle = '#050510';
+ ctx.stroke();
+ ctx.restore();
+}
+
+function drawLaunchStage(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ r: number,
+ visualState: GraphNode['launchVisualState'],
+ time: number
+): void {
+ if (!visualState) {
+ return;
+ }
+
+ ctx.save();
+ switch (visualState) {
+ case 'waiting': {
+ const ringR = r + 7 + Math.sin(time * 3.2) * 1.2;
+ const pulseAlpha = 0.16 + 0.12 * (0.5 + 0.5 * Math.sin(time * 3.2));
+ ctx.beginPath();
+ ctx.arc(x, y, ringR, 0, Math.PI * 2);
+ ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha);
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ break;
+ }
+ case 'spawning': {
+ const ringR = r + 7;
+ const rotation = time * 2.4;
+ ctx.beginPath();
+ ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15);
+ ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72);
+ ctx.lineWidth = 2;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+ break;
+ }
+ case 'runtime_pending': {
+ const ringR = r + 8;
+ ctx.beginPath();
+ ctx.arc(x, y, ringR, 0, Math.PI * 2);
+ ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4);
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ const orbit = time * 1.6;
+ const dotR = 2.2;
+ const dotX = x + Math.cos(orbit) * ringR;
+ const dotY = y + Math.sin(orbit) * ringR;
+ ctx.beginPath();
+ ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2);
+ ctx.fillStyle = hexWithAlpha('#67e8f9', 0.9);
+ ctx.fill();
+ break;
+ }
+ case 'settling': {
+ const ringR = r + 6;
+ const arc = 0.65 + 0.08 * Math.sin(time * 2.2);
+ const rotation = time * 1.25;
+ ctx.beginPath();
+ ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc);
+ ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55);
+ ctx.lineWidth = 1.75;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+ break;
+ }
+ case 'error': {
+ const ringR = r + 7 + Math.sin(time * 4) * 0.8;
+ ctx.beginPath();
+ ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15);
+ ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6);
+ ctx.lineWidth = 2;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+ break;
+ }
+ }
+ ctx.restore();
+}
+
function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
ctx.save();
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts
index 9982168b..de3c22f2 100644
--- a/packages/agent-graph/src/canvas/draw-edges.ts
+++ b/packages/agent-graph/src/canvas/draw-edges.ts
@@ -9,7 +9,10 @@ import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
// ─── Edge Type → Color/Width Mapping ────────────────────────────────────────
-const EDGE_STYLES: Record = {
+const EDGE_STYLES: Record<
+ GraphEdgeType,
+ { color: string; startW: number; endW: number; dash?: number[] }
+> = {
'parent-child': { color: COLORS.edgeParentChild, ...BEAM.parentChild },
ownership: { color: COLORS.edgeOwnership, ...BEAM.ownership },
blocking: { color: COLORS.edgeBlocking, ...BEAM.blocking, dash: [8, 4] },
@@ -30,7 +33,7 @@ export function computeControlPoints(
x1: number,
y1: number,
x2: number,
- y2: number,
+ y2: number
): ControlPoints {
const dx = x2 - x1;
const dy = y2 - y1;
@@ -53,7 +56,7 @@ export function bezierPoint(
cp: ControlPoints,
x2: number,
y2: number,
- t: number,
+ t: number
): { x: number; y: number } {
const u = 1 - t;
const uu = u * u;
@@ -74,7 +77,12 @@ export function drawEdges(
nodeMap: Map,
_time: number,
hasActiveParticles: Set,
+ focusEdgeIds?: ReadonlySet | null,
+ hoveredEdgeId?: string | null,
+ selectedEdgeId?: string | null,
+ zoom = 1
): void {
+ const simplify = zoom < 0.18;
for (const edge of edges) {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
@@ -83,47 +91,85 @@ export function drawEdges(
const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child'];
const isActive = hasActiveParticles.has(edge.id);
+ const isSelected = selectedEdgeId === edge.id;
+ const isHovered = !isSelected && hoveredEdgeId === edge.id;
// Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave
- const alpha = isActive
- ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6)
- : BEAM.idleAlpha;
+ const alpha = isActive ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) : BEAM.idleAlpha;
+ const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1;
+ const interactionAlpha = isSelected ? 0.95 : isHovered ? 0.6 : 0;
+ const finalAlpha = Math.max(alpha * focusAlpha, interactionAlpha);
- if (alpha < MIN_VISIBLE_OPACITY) continue;
+ if (finalAlpha < MIN_VISIBLE_OPACITY) continue;
const cp = computeControlPoints(source.x, source.y, target.x, target.y);
ctx.save();
- ctx.globalAlpha = alpha;
+ ctx.globalAlpha = finalAlpha;
// Subtle glow pass when edge has active particles
- if (isActive) {
+ if (!simplify && (isActive || isSelected || isHovered)) {
ctx.shadowColor = edge.color ?? style.color;
- ctx.shadowBlur = 12;
+ ctx.shadowBlur = isSelected ? 16 : isHovered ? 10 : 12;
}
- // Draw tapered bezier
- drawTaperedBezier(
- ctx,
- source.x,
- source.y,
- cp,
- target.x,
- target.y,
- style.startW,
- style.endW,
- edge.color ?? style.color,
- style.dash,
- );
+ if (simplify) {
+ drawSimplifiedBezier(
+ ctx,
+ source.x,
+ source.y,
+ cp,
+ target.x,
+ target.y,
+ (style.startW + style.endW) * 0.5 * (isSelected ? 1.35 : isHovered ? 1.15 : 1),
+ edge.color ?? style.color,
+ style.dash
+ );
+ } else {
+ // Draw tapered bezier
+ drawTaperedBezier(
+ ctx,
+ source.x,
+ source.y,
+ cp,
+ target.x,
+ target.y,
+ style.startW,
+ style.endW,
+ edge.color ?? style.color,
+ style.dash
+ );
+ }
// Arrow for blocking edges
- if (edge.type === 'blocking') {
- drawArrowHead(ctx, cp, target.x, target.y, style.color, alpha);
+ if (!simplify && edge.type === 'blocking') {
+ drawArrowHead(ctx, cp, target.x, target.y, style.color, finalAlpha);
}
ctx.restore();
}
}
+function drawSimplifiedBezier(
+ ctx: CanvasRenderingContext2D,
+ x1: number,
+ y1: number,
+ cp: ControlPoints,
+ x2: number,
+ y2: number,
+ width: number,
+ color: string,
+ dash?: number[]
+): void {
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.bezierCurveTo(cp.cp1x, cp.cp1y, cp.cp2x, cp.cp2y, x2, y2);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ if (dash) ctx.setLineDash(dash);
+ ctx.stroke();
+ if (dash) ctx.setLineDash([]);
+}
+
// ─── Tapered Bezier ─────────────────────────────────────────────────────────
function drawTaperedBezier(
@@ -136,7 +182,7 @@ function drawTaperedBezier(
startW: number,
endW: number,
color: string,
- dash?: number[],
+ dash?: number[]
): void {
if (dash) {
// Dashed edges use stroke, not fill polygon
@@ -196,7 +242,7 @@ function drawArrowHead(
x2: number,
y2: number,
color: string,
- alpha: number,
+ alpha: number
): void {
// Compute direction at t=1
const dx = x2 - cp.cp2x;
@@ -211,8 +257,14 @@ function drawArrowHead(
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
- ctx.lineTo(x2 - ux * arrowSize - uy * arrowSize * 0.5, y2 - uy * arrowSize + ux * arrowSize * 0.5);
- ctx.lineTo(x2 - ux * arrowSize + uy * arrowSize * 0.5, y2 - uy * arrowSize - ux * arrowSize * 0.5);
+ ctx.lineTo(
+ x2 - ux * arrowSize - uy * arrowSize * 0.5,
+ y2 - uy * arrowSize + ux * arrowSize * 0.5
+ );
+ ctx.lineTo(
+ x2 - ux * arrowSize + uy * arrowSize * 0.5,
+ y2 - uy * arrowSize - ux * arrowSize * 0.5
+ );
ctx.closePath();
ctx.fill();
ctx.restore();
diff --git a/packages/agent-graph/src/canvas/draw-handoff-cards.ts b/packages/agent-graph/src/canvas/draw-handoff-cards.ts
new file mode 100644
index 00000000..098e439f
--- /dev/null
+++ b/packages/agent-graph/src/canvas/draw-handoff-cards.ts
@@ -0,0 +1,266 @@
+import { COLORS } from '../constants/colors';
+import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
+import type { CameraTransform } from '../hooks/useGraphCamera';
+import { getHandoffAnchorTarget } from '../layout/launchAnchor';
+import type { GraphNode } from '../ports/types';
+import type { TransientHandoffCard } from '../ui/transientHandoffs';
+import { truncateText } from './draw-misc';
+import { hexWithAlpha, measureTextCached } from './render-cache';
+
+export function drawHandoffCards(
+ ctx: CanvasRenderingContext2D,
+ params: {
+ cards: TransientHandoffCard[];
+ nodeMap: Map;
+ time: number;
+ camera: CameraTransform;
+ viewport: { width: number; height: number };
+ }
+): void {
+ const { cards, nodeMap, time, camera, viewport } = params;
+ if (cards.length === 0) return;
+
+ const stackIndexByDestination = new Map();
+ let drawnCount = 0;
+
+ for (const card of cards) {
+ if (drawnCount >= HANDOFF_CARD.maxVisible) break;
+ const destinationNode = nodeMap.get(card.destinationNodeId);
+ if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue;
+
+ const alpha = getCardAlpha(card, time);
+ if (alpha <= MIN_VISIBLE_OPACITY) continue;
+
+ const previewLines = buildPreviewLines(ctx, card.preview);
+ const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
+ const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0;
+ stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1);
+
+ const position = getCardPosition({
+ node: destinationNode,
+ camera,
+ viewport,
+ height,
+ stackIndex,
+ });
+ if (!position) continue;
+
+ drawCard({
+ ctx,
+ card,
+ previewLines,
+ alpha,
+ x: position.x,
+ y: position.y,
+ width: HANDOFF_CARD.width,
+ height,
+ });
+ drawnCount += 1;
+ }
+}
+
+function getCardAlpha(card: TransientHandoffCard, time: number): number {
+ const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds);
+ const fadeOutRemaining = card.expiresAt - time;
+ const fadeOut = fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds
+ ? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds)
+ : 1;
+ return Math.max(0, Math.min(1, fadeIn * fadeOut));
+}
+
+function getCardPosition(params: {
+ node: GraphNode;
+ camera: CameraTransform;
+ viewport: { width: number; height: number };
+ height: number;
+ stackIndex: number;
+}): { x: number; y: number } | null {
+ const { node, camera, viewport, height, stackIndex } = params;
+ const anchor = getCardAnchorPosition(node, camera.zoom);
+ const screenX = anchor.x * camera.zoom + camera.x;
+ const screenY = anchor.y * camera.zoom + camera.y;
+
+ const visibleMargin = 80;
+ if (
+ screenX < -visibleMargin ||
+ screenX > viewport.width + visibleMargin ||
+ screenY < -visibleMargin ||
+ screenY > viewport.height + visibleMargin
+ ) {
+ return null;
+ }
+
+ const stackOffset = stackIndex * (height + HANDOFF_CARD.stackGap);
+ let x = screenX - HANDOFF_CARD.width / 2;
+ let y = screenY - height / 2 - stackOffset;
+
+ if (x + HANDOFF_CARD.width > viewport.width - HANDOFF_CARD.viewportPadding) {
+ x = viewport.width - HANDOFF_CARD.width - HANDOFF_CARD.viewportPadding;
+ }
+ if (x < HANDOFF_CARD.viewportPadding) {
+ x = HANDOFF_CARD.viewportPadding;
+ }
+
+ if (y < HANDOFF_CARD.viewportPadding) {
+ y = HANDOFF_CARD.viewportPadding;
+ }
+ if (y + height > viewport.height - HANDOFF_CARD.viewportPadding) {
+ y = Math.max(HANDOFF_CARD.viewportPadding, viewport.height - height - HANDOFF_CARD.viewportPadding);
+ }
+
+ return { x, y };
+}
+
+function getCardAnchorPosition(node: GraphNode, zoom: number): { x: number; y: number } {
+ switch (node.kind) {
+ case 'lead':
+ case 'member':
+ return getHandoffAnchorTarget({
+ nodeX: node.x ?? 0,
+ nodeY: node.y ?? 0,
+ nodeKind: node.kind,
+ });
+ case 'task':
+ return {
+ x: (node.x ?? 0) + TASK_PILL.width * 0.5 + HANDOFF_CARD.anchorGap / zoom,
+ y: (node.y ?? 0) - (TASK_PILL.height * 0.5 + HANDOFF_CARD.anchorGap / zoom),
+ };
+ case 'process':
+ return {
+ x: (node.x ?? 0) + NODE.radiusProcess + HANDOFF_CARD.anchorGap / zoom,
+ y: (node.y ?? 0) - (NODE.radiusProcess + HANDOFF_CARD.anchorGap / zoom),
+ };
+ case 'crossteam':
+ return {
+ x: (node.x ?? 0) + NODE.radiusCrossTeam + HANDOFF_CARD.anchorGap / zoom,
+ y: (node.y ?? 0) - (NODE.radiusCrossTeam + HANDOFF_CARD.anchorGap / zoom),
+ };
+ }
+}
+
+function drawCard(params: {
+ ctx: CanvasRenderingContext2D;
+ card: TransientHandoffCard;
+ previewLines: string[];
+ alpha: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}): void {
+ const { ctx, card, previewLines, alpha, x, y, width, height } = params;
+ const accent = card.color || COLORS.particleInboxMessage;
+ const radius = 10;
+
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.shadowColor = hexWithAlpha(accent, 0.22 * alpha);
+ ctx.shadowBlur = 12;
+ ctx.fillStyle = hexWithAlpha('#08111f', 0.92);
+ ctx.strokeStyle = hexWithAlpha(accent, 0.38);
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.roundRect(x, y, width, height, radius);
+ ctx.fill();
+ ctx.stroke();
+
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = hexWithAlpha(accent, 0.14);
+ ctx.beginPath();
+ ctx.roundRect(x + 8, y + 8, 54, 16, 6);
+ ctx.fill();
+
+ ctx.fillStyle = hexWithAlpha(accent, 0.92);
+ ctx.font = 'bold 8px monospace';
+ ctx.textAlign = 'left';
+ ctx.fillText(getKindLabel(card.kind), x + 16, y + 19);
+
+ if (card.count > 1) {
+ const countText = `+${card.count - 1}`;
+ ctx.font = 'bold 8px monospace';
+ const countWidth = measureTextCached(ctx, ctx.font, countText) + 14;
+ ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.16);
+ ctx.beginPath();
+ ctx.roundRect(x + width - countWidth - 10, y + 8, countWidth, 16, 6);
+ ctx.fill();
+ ctx.fillStyle = COLORS.holoBright;
+ ctx.textAlign = 'center';
+ ctx.fillText(countText, x + width - countWidth / 2 - 10, y + 19);
+ }
+
+ ctx.textAlign = 'left';
+ ctx.font = 'bold 10px monospace';
+ ctx.fillStyle = COLORS.textPrimary;
+ const route = truncateText(
+ ctx,
+ `${card.sourceLabel} -> ${card.destinationLabel}`,
+ width - 20,
+ ctx.font
+ );
+ ctx.fillText(route, x + 10, y + 36);
+
+ if (previewLines.length > 0) {
+ ctx.font = '8px monospace';
+ ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.86);
+ for (let index = 0; index < previewLines.length; index += 1) {
+ ctx.fillText(
+ previewLines[index],
+ x + 10,
+ y + 50 + index * HANDOFF_CARD.previewLineHeight
+ );
+ }
+ }
+ ctx.restore();
+}
+
+function buildPreviewLines(ctx: CanvasRenderingContext2D, preview: string | undefined): string[] {
+ if (!preview) return [];
+ ctx.font = '8px monospace';
+ let remaining = preview.replace(/\s+/g, ' ').trim();
+ if (remaining.length === 0) return [];
+ const lines: string[] = [];
+ for (let index = 0; index < HANDOFF_CARD.previewMaxLines && remaining.length > 0; index += 1) {
+ if (index === HANDOFF_CARD.previewMaxLines - 1) {
+ lines.push(truncateText(ctx, remaining, HANDOFF_CARD.previewMaxWidth, ctx.font));
+ break;
+ }
+
+ const words = remaining.split(' ');
+ let line = '';
+ let consumedWords = 0;
+ for (const word of words) {
+ const candidate = line.length > 0 ? `${line} ${word}` : word;
+ if (measureTextCached(ctx, ctx.font, candidate) <= HANDOFF_CARD.previewMaxWidth) {
+ line = candidate;
+ consumedWords += 1;
+ continue;
+ }
+ break;
+ }
+
+ if (consumedWords === 0) {
+ lines.push(truncateText(ctx, words[0] ?? remaining, HANDOFF_CARD.previewMaxWidth, ctx.font));
+ break;
+ }
+
+ lines.push(line);
+ remaining = words.slice(consumedWords).join(' ').trim();
+ }
+
+ return lines;
+}
+
+function getKindLabel(kind: TransientHandoffCard['kind']): string {
+ switch (kind) {
+ case 'task_comment':
+ return 'COMMENT';
+ case 'task_assign':
+ return 'TASK';
+ case 'review_request':
+ return 'REVIEW';
+ case 'review_response':
+ return 'REPLY';
+ case 'inbox_message':
+ return 'MESSAGE';
+ }
+}
diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts
index ce222779..a5086876 100644
--- a/packages/agent-graph/src/canvas/draw-particles.ts
+++ b/packages/agent-graph/src/canvas/draw-particles.ts
@@ -27,8 +27,10 @@ export function drawParticles(
edgeMap: Map,
nodeMap: Map,
time: number,
+ focusEdgeIds?: ReadonlySet | null,
): void {
for (const p of particles) {
+ if (focusEdgeIds && !focusEdgeIds.has(p.edgeId)) continue;
const edge = edgeMap.get(p.edgeId);
if (!edge) continue;
diff --git a/packages/agent-graph/src/canvas/draw-pill-shell.ts b/packages/agent-graph/src/canvas/draw-pill-shell.ts
new file mode 100644
index 00000000..9c786294
--- /dev/null
+++ b/packages/agent-graph/src/canvas/draw-pill-shell.ts
@@ -0,0 +1,78 @@
+import { hexWithAlpha } from './render-cache';
+
+export interface PillShellOptions {
+ width: number;
+ height: number;
+ radius: number;
+ fillStyle: string;
+ borderColor: string;
+ borderWidth: number;
+ shadowColor?: string;
+ shadowBlur?: number;
+ accentColor?: string;
+ accentWidth?: number;
+}
+
+export function drawPillShell(
+ ctx: CanvasRenderingContext2D,
+ options: PillShellOptions
+): void {
+ const {
+ width,
+ height,
+ radius,
+ fillStyle,
+ borderColor,
+ borderWidth,
+ shadowColor,
+ shadowBlur = 0,
+ accentColor,
+ accentWidth = 4,
+ } = options;
+ const halfWidth = width / 2;
+ const halfHeight = height / 2;
+
+ if (shadowColor && shadowBlur > 0) {
+ ctx.shadowColor = shadowColor;
+ ctx.shadowBlur = shadowBlur;
+ }
+
+ ctx.beginPath();
+ ctx.roundRect(-halfWidth, -halfHeight, width, height, radius);
+ ctx.fillStyle = fillStyle;
+ ctx.fill();
+
+ ctx.shadowBlur = 0;
+ ctx.strokeStyle = borderColor;
+ ctx.lineWidth = borderWidth;
+ ctx.stroke();
+
+ if (accentColor) {
+ ctx.fillStyle = accentColor;
+ ctx.beginPath();
+ ctx.roundRect(-halfWidth, -halfHeight, accentWidth, height, [radius, 0, 0, radius]);
+ ctx.fill();
+ }
+}
+
+export function drawPillStackLayer(
+ ctx: CanvasRenderingContext2D,
+ options: {
+ width: number;
+ height: number;
+ radius: number;
+ offsetX: number;
+ offsetY: number;
+ fillColor: string;
+ fillAlpha: number;
+ }
+): void {
+ const { width, height, radius, offsetX, offsetY, fillColor, fillAlpha } = options;
+ const halfWidth = width / 2;
+ const halfHeight = height / 2;
+
+ ctx.beginPath();
+ ctx.roundRect(-halfWidth + offsetX, -halfHeight + offsetY, width, height, radius);
+ ctx.fillStyle = hexWithAlpha(fillColor, fillAlpha);
+ ctx.fill();
+}
diff --git a/packages/agent-graph/src/canvas/draw-processes.ts b/packages/agent-graph/src/canvas/draw-processes.ts
index 25485126..c7f8e9b9 100644
--- a/packages/agent-graph/src/canvas/draw-processes.ts
+++ b/packages/agent-graph/src/canvas/draw-processes.ts
@@ -17,7 +17,10 @@ export function drawProcesses(
time: number,
selectedId: string | null,
hoveredId: string | null,
+ focusNodeIds?: ReadonlySet | null,
+ zoom = 1
): void {
+ const simplify = zoom < 0.2;
for (const node of nodes) {
if (node.kind !== 'process') continue;
@@ -26,14 +29,17 @@ export function drawProcesses(
const r = NODE.radiusProcess;
const isSelected = node.id === selectedId;
const isHovered = node.id === hoveredId;
+ const focusOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1;
ctx.save();
- ctx.globalAlpha = 0.8;
+ ctx.globalAlpha = 0.8 * focusOpacity;
- // Glow — use cached sprite instead of createRadialGradient per frame
const procColor = node.color ?? COLORS.tool_calling;
- const glowSprite = getGlowSprite(procColor, r * 2, 0.19, 0);
- ctx.drawImage(glowSprite, x - r * 2, y - r * 2);
+ if (!simplify) {
+ // Glow — use cached sprite instead of createRadialGradient per frame
+ const glowSprite = getGlowSprite(procColor, r * 2, 0.19, 0);
+ ctx.drawImage(glowSprite, x - r * 2, y - r * 2);
+ }
// Body
ctx.beginPath();
@@ -44,21 +50,25 @@ export function drawProcesses(
ctx.lineWidth = isSelected ? 2 : 1;
ctx.stroke();
- // Spinning ring for active processes
- const spinAngle = time * 2;
- ctx.beginPath();
- ctx.arc(x, y, r + 3, spinAngle, spinAngle + Math.PI * 0.8);
- ctx.strokeStyle = hexWithAlpha(procColor, 0.38);
- ctx.lineWidth = 1.5;
- ctx.stroke();
+ if (!simplify) {
+ // Spinning ring for active processes
+ const spinAngle = time * 2;
+ ctx.beginPath();
+ ctx.arc(x, y, r + 3, spinAngle, spinAngle + Math.PI * 0.8);
+ ctx.strokeStyle = hexWithAlpha(procColor, 0.38);
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ }
- // Label
- ctx.font = '7px monospace';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'top';
- ctx.fillStyle = COLORS.textDim;
- const label = node.label.length > 12 ? node.label.slice(0, 12) + '...' : node.label;
- ctx.fillText(label, x, y + r + 4);
+ if (!simplify) {
+ // Label
+ ctx.font = '7px monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = COLORS.textDim;
+ const label = node.label.length > 12 ? node.label.slice(0, 12) + '...' : node.label;
+ ctx.fillText(label, x, y + r + 4);
+ }
ctx.restore();
}
diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts
index 04184bf8..ccc691a8 100644
--- a/packages/agent-graph/src/canvas/draw-tasks.ts
+++ b/packages/agent-graph/src/canvas/draw-tasks.ts
@@ -7,6 +7,7 @@ import type { GraphNode } from '../ports/types';
import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors';
import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants';
import { truncateText } from './draw-misc';
+import { drawPillShell, drawPillStackLayer } from './draw-pill-shell';
import { hexWithAlpha } from './render-cache';
import type { KanbanZoneInfo } from '../layout/kanbanLayout';
@@ -19,11 +20,14 @@ export function drawTasks(
time: number,
selectedId: string | null,
hoveredId: string | null,
+ focusNodeIds?: ReadonlySet | null,
+ zoom = 1
): void {
+ const simplify = zoom < 0.2;
for (const node of nodes) {
if (node.kind !== 'task') continue;
- const opacity = getTaskOpacity(node);
+ const opacity = getTaskOpacity(node, focusNodeIds);
if (opacity < MIN_VISIBLE_OPACITY) continue;
const x = node.x ?? 0;
@@ -34,7 +38,11 @@ export function drawTasks(
ctx.save();
ctx.globalAlpha = opacity;
- drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
+ if (simplify) {
+ drawTaskPillLod(ctx, x, y, node, isSelected, isHovered);
+ } else {
+ drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
+ }
ctx.restore();
}
@@ -42,8 +50,9 @@ export function drawTasks(
// ─── Private ────────────────────────────────────────────────────────────────
-function getTaskOpacity(_node: GraphNode): number {
- if (_node.taskStatus === 'deleted') return 0;
+function getTaskOpacity(node: GraphNode, focusNodeIds?: ReadonlySet | null): number {
+ if (node.taskStatus === 'deleted') return 0;
+ if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25;
return 1;
}
@@ -54,7 +63,7 @@ function drawTaskPill(
node: GraphNode,
time: number,
isSelected: boolean,
- isHovered: boolean,
+ isHovered: boolean
): void {
const w = TASK_PILL.width;
const h = TASK_PILL.height;
@@ -65,20 +74,28 @@ function drawTaskPill(
const statusColor = getTaskStatusColor(node.taskStatus);
const reviewColor = getReviewStateColor(node.reviewState);
+ ctx.save();
+ ctx.translate(x, y);
+
+ if (node.isOverflowStack) {
+ drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered);
+ ctx.restore();
+ return;
+ }
+
// Pulse only for active work — completed + approved = static
const needsAttention =
(node.taskStatus === 'in_progress' && node.reviewState !== 'approved') ||
node.reviewState === 'review' ||
node.reviewState === 'needsFix' ||
- (node.needsClarification != null);
+ node.needsClarification != null;
const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved';
- const breathe = needsAttention && !isFinished
- ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed)
- : 1;
+ const breathe =
+ needsAttention && !isFinished
+ ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed)
+ : 1;
const scale = breathe;
- ctx.save();
- ctx.translate(x, y);
ctx.scale(scale, scale);
// Shadow — stronger for attention tasks, red for blocked
@@ -88,43 +105,31 @@ function drawTaskPill(
ctx.shadowBlur = needsAttention || node.isBlocked ? 12 : 4;
// Background fill
- ctx.beginPath();
- ctx.roundRect(-halfW, -halfH, w, h, r);
- ctx.fillStyle = isSelected
- ? COLORS.cardBgSelected
- : isHovered
- ? 'rgba(15, 20, 40, 0.7)'
- : COLORS.cardBg;
- ctx.fill();
- ctx.shadowBlur = 0;
-
- // Border — red for blocked tasks
- ctx.beginPath();
- ctx.roundRect(-halfW, -halfH, w, h, r);
- if (node.isBlocked) {
- ctx.strokeStyle = hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.9 : 0.7);
- ctx.lineWidth = isSelected ? 2.5 : 1.8;
- } else {
- ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5);
- ctx.lineWidth = isSelected ? 2 : 1;
- }
- ctx.stroke();
-
- // Blocked indicator — red left stripe
- if (node.isBlocked) {
- ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6);
- ctx.beginPath();
- ctx.roundRect(-halfW, -halfH, 4, h, [r, 0, 0, r]);
- ctx.fill();
- }
+ drawPillShell(ctx, {
+ width: w,
+ height: h,
+ radius: r,
+ fillStyle: isSelected
+ ? COLORS.cardBgSelected
+ : isHovered
+ ? 'rgba(15, 20, 40, 0.7)'
+ : COLORS.cardBg,
+ borderColor: node.isBlocked
+ ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.9 : 0.7)
+ : hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5),
+ borderWidth: node.isBlocked ? (isSelected ? 2.5 : 1.8) : isSelected ? 2 : 1,
+ shadowColor: node.isBlocked
+ ? hexWithAlpha(COLORS.edgeBlocking, 0.3)
+ : hexWithAlpha(statusColor, 0.25),
+ shadowBlur: needsAttention || node.isBlocked ? 12 : 4,
+ accentColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.6) : undefined,
+ });
// Review state overlay border — pulsing for review/needsFix, STATIC for approved
if (reviewColor !== 'transparent') {
ctx.beginPath();
ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1);
- const reviewAlpha = node.reviewState === 'approved'
- ? 0.6 // static — no pulse
- : 0.5 + 0.3 * Math.sin(time * 3); // pulsing for review/needsFix
+ const reviewAlpha = node.reviewState === 'approved' ? 0.6 : 0.5 + 0.3 * Math.sin(time * 3);
ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha);
ctx.lineWidth = 1.5;
ctx.stroke();
@@ -147,7 +152,10 @@ function drawTaskPill(
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary;
const textX = -halfW + 10;
- const maxW = w - 18;
+ const hasReviewChip =
+ node.reviewState !== 'approved' &&
+ (node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName));
+ const maxW = hasReviewChip ? w - 64 : w - 18;
const subject = truncateText(ctx, node.sublabel, maxW, ctx.font);
ctx.fillText(subject, textX, -4);
}
@@ -169,6 +177,13 @@ function drawTaskPill(
ctx.fillText('\u2713', halfW - 8, 0); // ✓
}
+ if (
+ node.reviewState !== 'approved' &&
+ (node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && node.reviewerName))
+ ) {
+ drawReviewChip(ctx, halfW, -halfH, node);
+ }
+
// Comment count badge — on the bottom-right border edge, 1.5x bigger
if (node.totalCommentCount && node.totalCommentCount > 0) {
const badgeX = halfW - 6;
@@ -215,13 +230,154 @@ function drawTaskPill(
ctx.restore();
}
+function drawTaskPillLod(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ node: GraphNode,
+ isSelected: boolean,
+ isHovered: boolean
+): void {
+ const w = TASK_PILL.width;
+ const h = TASK_PILL.height;
+ const r = TASK_PILL.borderRadius;
+ const halfW = w / 2;
+ const halfH = h / 2;
+
+ const statusColor = getTaskStatusColor(node.taskStatus);
+
+ ctx.save();
+ ctx.translate(x, y);
+
+ if (node.isOverflowStack) {
+ drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered);
+ ctx.restore();
+ return;
+ }
+
+ ctx.beginPath();
+ ctx.roundRect(-halfW, -halfH, w, h, r);
+ ctx.fillStyle = isSelected
+ ? COLORS.cardBgSelected
+ : isHovered
+ ? 'rgba(15, 20, 40, 0.78)'
+ : COLORS.cardBg;
+ ctx.fill();
+ ctx.strokeStyle = node.isBlocked
+ ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65)
+ : hexWithAlpha(statusColor, isSelected ? 0.8 : 0.55);
+ ctx.lineWidth = node.isBlocked ? (isSelected ? 2.2 : 1.5) : isSelected ? 2 : 1;
+ ctx.stroke();
+
+ if (node.isBlocked) {
+ ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6);
+ ctx.beginPath();
+ ctx.roundRect(-halfW, -halfH, 4, h, [r, 0, 0, r]);
+ ctx.fill();
+ }
+
+ ctx.restore();
+}
+
+function drawOverflowStack(
+ ctx: CanvasRenderingContext2D,
+ halfW: number,
+ halfH: number,
+ r: number,
+ node: GraphNode,
+ isSelected: boolean,
+ isHovered: boolean
+): void {
+ for (const [offset, alpha] of [
+ [6, 0.18],
+ [3, 0.28],
+ ] as const) {
+ drawPillStackLayer(ctx, {
+ width: TASK_PILL.width,
+ height: TASK_PILL.height,
+ radius: r,
+ offsetX: offset,
+ offsetY: -offset,
+ fillColor: '#334155',
+ fillAlpha: alpha,
+ });
+ }
+
+ drawPillShell(ctx, {
+ width: TASK_PILL.width,
+ height: TASK_PILL.height,
+ radius: r,
+ fillStyle: isSelected
+ ? COLORS.cardBgSelected
+ : isHovered
+ ? 'rgba(15, 20, 40, 0.78)'
+ : COLORS.cardBg,
+ borderColor: node.isBlocked
+ ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65)
+ : hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55),
+ borderWidth: node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 2 : 1,
+ accentColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.6) : undefined,
+ });
+
+ ctx.font = 'bold 12px sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = COLORS.textPrimary;
+ ctx.fillText(node.label, -halfW + 12, -2);
+
+ ctx.font = '7px monospace';
+ ctx.fillStyle = COLORS.textDim;
+ ctx.fillText('more tasks', -halfW + 12, 10);
+}
+
+function drawReviewChip(
+ ctx: CanvasRenderingContext2D,
+ halfW: number,
+ halfH: number,
+ node: GraphNode
+): void {
+ const chipText = node.reviewMode === 'manual' ? 'REV' : (node.reviewerName ?? 'REV');
+ const chipColor = node.reviewMode === 'manual' ? '#8b5cf6' : (node.reviewerColor ?? '#38bdf8');
+ const chipX = halfW - 44;
+ const chipY = halfH + 10;
+ const chipW = 34;
+ const chipH = 12;
+
+ ctx.beginPath();
+ ctx.roundRect(chipX, chipY, chipW, chipH, 6);
+ ctx.fillStyle = hexWithAlpha(chipColor, 0.2);
+ ctx.fill();
+ ctx.strokeStyle = hexWithAlpha(chipColor, 0.55);
+ ctx.lineWidth = 1;
+ ctx.stroke();
+
+ ctx.font = 'bold 7px monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = hexWithAlpha(chipColor, 0.95);
+ ctx.fillText(
+ chipText.length > 8 ? `${chipText.slice(0, 7)}…` : chipText,
+ chipX + chipW / 2,
+ chipY + chipH / 2 + 0.5
+ );
+
+ if (node.changePresence === 'has_changes') {
+ ctx.beginPath();
+ ctx.arc(chipX + chipW + 4, chipY + chipH / 2, 2.5, 0, Math.PI * 2);
+ ctx.fillStyle = '#38bdf8';
+ ctx.fill();
+ }
+}
+
/**
* Draw kanban column headers above task columns.
*/
export function drawColumnHeaders(
ctx: CanvasRenderingContext2D,
zones: KanbanZoneInfo[],
+ zoom = 1
): void {
+ if (zoom < 0.22) return;
for (const zone of zones) {
// Section header for unassigned tasks — larger, centered above all columns
if (zone.ownerId === '__unassigned__') {
diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts
index 8e77998e..3d3577c3 100644
--- a/packages/agent-graph/src/canvas/hit-detection.ts
+++ b/packages/agent-graph/src/canvas/hit-detection.ts
@@ -3,8 +3,9 @@
* Adapted from agent-flow's hit-detection.ts (Apache 2.0).
*/
-import type { GraphNode } from '../ports/types';
-import { NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants';
+import type { GraphEdge, GraphNode } from '../ports/types';
+import { BEAM, NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants';
+import { bezierPoint, computeControlPoints } from './draw-edges';
/**
* Find the node at the given world-space coordinates.
@@ -65,3 +66,192 @@ export function findNodeAt(
return hit;
}
+
+const EDGE_HIT_PRIORITY: Record = {
+ blocking: 5,
+ related: 4,
+ message: 3,
+ ownership: 2,
+ 'parent-child': 1,
+};
+
+function getEdgeHitRadius(edgeType: GraphEdge['type']): number {
+ switch (edgeType) {
+ case 'parent-child':
+ return Math.max(BEAM.parentChild.startW, BEAM.parentChild.endW) * 0.5 + HIT_DETECTION.edgePadding;
+ case 'ownership':
+ return Math.max(BEAM.ownership.startW, BEAM.ownership.endW) * 0.5 + HIT_DETECTION.edgePadding;
+ case 'blocking':
+ return Math.max(BEAM.blocking.startW, BEAM.blocking.endW) * 0.5 + HIT_DETECTION.edgePadding;
+ case 'related':
+ return Math.max(BEAM.related.startW, BEAM.related.endW) * 0.5 + HIT_DETECTION.edgePadding;
+ case 'message':
+ return Math.max(BEAM.message.startW, BEAM.message.endW) * 0.5 + HIT_DETECTION.edgePadding;
+ }
+}
+
+function distanceToSegmentSquared(
+ px: number,
+ py: number,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number
+): number {
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ if (dx === 0 && dy === 0) {
+ const ddx = px - x1;
+ const ddy = py - y1;
+ return ddx * ddx + ddy * ddy;
+ }
+
+ const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)));
+ const lx = x1 + dx * t;
+ const ly = y1 + dy * t;
+ const ddx = px - lx;
+ const ddy = py - ly;
+ return ddx * ddx + ddy * ddy;
+}
+
+function distanceToBezierSquared(
+ px: number,
+ py: number,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number
+): number {
+ const cp = computeControlPoints(x1, y1, x2, y2);
+ let previous = { x: x1, y: y1 };
+ let best = Number.POSITIVE_INFINITY;
+
+ for (let segment = 1; segment <= 20; segment += 1) {
+ const next = bezierPoint(x1, y1, cp, x2, y2, segment / 20);
+ best = Math.min(best, distanceToSegmentSquared(px, py, previous.x, previous.y, next.x, next.y));
+ previous = next;
+ }
+
+ return best;
+}
+
+function getBezierBounds(
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ padding: number
+): { left: number; top: number; right: number; bottom: number } {
+ const cp = computeControlPoints(x1, y1, x2, y2);
+ const left = Math.min(x1, x2, cp.cp1x, cp.cp2x) - padding;
+ const right = Math.max(x1, x2, cp.cp1x, cp.cp2x) + padding;
+ const top = Math.min(y1, y2, cp.cp1y, cp.cp2y) - padding;
+ const bottom = Math.max(y1, y2, cp.cp1y, cp.cp2y) + padding;
+ return { left, top, right, bottom };
+}
+
+function boundsIntersect(
+ left: number,
+ top: number,
+ right: number,
+ bottom: number,
+ other: { left: number; top: number; right: number; bottom: number }
+): boolean {
+ return left <= other.right && right >= other.left && top <= other.bottom && bottom >= other.top;
+}
+
+export function collectInteractiveEdgesInViewport(
+ edges: GraphEdge[],
+ nodeMap: Map,
+ bounds: { left: number; top: number; right: number; bottom: number },
+): GraphEdge[] {
+ const candidates: GraphEdge[] = [];
+
+ for (const edge of edges) {
+ if (edge.type !== 'blocking') {
+ continue;
+ }
+
+ const source = nodeMap.get(edge.source);
+ const target = nodeMap.get(edge.target);
+ if (!source || !target) continue;
+ if (source.x == null || source.y == null || target.x == null || target.y == null) continue;
+
+ const edgeBounds = getBezierBounds(
+ source.x,
+ source.y,
+ target.x,
+ target.y,
+ getEdgeHitRadius(edge.type) + 24
+ );
+ if (!boundsIntersect(edgeBounds.left, edgeBounds.top, edgeBounds.right, edgeBounds.bottom, bounds)) {
+ continue;
+ }
+
+ candidates.push(edge);
+ }
+
+ return candidates;
+}
+
+export function findEdgeAt(
+ worldX: number,
+ worldY: number,
+ edges: GraphEdge[],
+ nodeMap: Map,
+): string | null {
+ let bestHit: { id: string; distanceSquared: number; priority: number } | null = null;
+
+ for (const edge of edges) {
+ const source = nodeMap.get(edge.source);
+ const target = nodeMap.get(edge.target);
+ if (!source || !target) continue;
+ if (source.x == null || source.y == null || target.x == null || target.y == null) continue;
+
+ const radius = getEdgeHitRadius(edge.type);
+ const bounds = getBezierBounds(source.x, source.y, target.x, target.y, radius);
+ if (
+ worldX < bounds.left ||
+ worldX > bounds.right ||
+ worldY < bounds.top ||
+ worldY > bounds.bottom
+ ) {
+ continue;
+ }
+ const distanceSquared = distanceToBezierSquared(
+ worldX,
+ worldY,
+ source.x,
+ source.y,
+ target.x,
+ target.y
+ );
+ if (distanceSquared > radius * radius) {
+ continue;
+ }
+
+ const priority = EDGE_HIT_PRIORITY[edge.type];
+ if (
+ !bestHit ||
+ distanceSquared < bestHit.distanceSquared ||
+ (distanceSquared === bestHit.distanceSquared && priority > bestHit.priority)
+ ) {
+ bestHit = { id: edge.id, distanceSquared, priority };
+ }
+ }
+
+ return bestHit?.id ?? null;
+}
+
+export function getEdgeMidpoint(
+ edge: GraphEdge,
+ nodeMap: Map
+): { x: number; y: number } | null {
+ const source = nodeMap.get(edge.source);
+ const target = nodeMap.get(edge.target);
+ if (!source || !target) return null;
+ if (source.x == null || source.y == null || target.x == null || target.y == null) return null;
+
+ const cp = computeControlPoints(source.x, source.y, target.x, target.y);
+ return bezierPoint(source.x, source.y, cp, target.x, target.y, 0.5);
+}
diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts
index 54c7129a..59d32dcc 100644
--- a/packages/agent-graph/src/constants/canvas-constants.ts
+++ b/packages/agent-graph/src/constants/canvas-constants.ts
@@ -211,6 +211,24 @@ export const PARTICLE_DRAW = {
lifetime: 2.0,
} as const;
+export const HANDOFF_CARD = {
+ triggerProgress: 0.58,
+ lingerSeconds: 3.2,
+ fadeInSeconds: 0.14,
+ fadeOutSeconds: 0.35,
+ width: 196,
+ maxVisible: 6,
+ maxPerDestination: 2,
+ baseHeight: 42,
+ previewLineHeight: 10,
+ previewMaxLines: 2,
+ previewMaxWidth: 176,
+ badgeGap: 8,
+ stackGap: 10,
+ viewportPadding: 12,
+ anchorGap: 14,
+} as const;
+
// ─── Hit detection ──────────────────────────────────────────────────────────
export const HIT_DETECTION = {
@@ -218,13 +236,15 @@ export const HIT_DETECTION = {
agentPadding: 8,
/** Task pill hit area padding */
taskPadding: 4,
+ /** Extra padding around curved edges for easier inspection */
+ edgePadding: 6,
} as const;
// ─── Background ─────────────────────────────────────────────────────────────
export const BACKGROUND = {
/** Number of depth particles (stars) */
- starCount: 80,
+ starCount: 320,
/** Hex grid cell size */
hexSize: 30,
/** Hex grid max alpha */
diff --git a/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts
index 75b1ae89..0a842b91 100644
--- a/packages/agent-graph/src/hooks/useGraphCamera.ts
+++ b/packages/agent-graph/src/hooks/useGraphCamera.ts
@@ -7,6 +7,7 @@
import { useRef, useCallback } from 'react';
import type { GraphNode } from '../ports/types';
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
+import type { WorldBounds } from '../layout/launchAnchor';
export interface CameraTransform {
x: number;
@@ -22,7 +23,7 @@ export interface UseGraphCameraResult {
handlePanStart: (sx: number, sy: number) => void;
handlePanMove: (sx: number, sy: number) => void;
handlePanEnd: () => void;
- zoomToFit: (nodes: GraphNode[], canvasW: number, canvasH: number) => void;
+ zoomToFit: (nodes: GraphNode[], canvasW: number, canvasH: number, extraBounds?: WorldBounds[]) => void;
zoomIn: () => void;
zoomOut: () => void;
updateInertia: () => void;
@@ -117,8 +118,8 @@ export function useGraphCamera(): UseGraphCameraResult {
v.vy *= ANIM.inertiaDecay;
}, []);
- const zoomToFit = useCallback((nodes: GraphNode[], canvasW: number, canvasH: number) => {
- if (nodes.length === 0) return;
+ const zoomToFit = useCallback((nodes: GraphNode[], canvasW: number, canvasH: number, extraBounds: WorldBounds[] = []) => {
+ if (nodes.length === 0 && extraBounds.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of nodes) {
@@ -135,6 +136,13 @@ export function useGraphCamera(): UseGraphCameraResult {
maxY = Math.max(maxY, y + pad);
}
+ for (const bounds of extraBounds) {
+ minX = Math.min(minX, bounds.left);
+ minY = Math.min(minY, bounds.top);
+ maxX = Math.max(maxX, bounds.right);
+ maxY = Math.max(maxY, bounds.bottom);
+ }
+
const padding = ANIM.viewportPadding;
const contentW = maxX - minX + padding * 2;
const contentH = maxY - minY + padding * 2;
diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts
index 94ecb8ba..bd6dd240 100644
--- a/packages/agent-graph/src/hooks/useGraphSimulation.ts
+++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts
@@ -23,12 +23,28 @@ import { getNodeStrategy } from '../strategies';
import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects';
import { getStateColor } from '../constants/colors';
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
+import {
+ LAUNCH_ANCHOR_LAYOUT,
+ getActivityAnchorId,
+ getHandoffAnchorBounds,
+ getLaunchAnchorBounds,
+ getLaunchAnchorId,
+ getLaunchAnchorTarget,
+ isActivityAnchorId,
+ isLaunchAnchorId,
+ type WorldBounds,
+} from '../layout/launchAnchor';
+import { ACTIVITY_ANCHOR_LAYOUT, getActivityAnchorTarget } from '../layout/activityLane';
// ─── Force Node/Link types (properly typed, no loose `string`) ──────────────
+type InternalNodeKind = GraphNodeKind | 'launch-anchor' | 'activity-anchor';
+
interface ForceNode extends SimulationNodeDatum {
id: string;
- kind: GraphNodeKind;
+ kind: InternalNodeKind;
+ anchorForLeadId?: string;
+ anchorForNodeId?: string;
}
interface ForceLink extends SimulationLinkDatum {
@@ -51,6 +67,10 @@ export interface UseGraphSimulationResult {
updateData: (nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => void;
/** Tick one simulation frame — called from parent's RAF loop */
tick: (dt: number) => void;
+ setNodePosition: (nodeId: string, x: number, y: number) => void;
+ getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
+ getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null;
+ getExtraWorldBounds: () => WorldBounds[];
}
// ─── Deterministic hash for stable initial positions ─────────────────────────
@@ -64,6 +84,70 @@ function deterministicPosition(id: string, seed: number): number {
return ((hash & 0x7fffffff) % 1000) / 1000 - 0.5;
}
+function syncLaunchAnchors(forceNodes: ForceNode[]): void {
+ const forceNodeMap = new Map();
+ for (const node of forceNodes) {
+ forceNodeMap.set(node.id, node);
+ }
+ const leadNode = forceNodes.find((node) => node.kind === 'lead');
+ const leadX = leadNode?.x ?? leadNode?.fx ?? null;
+
+ for (const node of forceNodes) {
+ let target: { x: number; y: number } | null = null;
+ if (node.kind === 'launch-anchor' && node.anchorForLeadId) {
+ const leadNode = forceNodeMap.get(node.anchorForLeadId);
+ if (!leadNode) continue;
+ target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0);
+ } else if (node.kind === 'activity-anchor' && node.anchorForNodeId) {
+ const ownerNode = forceNodeMap.get(node.anchorForNodeId);
+ if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue;
+ target = getActivityAnchorTarget({
+ nodeX: ownerNode.x ?? 0,
+ nodeY: ownerNode.y ?? 0,
+ nodeKind: ownerNode.kind,
+ leadX,
+ });
+ } else {
+ continue;
+ }
+ if (!target) {
+ continue;
+ }
+
+ node.fx = target.x;
+ node.fy = target.y;
+ node.x = target.x;
+ node.y = target.y;
+ node.vx = 0;
+ node.vy = 0;
+ }
+}
+
+function updateLaunchAnchorCaches(
+ forceNodes: ForceNode[],
+ launchPositions: Map,
+ activityPositions: Map,
+ bounds: WorldBounds[]
+): void {
+ launchPositions.clear();
+ activityPositions.clear();
+ bounds.length = 0;
+
+ for (const node of forceNodes) {
+ const x = node.x ?? node.fx ?? 0;
+ const y = node.y ?? node.fy ?? 0;
+ if (node.kind === 'launch-anchor' && node.anchorForLeadId) {
+ launchPositions.set(node.anchorForLeadId, { x, y });
+ bounds.push(getLaunchAnchorBounds(x, y));
+ continue;
+ }
+ if (node.kind === 'activity-anchor' && node.anchorForNodeId) {
+ activityPositions.set(node.anchorForNodeId, { x, y });
+ bounds.push(getHandoffAnchorBounds(x, y));
+ }
+ }
+}
+
// ─── Hook ───────────────────────────────────────────────────────────────────
export function useGraphSimulation(): UseGraphSimulationResult {
@@ -76,6 +160,9 @@ export function useGraphSimulation(): UseGraphSimulationResult {
});
const simRef = useRef | null>(null);
+ const launchAnchorPositionsRef = useRef(new Map());
+ const activityAnchorPositionsRef = useRef(new Map());
+ const extraWorldBoundsRef = useRef([]);
// Initialize d3-force simulation
const initSimulation = useCallback(() => {
@@ -84,9 +171,18 @@ export function useGraphSimulation(): UseGraphSimulationResult {
const sim = forceSimulation([])
.force('center', forceCenter(0, 0).strength(FORCE.centerStrength))
.force('charge', forceManyBody().strength((d) => {
+ if (d.kind === 'launch-anchor' || d.kind === 'activity-anchor') {
+ return 0;
+ }
return getNodeStrategy(d.kind).getChargeStrength();
}))
.force('collide', forceCollide().radius((d) => {
+ if (d.kind === 'launch-anchor') {
+ return LAUNCH_ANCHOR_LAYOUT.collisionRadius;
+ }
+ if (d.kind === 'activity-anchor') {
+ return ACTIVITY_ANCHOR_LAYOUT.collisionRadius;
+ }
return getNodeStrategy(d.kind).getCollisionRadius();
}))
.force('link', forceLink([]).id((d) => d.id).distance((d) => {
@@ -113,6 +209,15 @@ export function useGraphSimulation(): UseGraphSimulationResult {
let sim = simRef.current;
if (!sim) sim = initSimulation();
+ const prevInternalPositions = new Map();
+ for (const forceNode of sim.nodes()) {
+ if (!isLaunchAnchorId(forceNode.id) && !isActivityAnchorId(forceNode.id)) continue;
+ prevInternalPositions.set(forceNode.id, {
+ x: forceNode.x ?? forceNode.fx ?? 0,
+ y: forceNode.y ?? forceNode.fy ?? 0,
+ });
+ }
+
// Tasks excluded from d3-force — positioned by KanbanLayoutEngine
const forceNodes: ForceNode[] = nodes
.filter((n) => n.kind !== 'task')
@@ -128,6 +233,51 @@ export function useGraphSimulation(): UseGraphSimulationResult {
fy: n.fy,
}));
+ for (const leadNode of nodes.filter((node) => node.kind === 'lead')) {
+ const anchorId = getLaunchAnchorId(leadNode.id);
+ const cached = prevInternalPositions.get(anchorId);
+ const target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0);
+ const position = cached ?? target;
+ forceNodes.push({
+ id: anchorId,
+ kind: 'launch-anchor',
+ anchorForLeadId: leadNode.id,
+ x: position.x,
+ y: position.y,
+ vx: 0,
+ vy: 0,
+ fx: target.x,
+ fy: target.y,
+ });
+ }
+
+ const leadNode = nodes.find((node) => node.kind === 'lead');
+ for (const ownerNode of nodes.filter(
+ (node): node is GraphNode & { kind: 'lead' | 'member' } =>
+ node.kind === 'lead' || node.kind === 'member'
+ )) {
+ const anchorId = getActivityAnchorId(ownerNode.id);
+ const cached = prevInternalPositions.get(anchorId);
+ const target = getActivityAnchorTarget({
+ nodeX: ownerNode.x ?? 0,
+ nodeY: ownerNode.y ?? 0,
+ nodeKind: ownerNode.kind,
+ leadX: leadNode?.x ?? null,
+ });
+ const position = cached ?? target;
+ forceNodes.push({
+ id: anchorId,
+ kind: 'activity-anchor',
+ anchorForNodeId: ownerNode.id,
+ x: position.x,
+ y: position.y,
+ vx: 0,
+ vy: 0,
+ fx: target.x,
+ fy: target.y,
+ });
+ }
+
// Links only between non-task nodes (parent-child: lead↔member)
const forceNodeIds = new Set(forceNodes.map((n) => n.id));
const forceLinks: ForceLink[] = edges
@@ -144,7 +294,10 @@ export function useGraphSimulation(): UseGraphSimulationResult {
sim.alpha(1);
// Run simulation to near-completion so nodes are settled on first render
- for (let i = 0; i < 120; i++) sim.tick();
+ for (let i = 0; i < 120; i++) {
+ syncLaunchAnchors(sim.nodes());
+ sim.tick();
+ }
sim.alpha(0); // fully settled — no more movement until new data
// Copy settled positions BACK to GraphNode objects
@@ -162,6 +315,12 @@ export function useGraphSimulation(): UseGraphSimulationResult {
// Position tasks in kanban zones relative to their owners
KanbanLayoutEngine.layout(nodes);
+ updateLaunchAnchorCaches(
+ sim.nodes(),
+ launchAnchorPositionsRef.current,
+ activityAnchorPositionsRef.current,
+ extraWorldBoundsRef.current
+ );
}, [initSimulation]);
// Track previous node IDs and states for effect spawning
@@ -225,7 +384,49 @@ export function useGraphSimulation(): UseGraphSimulationResult {
// Tick one frame (called by parent's RAF loop)
const tick = useCallback((dt: number) => {
- tickFrame(stateRef.current, simRef.current, dt);
+ tickFrame(
+ stateRef.current,
+ simRef.current,
+ dt,
+ launchAnchorPositionsRef.current,
+ activityAnchorPositionsRef.current,
+ extraWorldBoundsRef.current
+ );
+ }, []);
+
+ const setNodePosition = useCallback((nodeId: string, x: number, y: number) => {
+ const graphNode = stateRef.current.nodes.find((node) => node.id === nodeId);
+ if (graphNode) {
+ graphNode.fx = x;
+ graphNode.fy = y;
+ graphNode.x = x;
+ graphNode.y = y;
+ graphNode.vx = 0;
+ graphNode.vy = 0;
+ }
+
+ const sim = simRef.current;
+ if (!sim) {
+ return;
+ }
+
+ const simNode = sim.nodes().find((node) => node.id === nodeId);
+ if (simNode) {
+ simNode.fx = x;
+ simNode.fy = y;
+ simNode.x = x;
+ simNode.y = y;
+ simNode.vx = 0;
+ simNode.vy = 0;
+ }
+
+ syncLaunchAnchors(sim.nodes());
+ updateLaunchAnchorCaches(
+ sim.nodes(),
+ launchAnchorPositionsRef.current,
+ activityAnchorPositionsRef.current,
+ extraWorldBoundsRef.current
+ );
}, []);
// Cleanup
@@ -235,7 +436,24 @@ export function useGraphSimulation(): UseGraphSimulationResult {
};
}, []);
- return { stateRef, updateData, tick };
+ const getLaunchAnchorWorldPosition = useCallback((leadNodeId: string) => {
+ return launchAnchorPositionsRef.current.get(leadNodeId) ?? null;
+ }, []);
+
+ const getExtraWorldBounds = useCallback(() => {
+ return extraWorldBoundsRef.current;
+ }, []);
+
+ return {
+ stateRef,
+ updateData,
+ tick,
+ setNodePosition,
+ getLaunchAnchorWorldPosition,
+ getActivityAnchorWorldPosition: (nodeId: string) =>
+ activityAnchorPositionsRef.current.get(nodeId) ?? null,
+ getExtraWorldBounds,
+ };
}
function mergeParticles(
@@ -261,11 +479,15 @@ function tickFrame(
state: SimulationState,
sim: Simulation | null,
dt: number,
+ launchAnchorPositions: Map,
+ activityAnchorPositions: Map,
+ extraWorldBounds: WorldBounds[],
): void {
state.time += dt;
// Tick d3-force (only when simulation is still active)
if (sim && sim.alpha() > 0.001) {
+ syncLaunchAnchors(sim.nodes());
sim.tick(1);
const simNodes = sim.nodes();
@@ -281,6 +503,15 @@ function tickFrame(
node.vy = sn.vy;
}
}
+ updateLaunchAnchorCaches(simNodes, launchAnchorPositions, activityAnchorPositions, extraWorldBounds);
+ } else if (sim) {
+ syncLaunchAnchors(sim.nodes());
+ updateLaunchAnchorCaches(
+ sim.nodes(),
+ launchAnchorPositions,
+ activityAnchorPositions,
+ extraWorldBounds
+ );
}
// Re-layout tasks in kanban zones — always run to handle new/moved tasks
diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts
index 26a1f01c..27a12196 100644
--- a/packages/agent-graph/src/index.ts
+++ b/packages/agent-graph/src/index.ts
@@ -20,8 +20,10 @@ export type {
GraphNode,
GraphEdge,
GraphParticle,
+ GraphActivityItem,
GraphNodeKind,
GraphNodeState,
+ GraphLaunchVisualState,
GraphEdgeType,
GraphParticleKind,
GraphDomainRef,
diff --git a/packages/agent-graph/src/layout/activityLane.ts b/packages/agent-graph/src/layout/activityLane.ts
new file mode 100644
index 00000000..a5766933
--- /dev/null
+++ b/packages/agent-graph/src/layout/activityLane.ts
@@ -0,0 +1,196 @@
+import { KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants';
+import type { GraphActivityItem, GraphNode } from '../ports/types';
+
+export const ACTIVITY_LANE = {
+ width: 296,
+ itemHeight: 58,
+ rowHeight: 62,
+ maxVisibleItems: 3,
+ headerHeight: 18,
+ overflowHeight: 18,
+ horizontalGapLead: 76,
+ horizontalGapMember: 84,
+ bottomClearance: 18,
+ viewportPadding: 12,
+ visiblePadding: 80,
+ minScale: 0,
+ maxScale: 1,
+} as const;
+
+const RESERVED_HEIGHT =
+ ACTIVITY_LANE.headerHeight
+ + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight
+ + ACTIVITY_LANE.overflowHeight;
+
+export const ACTIVITY_ANCHOR_LAYOUT = {
+ reservedWidth: ACTIVITY_LANE.width,
+ reservedHeight: RESERVED_HEIGHT,
+ memberOffsetX: ACTIVITY_LANE.width / 2 + NODE.radiusMember + ACTIVITY_LANE.horizontalGapMember,
+ memberOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance),
+ leadOffsetX: -(ACTIVITY_LANE.width / 2 + NODE.radiusLead + ACTIVITY_LANE.horizontalGapLead),
+ leadOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance),
+ collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 12,
+} as const;
+
+export interface ActivityLaneWindow {
+ items: GraphActivityItem[];
+ overflowCount: number;
+}
+
+export interface ActivityAnchorScreenPlacement {
+ x: number;
+ y: number;
+ scale: number;
+ visible: boolean;
+}
+
+export interface ActivityLaneItemHit {
+ ownerNodeId: string;
+ item: GraphActivityItem;
+}
+
+export type ActivityLaneSide = 'left' | 'right';
+
+export function resolveActivityLaneSide(args: {
+ nodeKind: 'lead' | 'member';
+ nodeX: number;
+ leadX?: number | null;
+}): ActivityLaneSide {
+ const { nodeKind, nodeX, leadX } = args;
+ if (nodeKind === 'lead') {
+ return 'left';
+ }
+ if (leadX == null) {
+ return 'right';
+ }
+ return nodeX < leadX ? 'left' : 'right';
+}
+
+export function getActivityAnchorTarget(args: {
+ nodeX: number;
+ nodeY: number;
+ nodeKind: 'lead' | 'member';
+ leadX?: number | null;
+}): { x: number; y: number } {
+ const { nodeX, nodeY, nodeKind, leadX } = args;
+ const side = resolveActivityLaneSide({ nodeKind, nodeX, leadX });
+ if (side === 'left') {
+ return {
+ x: nodeX + ACTIVITY_ANCHOR_LAYOUT.leadOffsetX,
+ y: nodeY + ACTIVITY_ANCHOR_LAYOUT.leadOffsetY,
+ };
+ }
+
+ return {
+ x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX,
+ y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY,
+ };
+}
+
+export function getActivityLaneBounds(anchorX: number, anchorY: number): {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+} {
+ const halfWidth = ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2;
+ const halfHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2;
+ return {
+ left: anchorX - halfWidth,
+ top: anchorY - halfHeight,
+ right: anchorX + halfWidth,
+ bottom: anchorY + halfHeight,
+ };
+}
+
+export function getActivityLaneScale(zoom: number): number {
+ return Math.max(ACTIVITY_LANE.minScale, Math.min(ACTIVITY_LANE.maxScale, zoom));
+}
+
+export function getActivityAnchorScreenPlacement(args: {
+ anchorX: number;
+ anchorY: number;
+ cameraX: number;
+ cameraY: number;
+ zoom: number;
+ viewportWidth: number;
+ viewportHeight: number;
+}): ActivityAnchorScreenPlacement {
+ const { anchorX, anchorY, cameraX, cameraY, zoom, viewportWidth, viewportHeight } = args;
+ const scale = getActivityLaneScale(zoom);
+ const scaledWidth = ACTIVITY_LANE.width * scale;
+ const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale;
+ const screenX = anchorX * zoom + cameraX;
+ const screenY = anchorY * zoom + cameraY;
+ const x = screenX - scaledWidth / 2;
+ const y = screenY - scaledHeight / 2;
+ const right = x + scaledWidth;
+ const bottom = y + scaledHeight;
+
+ return {
+ x,
+ y,
+ scale,
+ visible:
+ right > -ACTIVITY_LANE.visiblePadding &&
+ x < viewportWidth + ACTIVITY_LANE.visiblePadding &&
+ bottom > -ACTIVITY_LANE.visiblePadding &&
+ y < viewportHeight + ACTIVITY_LANE.visiblePadding,
+ };
+}
+
+export function getVisibleActivityWindow(
+ items: GraphActivityItem[] | undefined
+): ActivityLaneWindow {
+ const source = items ?? [];
+ if (source.length <= ACTIVITY_LANE.maxVisibleItems) {
+ return { items: source, overflowCount: 0 };
+ }
+ return {
+ items: source.slice(0, ACTIVITY_LANE.maxVisibleItems),
+ overflowCount: source.length - ACTIVITY_LANE.maxVisibleItems,
+ };
+}
+
+export function findActivityItemAt(
+ worldX: number,
+ worldY: number,
+ nodes: GraphNode[]
+): ActivityLaneItemHit | null {
+ const leadNode = nodes.find((node) => node.kind === 'lead' && node.x != null);
+ const leadX = leadNode?.x ?? null;
+ for (const node of nodes) {
+ if (!isActivityOwner(node) || node.x == null || node.y == null) continue;
+ const { items } = getVisibleActivityWindow(node.activityItems);
+ if (items.length === 0) continue;
+
+ const anchor = getActivityAnchorTarget({
+ nodeX: node.x,
+ nodeY: node.y,
+ nodeKind: node.kind,
+ leadX,
+ });
+ const bounds = getActivityLaneBounds(anchor.x, anchor.y);
+ const left = bounds.left;
+ const top = bounds.top;
+ const itemsTop = top + ACTIVITY_LANE.headerHeight;
+
+ for (let index = 0; index < items.length; index += 1) {
+ const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
+ if (
+ worldX >= left &&
+ worldX <= left + ACTIVITY_LANE.width &&
+ worldY >= itemTop &&
+ worldY <= itemTop + ACTIVITY_LANE.itemHeight
+ ) {
+ return { ownerNodeId: node.id, item: items[index] };
+ }
+ }
+ }
+
+ return null;
+}
+
+export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } {
+ return node.kind === 'lead' || node.kind === 'member';
+}
diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts
index e8bb24e4..ccbb3be2 100644
--- a/packages/agent-graph/src/layout/kanbanLayout.ts
+++ b/packages/agent-graph/src/layout/kanbanLayout.ts
@@ -10,6 +10,7 @@
import type { GraphNode } from '../ports/types';
import { KANBAN_ZONE } from '../constants/canvas-constants';
import { COLORS } from '../constants/colors';
+import { resolveActivityLaneSide } from './activityLane';
/** Column header info for rendering */
export interface KanbanColumnHeader {
@@ -40,6 +41,35 @@ const COLUMN_LABELS: Record = {
approved: { label: 'Approved', color: COLORS.reviewApproved },
};
+export function getOwnerKanbanBaseX(args: {
+ ownerX: number;
+ ownerKind: GraphNode['kind'];
+ activeColumnCount: number;
+ columnWidth: number;
+ leadX?: number | null;
+}): number {
+ const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args;
+ if (activeColumnCount <= 0) {
+ return ownerX;
+ }
+
+ if (ownerKind !== 'lead' && ownerKind !== 'member') {
+ return ownerX - (activeColumnCount * columnWidth) / 2;
+ }
+
+ const side = resolveActivityLaneSide({
+ nodeKind: ownerKind,
+ nodeX: ownerX,
+ leadX,
+ });
+
+ if (side === 'left') {
+ return ownerX;
+ }
+
+ return ownerX - (activeColumnCount - 1) * columnWidth;
+}
+
export class KanbanLayoutEngine {
// Reusable collections (cleared each call, never GC'd)
static readonly #nodeMap = new Map();
@@ -58,6 +88,7 @@ export class KanbanLayoutEngine {
const nodeMap = this.#nodeMap;
nodeMap.clear();
for (const n of nodes) nodeMap.set(n.id, n);
+ const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
const tasksByOwner = this.#tasksByOwner;
tasksByOwner.clear();
@@ -84,7 +115,7 @@ export class KanbanLayoutEngine {
for (const [ownerId, tasks] of tasksByOwner) {
const owner = nodeMap.get(ownerId);
if (!owner || owner.x == null || owner.y == null) continue;
- const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y, ownerId);
+ const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner, ownerId, leadX);
if (zoneInfo) this.zones.push(zoneInfo);
}
@@ -93,9 +124,16 @@ export class KanbanLayoutEngine {
// ─── Private ──────────────────────────────────────────────────────────────
- static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null {
- const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE;
+ static #layoutZone(
+ tasks: GraphNode[],
+ owner: GraphNode,
+ ownerId: string,
+ leadX: number | null
+ ): KanbanZoneInfo | null {
+ const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE;
const headerHeight = 20; // space for column header label
+ const ownerX = owner.x ?? 0;
+ const ownerY = owner.y ?? 0;
const baseY = ownerY + offsetY;
// Classify tasks into columns
@@ -119,9 +157,15 @@ export class KanbanLayoutEngine {
if (activeColumns.length === 0) return null;
- // Center active columns under owner
- const totalWidth = activeColumns.length * columnWidth;
- const baseX = ownerX - totalWidth / 2;
+ // Keep kanban columns on the open side of the owner, away from the reserved activity lane.
+ // This makes member lanes reserve real visual space instead of only affecting the force layout.
+ const baseX = getOwnerKanbanBaseX({
+ ownerX,
+ ownerKind: owner.kind,
+ activeColumnCount: activeColumns.length,
+ columnWidth,
+ leadX,
+ });
// Build headers + position tasks
const headers: KanbanColumnHeader[] = [];
@@ -129,8 +173,8 @@ export class KanbanLayoutEngine {
for (const [colIdx, col] of activeColumns.entries()) {
const colX = baseX + colIdx * columnWidth;
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
- const overflow = Math.max(0, col.tasks.length - maxVisibleRows);
- const visibleCount = Math.min(col.tasks.length, maxVisibleRows);
+ const overflow = col.tasks.find((task) => task.isOverflowStack)?.overflowCount ?? 0;
+ const visibleCount = col.tasks.length;
// Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y)
headers.push({
@@ -144,13 +188,6 @@ export class KanbanLayoutEngine {
// Position tasks below header
for (const [rowIdx, task] of col.tasks.entries()) {
- if (rowIdx >= maxVisibleRows) {
- task.x = -99999;
- task.y = -99999;
- task.fx = task.x;
- task.fy = task.y;
- continue;
- }
const targetX = colX;
const targetY = baseY + headerHeight + rowIdx * rowHeight;
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
@@ -207,6 +244,7 @@ export class KanbanLayoutEngine {
// Add zone header for unassigned section
if (tasks.length > 0) {
+ const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0);
this.zones.push({
ownerId: '__unassigned__',
ownerX: centerX,
@@ -216,7 +254,7 @@ export class KanbanLayoutEngine {
x: centerX,
y: baseY - 10,
color: COLORS.taskPending,
- overflowCount: Math.max(0, tasks.length - cols * KANBAN_ZONE.maxVisibleRows),
+ overflowCount,
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
}],
});
diff --git a/packages/agent-graph/src/layout/launchAnchor.ts b/packages/agent-graph/src/layout/launchAnchor.ts
new file mode 100644
index 00000000..522c818d
--- /dev/null
+++ b/packages/agent-graph/src/layout/launchAnchor.ts
@@ -0,0 +1,117 @@
+import { NODE } from '../constants/canvas-constants';
+import {
+ ACTIVITY_ANCHOR_LAYOUT,
+ getActivityAnchorTarget,
+ getActivityLaneBounds,
+} from './activityLane';
+
+export interface WorldBounds {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+export interface LaunchAnchorScreenPlacement {
+ x: number;
+ y: number;
+ scale: number;
+ visible: boolean;
+}
+
+export const LAUNCH_ANCHOR_LAYOUT = {
+ compactWidth: 336,
+ compactHeight: 132,
+ anchorCenterOffsetX: 336 / 2 + NODE.radiusLead + 40,
+ anchorCenterOffsetY: -(132 / 2 + NODE.radiusLead + 36),
+ collisionRadius: Math.ceil(Math.hypot(336 / 2, 132 / 2)) + 14,
+ viewportPadding: 12,
+ visiblePadding: 80,
+ minScale: 0,
+ maxScale: 1,
+} as const;
+
+const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:';
+const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:';
+
+export function getLaunchAnchorId(leadNodeId: string): string {
+ return `${LAUNCH_ANCHOR_PREFIX}${leadNodeId}`;
+}
+
+export function getActivityAnchorId(nodeId: string): string {
+ return `${ACTIVITY_ANCHOR_PREFIX}${nodeId}`;
+}
+
+export function isLaunchAnchorId(nodeId: string): boolean {
+ return nodeId.startsWith(LAUNCH_ANCHOR_PREFIX);
+}
+
+export function isActivityAnchorId(nodeId: string): boolean {
+ return nodeId.startsWith(ACTIVITY_ANCHOR_PREFIX);
+}
+
+export function getLaunchAnchorTarget(leadX: number, leadY: number): { x: number; y: number } {
+ return {
+ x: leadX + LAUNCH_ANCHOR_LAYOUT.anchorCenterOffsetX,
+ y: leadY + LAUNCH_ANCHOR_LAYOUT.anchorCenterOffsetY,
+ };
+}
+
+export function getLaunchHudScale(zoom: number): number {
+ return clamp(zoom, LAUNCH_ANCHOR_LAYOUT.minScale, LAUNCH_ANCHOR_LAYOUT.maxScale);
+}
+
+export function getLaunchAnchorBounds(anchorX: number, anchorY: number): WorldBounds {
+ const halfWidth = LAUNCH_ANCHOR_LAYOUT.compactWidth / 2;
+ const halfHeight = LAUNCH_ANCHOR_LAYOUT.compactHeight / 2;
+ return {
+ left: anchorX - halfWidth,
+ top: anchorY - halfHeight,
+ right: anchorX + halfWidth,
+ bottom: anchorY + halfHeight,
+ };
+}
+
+export const getLaunchHudBounds = getLaunchAnchorBounds;
+export const HANDOFF_ANCHOR_LAYOUT = ACTIVITY_ANCHOR_LAYOUT;
+export const getHandoffAnchorId = getActivityAnchorId;
+export const isHandoffAnchorId = isActivityAnchorId;
+export { getActivityAnchorTarget };
+export const getHandoffAnchorTarget = getActivityAnchorTarget;
+export const getHandoffAnchorBounds = getActivityLaneBounds;
+
+export function getLaunchAnchorScreenPlacement(args: {
+ anchorX: number;
+ anchorY: number;
+ cameraX: number;
+ cameraY: number;
+ zoom: number;
+ viewportWidth: number;
+ viewportHeight: number;
+}): LaunchAnchorScreenPlacement {
+ const { anchorX, anchorY, cameraX, cameraY, zoom, viewportWidth, viewportHeight } = args;
+ const scale = getLaunchHudScale(zoom);
+ const scaledWidth = LAUNCH_ANCHOR_LAYOUT.compactWidth * scale;
+ const scaledHeight = LAUNCH_ANCHOR_LAYOUT.compactHeight * scale;
+ const screenX = anchorX * zoom + cameraX;
+ const screenY = anchorY * zoom + cameraY;
+ const rawX = screenX - scaledWidth / 2;
+ const rawY = screenY - scaledHeight / 2;
+ const maxX = viewportWidth - scaledWidth - LAUNCH_ANCHOR_LAYOUT.viewportPadding;
+ const maxY = viewportHeight - scaledHeight - LAUNCH_ANCHOR_LAYOUT.viewportPadding;
+
+ return {
+ x: clamp(rawX, LAUNCH_ANCHOR_LAYOUT.viewportPadding, Math.max(LAUNCH_ANCHOR_LAYOUT.viewportPadding, maxX)),
+ y: clamp(rawY, LAUNCH_ANCHOR_LAYOUT.viewportPadding, Math.max(LAUNCH_ANCHOR_LAYOUT.viewportPadding, maxY)),
+ scale,
+ visible:
+ screenX > -LAUNCH_ANCHOR_LAYOUT.visiblePadding &&
+ screenX < viewportWidth + LAUNCH_ANCHOR_LAYOUT.visiblePadding &&
+ screenY > -LAUNCH_ANCHOR_LAYOUT.visiblePadding &&
+ screenY < viewportHeight + LAUNCH_ANCHOR_LAYOUT.visiblePadding,
+ };
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts
index a7b96067..931f38e2 100644
--- a/packages/agent-graph/src/ports/types.ts
+++ b/packages/agent-graph/src/ports/types.ts
@@ -17,6 +17,13 @@ export type GraphNodeState =
| 'error'
| 'terminated';
+export type GraphLaunchVisualState =
+ | 'waiting'
+ | 'spawning'
+ | 'runtime_pending'
+ | 'settling'
+ | 'error';
+
// ─── Edge & Particle Types ───────────────────────────────────────────────────
export type GraphEdgeType = 'parent-child' | 'ownership' | 'blocking' | 'related' | 'message';
@@ -29,6 +36,18 @@ export type GraphParticleKind =
| 'review_response'
| 'spawn';
+export interface GraphActivityItem {
+ id: string;
+ kind: Exclude;
+ timestamp: string;
+ title: string;
+ preview?: string;
+ accentColor?: string;
+ taskId?: string;
+ taskDisplayId?: string;
+ authorLabel?: string;
+}
+
// ─── Graph Node ──────────────────────────────────────────────────────────────
export interface GraphNode {
@@ -50,6 +69,8 @@ export interface GraphNode {
avatarUrl?: string;
/** Spawn lifecycle status */
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
+ /** Shared launch-stage visual derived by the host app */
+ launchVisualState?: GraphLaunchVisualState;
/** Context window usage ratio (0..1), available for lead only */
contextUsage?: number;
/** Current task ID this member is working on */
@@ -78,6 +99,14 @@ export interface GraphNode {
resultPreview?: string;
source: 'runtime' | 'member_log' | 'inbox';
}>;
+ /** Compact abnormal-state indicator */
+ exceptionTone?: 'warning' | 'error';
+ /** Short human-readable abnormal-state label */
+ exceptionLabel?: string;
+ /** Recent activity feed rendered inline beside the node */
+ activityItems?: GraphActivityItem[];
+ /** Count of older items hidden behind the visible activity window */
+ activityOverflowCount?: number;
// ─── Task-specific ─────────────────────────────────────────────────────
/** Short display ID (e.g., "#3") */
@@ -90,6 +119,14 @@ export interface GraphNode {
taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted';
/** Review state overlay */
reviewState?: 'none' | 'review' | 'needsFix' | 'approved';
+ /** Reviewer shown as a compact handoff chip for active review cycles */
+ reviewerName?: string | null;
+ /** Reviewer chip mode */
+ reviewMode?: 'assigned' | 'manual';
+ /** Reviewer color override for compact review chip */
+ reviewerColor?: string;
+ /** Cheap persisted change-presence state used only for active review chips */
+ changePresence?: 'has_changes' | 'no_changes' | 'unknown';
/** Requires clarification indicator */
needsClarification?: 'lead' | 'user' | null;
/** Task is blocked by other tasks */
@@ -102,6 +139,12 @@ export interface GraphNode {
totalCommentCount?: number;
/** Unread comment count on this task */
unreadCommentCount?: number;
+ /** Synthetic overflow stack node instead of hidden task tails */
+ isOverflowStack?: boolean;
+ /** Number of hidden tasks behind this overflow stack */
+ overflowCount?: number;
+ /** Raw task IDs hidden behind this overflow stack */
+ overflowTaskIds?: string[];
// ─── Process-specific ──────────────────────────────────────────────────
/** Clickable URL for process */
@@ -137,6 +180,12 @@ export interface GraphEdge {
label?: string;
/** Edge color override */
color?: string;
+ /** Number of aggregated raw relations behind this visual edge */
+ aggregateCount?: number;
+ /** Raw source-side task ids represented by this visual edge */
+ sourceTaskIds?: string[];
+ /** Raw target-side task ids represented by this visual edge */
+ targetTaskIds?: string[];
}
// ─── Graph Particle ──────────────────────────────────────────────────────────
@@ -153,6 +202,8 @@ export interface GraphParticle {
size?: number;
/** Short label near particle */
label?: string;
+ /** Longer preview text for transient handoff cards */
+ preview?: string;
/** If true, particle travels from target → source (reverse direction) */
reverse?: boolean;
}
@@ -163,5 +214,11 @@ export type GraphDomainRef =
| { kind: 'lead'; teamName: string; memberName: string }
| { kind: 'member'; teamName: string; memberName: string }
| { kind: 'task'; teamName: string; taskId: string }
+ | {
+ kind: 'task_overflow';
+ teamName: string;
+ ownerMemberName?: string | null;
+ columnKey: string;
+ }
| { kind: 'process'; teamName: string; processId: string }
| { kind: 'crossteam'; teamName: string; externalTeamName: string };
diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx
index 3548c49b..1e8c893d 100644
--- a/packages/agent-graph/src/ui/GraphCanvas.tsx
+++ b/packages/agent-graph/src/ui/GraphCanvas.tsx
@@ -8,8 +8,17 @@
import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types';
-import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer';
+import {
+ drawBackground,
+ createDepthParticles,
+ createShootingStarField,
+ updateDepthParticles,
+ updateShootingStarField,
+ type DepthParticle,
+ type ShootingStarField,
+} from '../canvas/background-layer';
import { drawEdges } from '../canvas/draw-edges';
+import { drawHandoffCards } from '../canvas/draw-handoff-cards';
import { drawParticles } from '../canvas/draw-particles';
import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
@@ -17,11 +26,21 @@ import { drawProcesses } from '../canvas/draw-processes';
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
import { BloomRenderer } from '../canvas/bloom-renderer';
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
+import {
+ computeAdaptiveParticleBudget,
+ selectRenderableParticles,
+} from './selectRenderableParticles';
+import {
+ createTransientHandoffState,
+ selectRenderableTransientHandoffCards,
+ updateTransientHandoffState,
+} from './transientHandoffs';
import type { CameraTransform } from '../hooks/useGraphCamera';
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
export interface GraphDrawState {
+ teamName: string;
nodes: GraphNode[];
edges: GraphEdge[];
particles: GraphParticle[];
@@ -30,6 +49,10 @@ export interface GraphDrawState {
camera: CameraTransform;
selectedNodeId: string | null;
hoveredNodeId: string | null;
+ selectedEdgeId: string | null;
+ hoveredEdgeId: string | null;
+ focusNodeIds: ReadonlySet | null;
+ focusEdgeIds: ReadonlySet | null;
}
export interface GraphCanvasHandle {
@@ -65,16 +88,24 @@ export const GraphCanvas = forwardRef(funct
onContextMenu,
className,
},
- ref,
+ ref
) {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const bloomRef = useRef(new BloomRenderer(bloomIntensity));
const starsRef = useRef([]);
+ const shootingStarsRef = useRef(createShootingStarField());
const sizeRef = useRef({ w: 0, h: 0 });
+ const lastBackgroundTimeRef = useRef(null);
// Performance tracking
- const perfRef = useRef({ frames: 0, fps: 0, frameTimeMs: 0, lastFpsUpdate: 0, frameTimes: [] as number[] });
+ const perfRef = useRef({
+ frames: 0,
+ fps: 0,
+ frameTimeMs: 0,
+ lastFpsUpdate: 0,
+ frameTimes: [] as number[],
+ });
// Rate-limited error logging (prevent console flood at 60fps)
const lastDrawErrorRef = useRef(0);
@@ -103,6 +134,8 @@ export const GraphCanvas = forwardRef(funct
sizeRef.current = { w: width, h: height };
bloomRef.current.resize(width * dpr, height * dpr);
starsRef.current = createDepthParticles(width, height);
+ shootingStarsRef.current = createShootingStarField();
+ lastBackgroundTimeRef.current = null;
}
});
@@ -116,157 +149,266 @@ export const GraphCanvas = forwardRef(funct
const visibleNodesCache = useRef([]);
const visibleEdgesCache = useRef([]);
const visibleNodeIdsCache = useRef(new Set());
+ const visibleEdgeIdsCache = useRef(new Set());
const activeParticleEdgesCache = useRef(new Set());
+ const handoffStateRef = useRef(createTransientHandoffState());
+ const lastTeamNameRef = useRef(null);
// Imperative draw function — called from RAF, NOT from React render
- useImperativeHandle(ref, () => ({
- draw: (state: GraphDrawState) => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
+ useImperativeHandle(
+ ref,
+ () => ({
+ draw: (state: GraphDrawState) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
- const frameStart = performance.now();
+ const frameStart = performance.now();
- const dpr = window.devicePixelRatio || 1;
- const { w, h } = sizeRef.current;
- if (w === 0 || h === 0) return;
+ const dpr = window.devicePixelRatio || 1;
+ const { w, h } = sizeRef.current;
+ if (w === 0 || h === 0) return;
- try {
+ try {
+ if (lastTeamNameRef.current !== state.teamName) {
+ handoffStateRef.current = createTransientHandoffState();
+ lastTeamNameRef.current = state.teamName;
+ }
- const cam = state.camera;
- const zoom = cam.zoom;
+ const cam = state.camera;
+ const zoom = cam.zoom;
- // ─── Frustum culling: compute visible world-space bounds ──────────
- const viewLeft = -cam.x / zoom;
- const viewTop = -cam.y / zoom;
- const viewRight = (w - cam.x) / zoom;
- const viewBottom = (h - cam.y) / zoom;
- const pad = 200; // overdraw padding for glow/labels
+ // ─── Frustum culling: compute visible world-space bounds ──────────
+ const viewLeft = -cam.x / zoom;
+ const viewTop = -cam.y / zoom;
+ const viewRight = (w - cam.x) / zoom;
+ const viewBottom = (h - cam.y) / zoom;
+ const pad = 200; // overdraw padding for glow/labels
- // ─── Reuse cached maps (avoid per-frame allocation) ───────────────
- const nodeMap = nodeMapCache.current;
- nodeMap.clear();
- for (const n of state.nodes) nodeMap.set(n.id, n);
+ // ─── Reuse cached maps (avoid per-frame allocation) ───────────────
+ const nodeMap = nodeMapCache.current;
+ nodeMap.clear();
+ for (const n of state.nodes) nodeMap.set(n.id, n);
- const edgeMap = edgeMapCache.current;
- edgeMap.clear();
- for (const e of state.edges) edgeMap.set(e.id, e);
+ const edgeMap = edgeMapCache.current;
+ edgeMap.clear();
+ for (const e of state.edges) edgeMap.set(e.id, e);
- // ─── Filter visible nodes (frustum cull) — reuse array ────────────
- const visibleNodes = visibleNodesCache.current;
- visibleNodes.length = 0;
- for (const n of state.nodes) {
- const x = n.x ?? 0;
- const y = n.y ?? 0;
- if (x > viewLeft - pad && x < viewRight + pad &&
- y > viewTop - pad && y < viewBottom + pad) {
- visibleNodes.push(n);
+ // ─── Filter visible nodes (frustum cull) — reuse array ────────────
+ const visibleNodes = visibleNodesCache.current;
+ visibleNodes.length = 0;
+ for (const n of state.nodes) {
+ const x = n.x ?? 0;
+ const y = n.y ?? 0;
+ if (
+ x > viewLeft - pad &&
+ x < viewRight + pad &&
+ y > viewTop - pad &&
+ y < viewBottom + pad
+ ) {
+ visibleNodes.push(n);
+ }
+ }
+
+ // ─── Active particle edges — reuse Set ───────────────────────────
+ const activeParticleEdges = activeParticleEdgesCache.current;
+ activeParticleEdges.clear();
+ for (const p of state.particles) activeParticleEdges.add(p.edgeId);
+
+ // ─── Draw ─────────────────────────────────────────────────────────
+ ctx.save();
+ ctx.scale(dpr, dpr);
+ ctx.clearRect(0, 0, w, h);
+
+ // 1. Background (screen space)
+ const backgroundDt = Math.min(
+ Math.max(
+ lastBackgroundTimeRef.current == null
+ ? 0
+ : state.time - lastBackgroundTimeRef.current,
+ 0
+ ),
+ 0.1
+ );
+ lastBackgroundTimeRef.current = state.time;
+ updateDepthParticles(starsRef.current, w, h, backgroundDt);
+ updateShootingStarField(shootingStarsRef.current, w, h, backgroundDt);
+ drawBackground(ctx, w, h, starsRef.current, shootingStarsRef.current, cam, state.time, {
+ showHexGrid,
+ showStarField,
+ });
+
+ // 2. World-space content
+ ctx.save();
+ ctx.translate(cam.x, cam.y);
+ ctx.scale(zoom, zoom);
+
+ // 2a. Edges (only those connecting visible nodes) — reuse collections
+ const visibleNodeIds = visibleNodeIdsCache.current;
+ visibleNodeIds.clear();
+ for (const n of visibleNodes) visibleNodeIds.add(n.id);
+
+ const visibleEdges = visibleEdgesCache.current;
+ visibleEdges.length = 0;
+ const visibleEdgeIds = visibleEdgeIdsCache.current;
+ visibleEdgeIds.clear();
+ for (const e of state.edges) {
+ if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) {
+ visibleEdges.push(e);
+ visibleEdgeIds.add(e.id);
+ }
+ }
+ const prioritizedEdgeIds =
+ state.focusEdgeIds ?? (state.selectedEdgeId ? new Set([state.selectedEdgeId]) : null);
+ drawEdges(
+ ctx,
+ visibleEdges,
+ nodeMap,
+ state.time,
+ activeParticleEdges,
+ prioritizedEdgeIds,
+ state.hoveredEdgeId,
+ state.selectedEdgeId,
+ zoom
+ );
+
+ // 2b. Particles - adaptive degradation keeps one visible particle per active edge
+ const particleBudget = computeAdaptiveParticleBudget({
+ visibleNodeCount: visibleNodes.length,
+ visibleEdgeCount: visibleEdges.length,
+ frameTimeMs: perfRef.current.frameTimeMs,
+ hasFocusedEdges: (prioritizedEdgeIds?.size ?? 0) > 0,
+ zoom,
+ });
+ const renderableParticles = selectRenderableParticles({
+ particles: state.particles,
+ visibleEdgeIds,
+ focusEdgeIds: prioritizedEdgeIds,
+ budget: particleBudget,
+ });
+ updateTransientHandoffState(handoffStateRef.current, {
+ particles: state.particles,
+ edgeMap,
+ nodeMap,
+ time: state.time,
+ });
+ const renderableHandoffCards = selectRenderableTransientHandoffCards(
+ handoffStateRef.current,
+ {
+ focusNodeIds: state.focusNodeIds,
+ focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
+ }
+ ).filter(
+ (card) => card.destinationKind !== 'lead' && card.destinationKind !== 'member'
+ );
+ drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
+
+ // 2c. Visible nodes only (back to front: process → task → member/lead)
+ drawProcesses(
+ ctx,
+ visibleNodes,
+ state.time,
+ state.selectedNodeId,
+ state.hoveredNodeId,
+ state.focusNodeIds,
+ zoom
+ );
+ drawCrossTeamNodes(
+ ctx,
+ visibleNodes,
+ state.time,
+ state.selectedNodeId,
+ state.hoveredNodeId,
+ state.focusNodeIds
+ );
+ drawColumnHeaders(ctx, KanbanLayoutEngine.zones, zoom);
+ drawTasks(
+ ctx,
+ visibleNodes,
+ state.time,
+ state.selectedNodeId,
+ state.hoveredNodeId,
+ state.focusNodeIds,
+ zoom
+ );
+ drawAgents(
+ ctx,
+ visibleNodes,
+ state.time,
+ state.selectedNodeId,
+ state.hoveredNodeId,
+ state.focusNodeIds,
+ zoom
+ );
+
+ // 2d. Effects
+ drawEffects(ctx, state.effects);
+
+ ctx.restore(); // world space
+ ctx.restore(); // DPR scale
+
+ // 3. Bloom post-processing — always active for space aesthetic
+ if (bloomIntensity > 0) {
+ bloomRef.current.apply(canvas, ctx);
+ }
+
+ if (renderableHandoffCards.length > 0) {
+ ctx.save();
+ ctx.scale(dpr, dpr);
+ drawHandoffCards(ctx, {
+ cards: renderableHandoffCards,
+ nodeMap,
+ time: state.time,
+ camera: cam,
+ viewport: { width: w, height: h },
+ });
+ ctx.restore();
+ }
+
+ // 4. Performance overlay (enabled via ?perf in URL)
+ const perf = perfRef.current;
+ const frameMs = performance.now() - frameStart;
+ perf.frameTimes.push(frameMs);
+ perf.frames++;
+ if (perf.frameTimes.length > 120) perf.frameTimes.shift();
+
+ const now = performance.now();
+ if (now - perf.lastFpsUpdate > 1000) {
+ perf.fps = perf.frames;
+ perf.frames = 0;
+ perf.lastFpsUpdate = now;
+ const sorted = [...perf.frameTimes].sort((a, b) => a - b);
+ perf.frameTimeMs = sorted[Math.floor(sorted.length * 0.95)] ?? 0;
+ }
+
+ if (typeof window !== 'undefined' && window.location?.search?.includes('perf')) {
+ ctx.save();
+ ctx.scale(dpr, dpr);
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
+ ctx.fillRect(w - 130, 4, 126, 48);
+ ctx.font = '10px monospace';
+ ctx.fillStyle = perf.fps >= 50 ? '#66ffaa' : perf.fps >= 30 ? '#ffbb44' : '#ff5566';
+ ctx.textAlign = 'right';
+ ctx.fillText(`${perf.fps} fps`, w - 10, 18);
+ ctx.fillStyle = '#aaeeff';
+ ctx.fillText(`p95: ${perf.frameTimeMs.toFixed(1)}ms`, w - 10, 32);
+ ctx.fillText(`${state.nodes.length} nodes ${state.edges.length} edges`, w - 10, 46);
+ ctx.restore();
+ }
+ } catch (err) {
+ // Rate-limited error logging — max once per 5 seconds
+ const now = performance.now();
+ if (now - lastDrawErrorRef.current > 5000) {
+ lastDrawErrorRef.current = now;
+ console.error('[AgentGraph] Draw error:', err);
+ }
}
- }
-
- // ─── Active particle edges — reuse Set ───────────────────────────
- const activeParticleEdges = activeParticleEdgesCache.current;
- activeParticleEdges.clear();
- for (const p of state.particles) activeParticleEdges.add(p.edgeId);
-
- // ─── Draw ─────────────────────────────────────────────────────────
- ctx.save();
- ctx.scale(dpr, dpr);
- ctx.clearRect(0, 0, w, h);
-
- // 1. Background (screen space)
- updateDepthParticles(starsRef.current, w, h, state.time > 0 ? 0.016 : 0);
- drawBackground(ctx, w, h, starsRef.current, cam, state.time, {
- showHexGrid,
- showStarField,
- });
-
- // 2. World-space content
- ctx.save();
- ctx.translate(cam.x, cam.y);
- ctx.scale(zoom, zoom);
-
- // 2a. Edges (only those connecting visible nodes) — reuse collections
- const visibleNodeIds = visibleNodeIdsCache.current;
- visibleNodeIds.clear();
- for (const n of visibleNodes) visibleNodeIds.add(n.id);
-
- const visibleEdges = visibleEdgesCache.current;
- visibleEdges.length = 0;
- for (const e of state.edges) {
- if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) {
- visibleEdges.push(e);
- }
- }
- drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges);
-
- // 2b. Particles (cap at 100 for performance)
- const cappedParticles = state.particles.length > 100
- ? state.particles.slice(-100)
- : state.particles;
- drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time);
-
- // 2c. Visible nodes only (back to front: process → task → member/lead)
- drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
- drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
- drawColumnHeaders(ctx, KanbanLayoutEngine.zones);
- drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
- drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
-
- // 2d. Effects
- drawEffects(ctx, state.effects);
-
- ctx.restore(); // world space
- ctx.restore(); // DPR scale
-
- // 3. Bloom post-processing — always active for space aesthetic
- if (bloomIntensity > 0) {
- bloomRef.current.apply(canvas, ctx);
- }
-
- // 4. Performance overlay (enabled via ?perf in URL)
- const perf = perfRef.current;
- const frameMs = performance.now() - frameStart;
- perf.frameTimes.push(frameMs);
- perf.frames++;
- if (perf.frameTimes.length > 120) perf.frameTimes.shift();
-
- const now = performance.now();
- if (now - perf.lastFpsUpdate > 1000) {
- perf.fps = perf.frames;
- perf.frames = 0;
- perf.lastFpsUpdate = now;
- const sorted = [...perf.frameTimes].sort((a, b) => a - b);
- perf.frameTimeMs = sorted[Math.floor(sorted.length * 0.95)] ?? 0;
- }
-
- if (typeof window !== 'undefined' && window.location?.search?.includes('perf')) {
- ctx.save();
- ctx.scale(dpr, dpr);
- ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
- ctx.fillRect(w - 130, 4, 126, 48);
- ctx.font = '10px monospace';
- ctx.fillStyle = perf.fps >= 50 ? '#66ffaa' : perf.fps >= 30 ? '#ffbb44' : '#ff5566';
- ctx.textAlign = 'right';
- ctx.fillText(`${perf.fps} fps`, w - 10, 18);
- ctx.fillStyle = '#aaeeff';
- ctx.fillText(`p95: ${perf.frameTimeMs.toFixed(1)}ms`, w - 10, 32);
- ctx.fillText(`${state.nodes.length} nodes ${state.edges.length} edges`, w - 10, 46);
- ctx.restore();
- }
-
- } catch (err) {
- // Rate-limited error logging — max once per 5 seconds
- const now = performance.now();
- if (now - lastDrawErrorRef.current > 5000) {
- lastDrawErrorRef.current = now;
- console.error('[AgentGraph] Draw error:', err);
- }
- }
- },
- getCanvas: () => canvasRef.current,
- }), [showHexGrid, showStarField, bloomIntensity]);
+ },
+ getCanvas: () => canvasRef.current,
+ }),
+ [showHexGrid, showStarField, bloomIntensity]
+ );
// Wheel handler (passive: false required for preventDefault)
useEffect(() => {
@@ -281,7 +423,7 @@ export const GraphCanvas = forwardRef(funct
}, [onWheel]);
return (
-