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.

demo @@ -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 ( -
+
void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + onOpenTeamPage?: () => void; + onCreateTask?: () => void; teamName: string; teamColor?: string; isAlive?: boolean; } +const TOPBAR_BUTTON_SIZE = 25; +const TOPBAR_ICON_SIZE = 10; + export function GraphControls({ filters, onFiltersChange, @@ -50,9 +58,9 @@ export function GraphControls({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, - teamName, + onOpenTeamPage, + onCreateTask, teamColor, - isAlive, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -91,26 +99,44 @@ export function GraphControls({ return ( <> -
-
- {isAlive && ( -
- )} - - {teamName} - -
+
+ {onOpenTeamPage ? ( +
+ } + toolbar + title="Open team page" + /> +
+ ) : null} + {onCreateTask ? ( +
+ } + toolbar + title="Create task" + /> +
+ ) : null}
-
+
toggle('paused')} - icon={filters.paused ? : } + icon={filters.paused ? : } + toolbar + title={filters.paused ? 'Resume animation' : 'Pause animation'} />
setIsSettingsOpen((value) => !value)} - icon={} - label="View" + icon={} active={isSettingsOpen} + toolbar + title="Graph settings" />
@@ -172,35 +201,50 @@ export function GraphControls({
- {onRequestPinAsTab && } />} + {onRequestPinAsTab && ( + } + toolbar + title="Pin as tab" + /> + )} {onRequestFullscreen && ( } - label="Fullscreen" + icon={} + toolbar + title="Fullscreen" + /> + )} + {onRequestClose && ( + } + toolbar + title="Close graph" /> )} - {onRequestClose && } />}
-
+
- } /> - } label="Fit" /> - } /> + } compact /> + } label="Fit" compact /> + } compact />
@@ -214,16 +258,52 @@ function ToolbarButton({ icon, label, active = false, + compact = false, + mini = false, + toolbar = false, + title, }: { onClick?: () => void; icon: React.ReactNode; label?: string; active?: boolean; + compact?: boolean; + mini?: boolean; + toolbar?: boolean; + title?: string; }): React.JSX.Element { - return ( + const button = ( ); + + if (!title) { + return button; + } + + return ( + + {button} + + + {title} + + + + + ); } function ToolbarToggle({ diff --git a/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx b/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx new file mode 100644 index 00000000..1a363744 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx @@ -0,0 +1,66 @@ +import type { GraphEdge, GraphNode } from '../ports/types'; + +function getEdgeTypeLabel(edgeType: GraphEdge['type']): string { + switch (edgeType) { + case 'blocking': + return 'Blocking'; + case 'ownership': + return 'Ownership'; + case 'related': + return 'Related'; + case 'message': + return 'Message'; + case 'parent-child': + return 'Parent-child'; + } +} + +export interface GraphEdgeOverlayProps { + edge: GraphEdge; + sourceNode: GraphNode | undefined; + targetNode: GraphNode | undefined; + onClose: () => void; +} + +export function GraphEdgeOverlay({ + edge, + sourceNode, + targetNode, + onClose, +}: GraphEdgeOverlayProps): React.JSX.Element { + return ( +
+
+ {getEdgeTypeLabel(edge.type)} +
+
+ {sourceNode?.label ?? edge.source} -> {targetNode?.label ?? edge.target} +
+ {edge.label && ( +
+ {edge.label} +
+ )} +
+ +
+
+ ); +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 90bd07a2..90279acb 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -10,20 +10,29 @@ * ALL animation state (positions, particles, effects, time) lives in refs. */ -import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; -import type { GraphNode } from '../ports/types'; +import type { GraphEdge, GraphNode } from '../ports/types'; import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; +import { GraphEdgeOverlay } from './GraphEdgeOverlay'; +import { buildFocusState } from './buildFocusState'; import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphInteraction } from '../hooks/useGraphInteraction'; -import { findNodeAt } from '../canvas/hit-detection'; +import { + collectInteractiveEdgesInViewport, + findEdgeAt, + findNodeAt, + getEdgeMidpoint, +} from '../canvas/hit-detection'; import { ANIM_SPEED } from '../constants/canvas-constants'; +import { getActivityAnchorScreenPlacement as buildActivityAnchorScreenPlacement } from '../layout/activityLane'; +import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor'; export interface GraphViewProps { data: GraphDataPort; @@ -34,12 +43,33 @@ export interface GraphViewProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + onOpenTeamPage?: () => void; + onCreateTask?: () => void; /** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */ renderOverlay?: (props: { node: GraphNode; screenPos: { x: number; y: number }; onClose: () => void; }) => React.ReactNode; + renderEdgeOverlay?: (props: { + edge: GraphEdge; + sourceNode: GraphNode | undefined; + targetNode: GraphNode | undefined; + onClose: () => void; + onSelectNode: (nodeId: string) => void; + }) => React.ReactNode; + renderHud?: (props: { + getLaunchAnchorScreenPlacement: ( + leadNodeId: string, + ) => { x: number; y: number; scale: number; visible: boolean } | null; + getActivityAnchorScreenPlacement: ( + ownerNodeId: string, + ) => { x: number; y: number; scale: number; visible: boolean } | null; + getNodeScreenPosition: ( + nodeId: string, + ) => { x: number; y: number; visible: boolean } | null; + focusNodeIds: ReadonlySet | null; + }) => React.ReactNode; } export function GraphView({ @@ -51,10 +81,15 @@ export function GraphView({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + onOpenTeamPage, + onCreateTask, renderOverlay, + renderEdgeOverlay, + renderHud, }: GraphViewProps): React.JSX.Element { // ─── React state (user-facing only) ───────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [filters, setFilters] = useState({ showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, @@ -66,6 +101,9 @@ export function GraphView({ // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change const selectedNodeIdRef = useRef(null); selectedNodeIdRef.current = selectedNodeId; + const selectedEdgeIdRef = useRef(null); + selectedEdgeIdRef.current = selectedEdgeId; + const hoveredEdgeIdRef = useRef(null); const containerRef = useRef(null); const canvasHandle = useRef(null); @@ -75,6 +113,8 @@ export function GraphView({ const runningRef = useRef(false); const hasAutoFit = useRef(false); const allowAutoFitRef = useRef(true); + const nodeMapRef = useRef(new Map()); + const nodeMapNodesRef = useRef(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -87,16 +127,12 @@ export function GraphView({ cameraRef.current = camera; const interaction = useGraphInteraction( - useCallback((nodeId: string, x: number, y: number) => { - const state = simulation.stateRef.current; - const node = state.nodes.find((n) => n.id === nodeId); - if (node) { - node.fx = x; - node.fy = y; - node.x = x; - node.y = y; - } - }, [simulation.stateRef]), + useCallback( + (nodeId: string, x: number, y: number) => { + simulation.setNodePosition(nodeId, x, y); + }, + [simulation] + ) ); // ─── Sync data from adapter → simulation ──────────────────────────────── @@ -113,7 +149,104 @@ export function GraphView({ }, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]); // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── - const idleFrameSkip = useRef(0); + const focusState = useMemo( + () => buildFocusState(selectedNodeId, selectedEdgeId, data.nodes, data.edges), + [selectedEdgeId, selectedNodeId, data.edges, data.nodes] + ); + + const getNodeMap = useCallback((nodes: GraphNode[]): Map => { + if (nodeMapNodesRef.current === nodes) { + return nodeMapRef.current; + } + const nodeMap = nodeMapRef.current; + nodeMap.clear(); + for (const node of nodes) { + nodeMap.set(node.id, node); + } + nodeMapNodesRef.current = nodes; + return nodeMap; + }, []); + + const getInteractiveEdges = useCallback( + (canvas: HTMLCanvasElement, nodes: GraphNode[], edges: GraphEdge[]): GraphEdge[] => { + const nodeMap = getNodeMap(nodes); + const rect = canvas.getBoundingClientRect(); + const transform = camera.transformRef.current; + const bounds = { + left: -transform.x / transform.zoom, + top: -transform.y / transform.zoom, + right: (rect.width - transform.x) / transform.zoom, + bottom: (rect.height - transform.y) / transform.zoom, + }; + return collectInteractiveEdgesInViewport(edges, nodeMap, bounds); + }, + [camera.transformRef, getNodeMap] + ); + const getViewportSize = useCallback(() => { + const container = containerRef.current; + return { + width: container?.clientWidth ?? 0, + height: container?.clientHeight ?? 0, + }; + }, []); + const getLaunchAnchorScreenPlacement = useCallback((leadNodeId: string) => { + const anchor = simulationRef.current.getLaunchAnchorWorldPosition(leadNodeId); + if (!anchor) { + return null; + } + const viewport = getViewportSize(); + if (viewport.width <= 0 || viewport.height <= 0) { + return null; + } + const transform = cameraRef.current.transformRef.current; + return buildLaunchAnchorScreenPlacement({ + anchorX: anchor.x, + anchorY: anchor.y, + cameraX: transform.x, + cameraY: transform.y, + zoom: transform.zoom, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }); + }, [getViewportSize]); + const getActivityAnchorScreenPlacement = useCallback((ownerNodeId: string) => { + const anchor = simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId); + if (!anchor) { + return null; + } + const viewport = getViewportSize(); + if (viewport.width <= 0 || viewport.height <= 0) { + return null; + } + const transform = cameraRef.current.transformRef.current; + return buildActivityAnchorScreenPlacement({ + anchorX: anchor.x, + anchorY: anchor.y, + cameraX: transform.x, + cameraY: transform.y, + zoom: transform.zoom, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }); + }, [getViewportSize]); + const getNodeScreenPosition = useCallback((nodeId: string) => { + const viewport = getViewportSize(); + if (viewport.width <= 0 || viewport.height <= 0) { + return null; + } + const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); + if (!node || node.x == null || node.y == null) { + return null; + } + const transform = cameraRef.current.transformRef.current; + const x = node.x * transform.zoom + transform.x; + const y = node.y * transform.zoom + transform.y; + return { + x, + y, + visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80, + }; + }, [getViewportSize]); const animate = useCallback(() => { if (!runningRef.current) return; @@ -121,7 +254,7 @@ export function GraphView({ const now = performance.now() / 1000; const dt = Math.min( lastTimeRef.current > 0 ? now - lastTimeRef.current : ANIM_SPEED.defaultDeltaTime, - ANIM_SPEED.maxDeltaTime, + ANIM_SPEED.maxDeltaTime ); lastTimeRef.current = now; @@ -131,21 +264,12 @@ export function GraphView({ // 2. Update camera inertia cameraRef.current.updateInertia(); - // 3. Adaptive frame rate: skip every other frame when idle (no particles, no effects, sim settled) + // 3. Draw every frame: background stars and shooting stars need continuous motion. const state = simulationRef.current.stateRef.current; - const isIdle = state.particles.length === 0 && state.effects.length === 0; - if (isIdle) { - idleFrameSkip.current++; - if (idleFrameSkip.current % 2 !== 0) { - rafRef.current = requestAnimationFrame(animate); - return; // skip draw, halve fps when idle - } - } else { - idleFrameSkip.current = 0; - } // 4. Draw canvas imperatively (NO React re-render) canvasHandle.current?.draw({ + teamName: data.teamName, nodes: state.nodes, edges: state.edges, particles: state.particles, @@ -154,11 +278,15 @@ export function GraphView({ camera: cameraRef.current.transformRef.current, selectedNodeId: selectedNodeIdRef.current, hoveredNodeId: interaction.hoveredNodeId.current, + selectedEdgeId: selectedEdgeIdRef.current, + hoveredEdgeId: hoveredEdgeIdRef.current, + focusNodeIds: focusState.focusNodeIds, + focusEdgeIds: focusState.focusEdgeIds, }); rafRef.current = requestAnimationFrame(animate); // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs - }, []); + }, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]); // Start/stop RAF useEffect(() => { @@ -179,8 +307,13 @@ export function GraphView({ const fitGraphToViewport = useCallback(() => { const el = containerRef.current; if (!el || data.nodes.length === 0) return; - camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); - }, [camera, data.nodes.length, simulation.stateRef]); + camera.zoomToFit( + simulation.stateRef.current.nodes, + el.clientWidth, + el.clientHeight, + simulation.getExtraWorldBounds() + ); + }, [camera, data.nodes.length, simulation]); // ─── Auto-fit: until first user interaction, also react to container resizes ───── useEffect(() => { @@ -229,119 +362,206 @@ export function GraphView({ allowAutoFitRef.current = false; }, []); - const handleWheel = useCallback((e: WheelEvent) => { - markUserInteracted(); - camera.handleWheel(e); - }, [camera, markUserInteracted]); + const handleWheel = useCallback( + (e: WheelEvent) => { + markUserInteracted(); + camera.handleWheel(e); + }, + [camera, markUserInteracted] + ); // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ const isPanningRef = useRef(false); + const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null); - const handleMouseDown = useCallback((e: React.MouseEvent) => { - if (e.button !== 0) return; // only left click + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) return; // only left click - const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - - // Check if we hit a node - interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes); - - // Hit a node (draggable or clickable) → don't pan - const hitNode = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); - if (hitNode) { - markUserInteracted(); - isPanningRef.current = false; - } else { - // Hit empty space → pan - markUserInteracted(); - isPanningRef.current = true; - camera.handlePanStart(e.clientX, e.clientY); - } - }, [camera, interaction, markUserInteracted, simulation.stateRef]); - - const handleMouseMove = useCallback((e: React.MouseEvent) => { - // Dragging with left button held - if (e.buttons & 1) { - if (isPanningRef.current) { - camera.handlePanMove(e.clientX, e.clientY); - return; - } const canvas = canvasHandle.current?.getCanvas(); if (!canvas) return; const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes); - return; - } + const nodes = simulation.stateRef.current.nodes; + const edges = simulation.stateRef.current.edges; + const nodeMap = getNodeMap(nodes); + const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); - // No button held — hover detection + cursor update - const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - interaction.hoveredNodeId.current = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); - canvas.style.cursor = interaction.hoveredNodeId.current ? 'pointer' : 'grab'; - }, [camera, interaction, simulation.stateRef]); + // Check if we hit a node + interaction.handleMouseDown(world.x, world.y, nodes); - const handleMouseUp = useCallback(() => { - if (isPanningRef.current) { - camera.handlePanEnd(); - isPanningRef.current = false; - setSelectedNodeId(null); // hide popover after pan - return; - } - - const clickedId = interaction.handleMouseUp(); - if (clickedId) { - setSelectedNodeId(clickedId); - const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId); - if (node) events?.onNodeClick?.(node.domainRef); - } else { - setSelectedNodeId(null); // click on empty space — hide popover - if (!interaction.isDragging.current) { - events?.onBackgroundClick?.(); - } - } - }, [interaction, simulation.stateRef, events, camera]); - - const handleDoubleClick = useCallback((e: React.MouseEvent) => { - const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - const nodeId = interaction.handleDoubleClick(world.x, world.y, simulation.stateRef.current.nodes); - if (nodeId) { - const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId); - if (node) { - // Unpin if pinned (toggle) - if (node.fx != null) { - node.fx = null; - node.fy = null; + // Hit a node (draggable or clickable) → don't pan + const hitNode = findNodeAt(world.x, world.y, nodes); + if (hitNode) { + markUserInteracted(); + isPanningRef.current = false; + edgeMouseDownRef.current = null; + hoveredEdgeIdRef.current = null; + } else { + const hitEdge = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); + if (hitEdge) { + markUserInteracted(); + isPanningRef.current = false; + edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y }; + hoveredEdgeIdRef.current = hitEdge; + } else { + // Hit empty space → pan + markUserInteracted(); + isPanningRef.current = true; + edgeMouseDownRef.current = null; + hoveredEdgeIdRef.current = null; + camera.handlePanStart(e.clientX, e.clientY); } - events?.onNodeDoubleClick?.(node.domainRef); } - } - }, [camera, interaction, simulation.stateRef, events]); + }, + [camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + // Dragging with left button held + if (e.buttons & 1) { + if (isPanningRef.current) { + camera.handlePanMove(e.clientX, e.clientY); + return; + } + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes); + return; + } + + // No button held — hover detection + cursor update + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodes = simulation.stateRef.current.nodes; + const edges = simulation.stateRef.current.edges; + + const hoveredNodeId = findNodeAt(world.x, world.y, nodes); + interaction.hoveredNodeId.current = hoveredNodeId; + + if (hoveredNodeId) { + hoveredEdgeIdRef.current = null; + canvas.style.cursor = 'pointer'; + return; + } + + const nodeMap = getNodeMap(nodes); + const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); + hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); + canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; + }, + [camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef] + ); + + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + if (isPanningRef.current) { + camera.handlePanEnd(); + isPanningRef.current = false; + setSelectedNodeId(null); // hide popover after pan + setSelectedEdgeId(null); + edgeMouseDownRef.current = null; + return; + } + + const clickedId = interaction.handleMouseUp(); + if (clickedId) { + setSelectedNodeId(clickedId); + setSelectedEdgeId(null); + const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId); + if (node) events?.onNodeClick?.(node.domainRef); + } else { + const canvas = canvasHandle.current?.getCanvas(); + let clickedEdgeId: string | null = null; + if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const dx = world.x - edgeMouseDownRef.current.x; + const dy = world.y - edgeMouseDownRef.current.y; + if (dx * dx + dy * dy <= 25) { + clickedEdgeId = edgeMouseDownRef.current.id; + } + } + edgeMouseDownRef.current = null; + + if (clickedEdgeId) { + setSelectedNodeId(null); + setSelectedEdgeId(clickedEdgeId); + const edge = simulation.stateRef.current.edges.find( + (candidate) => candidate.id === clickedEdgeId + ); + if (edge) { + events?.onEdgeClick?.(edge); + } + } else { + setSelectedNodeId(null); // click on empty space — hide popover + setSelectedEdgeId(null); + } + if (!interaction.isDragging.current && !clickedEdgeId) { + events?.onBackgroundClick?.(); + } + } + }, + [interaction, simulation.stateRef, events, camera, data.teamName] + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodeId = interaction.handleDoubleClick( + world.x, + world.y, + simulation.stateRef.current.nodes + ); + if (nodeId) { + setSelectedEdgeId(null); + const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId); + if (node) { + // Unpin if pinned (toggle) + if (node.fx != null) { + node.fx = null; + node.fy = null; + } + events?.onNodeDoubleClick?.(node.domainRef); + } + } + }, + [camera, interaction, simulation.stateRef, events] + ); // ─── Keyboard ─────────────────────────────────────────────────────────── useEffect(() => { const handler = (e: KeyboardEvent) => { // Don't capture from inputs const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + return; if (e.key === 'Escape') { - if (selectedNodeId) { + if (selectedNodeId || selectedEdgeId) { setSelectedNodeId(null); + setSelectedEdgeId(null); } else { onRequestClose?.(); } } if (e.key === 'f' || e.key === 'F') { const el = containerRef.current; - if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + if (el) + camera.zoomToFit( + simulation.stateRef.current.nodes, + el.clientWidth, + el.clientHeight, + simulation.getExtraWorldBounds() + ); } if (e.key === ' ') { e.preventDefault(); @@ -350,16 +570,26 @@ export function GraphView({ }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [selectedNodeId, onRequestClose, camera, simulation.stateRef]); + }, [selectedEdgeId, selectedNodeId, onRequestClose, camera, simulation.stateRef]); // ─── Selected node for overlay ────────────────────────────────────────── - const selectedNode: GraphNode | null = - selectedNodeId - ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null - : null; + const selectedNode: GraphNode | null = selectedNodeId + ? (simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null) + : null; + const selectedEdge: GraphEdge | null = selectedEdgeId + ? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null) + : null; + const hasBlockingEdges = useMemo( + () => data.edges.some((edge) => edge.type === 'blocking'), + [data.edges] + ); + const selectedEdgeNodeMap = useMemo( + () => getNodeMap(simulation.stateRef.current.nodes), + [data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef] + ); useLayoutEffect(() => { - if (!selectedNode || !containerRef.current || !overlayRef.current) { + if ((!selectedNode && !selectedEdgeId) || !containerRef.current || !overlayRef.current) { return; } @@ -369,7 +599,25 @@ export function GraphView({ const reference = { getBoundingClientRect(): DOMRect { const containerRect = container.getBoundingClientRect(); - const screenPos = camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + const screenPos = (() => { + if (selectedNode) { + return camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + } + if (selectedEdgeId) { + const currentNodes = simulation.stateRef.current.nodes; + const currentEdge = simulation.stateRef.current.edges.find( + (edge) => edge.id === selectedEdgeId + ); + if (currentEdge) { + const nodeMap = getNodeMap(currentNodes); + const midpoint = getEdgeMidpoint(currentEdge, nodeMap); + if (midpoint) { + return camera.worldToScreen(midpoint.x, midpoint.y); + } + } + } + return camera.worldToScreen(0, 0); + })(); return DOMRect.fromRect({ x: containerRect.left + screenPos.x, y: containerRect.top + screenPos.y, @@ -408,11 +656,11 @@ export function GraphView({ void updatePosition(); return cleanup; - }, [camera, selectedNode]); + }, [camera, getNodeMap, selectedEdgeId, selectedNode, simulation.stateRef]); // ─── Render ───────────────────────────────────────────────────────────── return ( -
+
{ markUserInteracted(); const el = containerRef.current; - if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + if (el) + camera.zoomToFit( + simulation.stateRef.current.nodes, + el.clientWidth, + el.clientHeight, + simulation.getExtraWorldBounds() + ); }} onRequestClose={onRequestClose} onRequestPinAsTab={onRequestPinAsTab} onRequestFullscreen={onRequestFullscreen} + onOpenTeamPage={onOpenTeamPage} + onCreateTask={onCreateTask} teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} /> - {selectedNode && ( -
- {renderOverlay ? ( - renderOverlay({ - node: selectedNode, - screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), - onClose: () => setSelectedNodeId(null), - }) - ) : ( - setSelectedNodeId(null)} - /> - )} + {renderHud ? ( +
+ {renderHud({ + getLaunchAnchorScreenPlacement, + getActivityAnchorScreenPlacement, + getNodeScreenPosition, + focusNodeIds: focusState.focusNodeIds, + })} +
+ ) : null} + + {(selectedNode || selectedEdge) && ( +
+ {selectedNode ? ( + renderOverlay ? ( + renderOverlay({ + node: selectedNode, + screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), + onClose: () => setSelectedNodeId(null), + }) + ) : ( + setSelectedNodeId(null)} + /> + ) + ) : selectedEdge ? ( + renderEdgeOverlay ? ( + renderEdgeOverlay({ + edge: selectedEdge, + sourceNode: selectedEdgeNodeMap.get(selectedEdge.source), + targetNode: selectedEdgeNodeMap.get(selectedEdge.target), + onClose: () => setSelectedEdgeId(null), + onSelectNode: (nodeId: string) => { + setSelectedEdgeId(null); + setSelectedNodeId(nodeId); + }, + }) + ) : ( + setSelectedEdgeId(null)} + /> + ) + ) : null}
)}
diff --git a/packages/agent-graph/src/ui/buildFocusState.ts b/packages/agent-graph/src/ui/buildFocusState.ts new file mode 100644 index 00000000..0481b8a6 --- /dev/null +++ b/packages/agent-graph/src/ui/buildFocusState.ts @@ -0,0 +1,239 @@ +import type { GraphEdge, GraphNode } from '../ports/types'; + +export interface GraphFocusState { + focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; +} + +function addNode(nodeIds: Set, nodeId: string | null | undefined): void { + if (nodeId) { + nodeIds.add(nodeId); + } +} + +function addNodeAndIncidentEdges( + nodeIds: Set, + edgeIds: Set, + nodeId: string | null | undefined, + adjacency: Map +): void { + if (!nodeId) return; + nodeIds.add(nodeId); + for (const edge of adjacency.get(nodeId) ?? []) { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } +} + +export function buildFocusState( + selectedNodeId: string | null, + selectedEdgeId: string | null, + nodes: GraphNode[], + edges: GraphEdge[] +): GraphFocusState { + if (!selectedNodeId && !selectedEdgeId) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); + const adjacency = new Map(); + + for (const edge of edges) { + const sourceEdges = adjacency.get(edge.source) ?? []; + sourceEdges.push(edge); + adjacency.set(edge.source, sourceEdges); + + const targetEdges = adjacency.get(edge.target) ?? []; + targetEdges.push(edge); + adjacency.set(edge.target, targetEdges); + } + + if (selectedNodeId == null && selectedEdgeId != null) { + const selectedEdge = edges.find((edge) => edge.id === selectedEdgeId) ?? null; + if (!selectedEdge || selectedEdge.type !== 'blocking') { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const sourceNode = nodeById.get(selectedEdge.source); + const targetNode = nodeById.get(selectedEdge.target); + if (!sourceNode || !targetNode) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const nodeIds = new Set([selectedEdge.source, selectedEdge.target]); + const edgeIds = new Set([selectedEdge.id]); + const queue = [selectedEdge.source, selectedEdge.target]; + + while (queue.length > 0) { + const currentNodeId = queue.shift()!; + const currentNode = nodeById.get(currentNodeId); + if (!currentNode || currentNode.kind !== 'task') { + continue; + } + + for (const edge of adjacency.get(currentNodeId) ?? []) { + if (edge.type !== 'blocking') { + continue; + } + if (!edgeIds.has(edge.id)) { + edgeIds.add(edge.id); + } + const neighborId = edge.source === currentNodeId ? edge.target : edge.source; + if (!nodeIds.has(neighborId)) { + nodeIds.add(neighborId); + queue.push(neighborId); + } + } + } + + for (const nodeId of Array.from(nodeIds)) { + const node = nodeById.get(nodeId); + if (!node || node.kind !== 'task') { + continue; + } + if (node.ownerId) { + nodeIds.add(node.ownerId); + } + if (node.reviewerName) { + const reviewerNode = nodes.find( + (candidate) => + candidate.kind === 'member' && + candidate.domainRef.kind === 'member' && + candidate.domainRef.memberName === node.reviewerName + ); + if (reviewerNode) { + nodeIds.add(reviewerNode.id); + } + } + for (const edge of adjacency.get(node.id) ?? []) { + if (edge.type === 'ownership') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + for (const nodeId of Array.from(nodeIds)) { + const node = nodeById.get(nodeId); + if (node?.kind !== 'member') continue; + for (const edge of adjacency.get(nodeId) ?? []) { + if (edge.type === 'parent-child') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + return { + focusNodeIds: nodeIds, + focusEdgeIds: edgeIds, + }; + } + + const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; + if ( + !selectedNode || + selectedNode.kind === 'process' || + selectedNode.kind === 'crossteam' || + selectedNode.isOverflowStack + ) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const nodeIds = new Set([selectedNode.id]); + const edgeIds = new Set(); + + const selectedMemberName = + selectedNode.domainRef.kind === 'member' || selectedNode.domainRef.kind === 'lead' + ? selectedNode.domainRef.memberName + : null; + + if (selectedNode.kind === 'lead') { + addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNode.id, adjacency); + } else if (selectedNode.kind === 'member') { + addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNode.id, adjacency); + + for (const node of nodes) { + if (node.kind !== 'task') continue; + if (node.isOverflowStack) { + if (node.ownerId === selectedNode.id) { + nodeIds.add(node.id); + for (const edge of adjacency.get(node.id) ?? []) { + edgeIds.add(edge.id); + } + } + continue; + } + + const isOwnedTask = node.ownerId === selectedNode.id; + const isReviewTask = + selectedMemberName != null && + node.reviewerName === selectedMemberName && + node.domainRef.kind === 'task' && + node.domainRef.taskId !== selectedNode.currentTaskId; + if (!isOwnedTask && !isReviewTask) continue; + + nodeIds.add(node.id); + for (const edge of adjacency.get(node.id) ?? []) { + if (edge.type === 'ownership' || edge.type === 'blocking') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + } else if (selectedNode.kind === 'task') { + if (selectedNode.ownerId) { + addNode(nodeIds, selectedNode.ownerId); + } + + if (selectedNode.reviewerName) { + const reviewerNode = nodes.find( + (node) => + node.kind === 'member' && + node.domainRef.kind === 'member' && + node.domainRef.memberName === selectedNode.reviewerName + ); + if (reviewerNode) { + nodeIds.add(reviewerNode.id); + } + } + + for (const edge of adjacency.get(selectedNode.id) ?? []) { + if (edge.type === 'ownership' || edge.type === 'blocking') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + const focusedMemberIds = Array.from(nodeIds).filter((nodeId) => { + const node = nodeById.get(nodeId); + return node?.kind === 'member'; + }); + + for (const memberId of focusedMemberIds) { + for (const edge of adjacency.get(memberId) ?? []) { + if (edge.type === 'parent-child') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + for (const edge of edges) { + if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { + edgeIds.add(edge.id); + } + } + + return { + focusNodeIds: nodeIds, + focusEdgeIds: edgeIds, + }; +} diff --git a/packages/agent-graph/src/ui/selectRenderableParticles.ts b/packages/agent-graph/src/ui/selectRenderableParticles.ts new file mode 100644 index 00000000..d642464a --- /dev/null +++ b/packages/agent-graph/src/ui/selectRenderableParticles.ts @@ -0,0 +1,109 @@ +import type { GraphParticle } from '../ports/types'; + +const MIN_PARTICLE_BUDGET = 120; +const MAX_PARTICLE_BUDGET = 360; +const FOCUSED_MIN_BUDGET = 180; + +export function computeAdaptiveParticleBudget(params: { + visibleNodeCount: number; + visibleEdgeCount: number; + frameTimeMs: number; + hasFocusedEdges: boolean; + zoom?: number; +}): number { + const baseBudget = Math.max( + MIN_PARTICLE_BUDGET, + Math.min(MAX_PARTICLE_BUDGET, 48 + params.visibleNodeCount * 3 + params.visibleEdgeCount * 2) + ); + + let adjustedBudget = baseBudget; + if ((params.zoom ?? 1) < 0.18) { + adjustedBudget = Math.floor(adjustedBudget * 0.45); + } else if ((params.zoom ?? 1) < 0.24) { + adjustedBudget = Math.floor(adjustedBudget * 0.7); + } + + if (params.frameTimeMs >= 24) { + adjustedBudget = Math.floor(adjustedBudget * 0.55); + } else if (params.frameTimeMs >= 18) { + adjustedBudget = Math.floor(adjustedBudget * 0.72); + } else if (params.frameTimeMs >= 14) { + adjustedBudget = Math.floor(adjustedBudget * 0.88); + } + + if (params.hasFocusedEdges) { + adjustedBudget = Math.max(adjustedBudget, FOCUSED_MIN_BUDGET); + } + + return Math.max(48, adjustedBudget); +} + +function sampleEvenly(items: T[], limit: number): T[] { + if (items.length <= limit) { + return items; + } + if (limit <= 0) { + return []; + } + + const sampled: T[] = []; + for (let index = 0; index < limit; index += 1) { + const itemIndex = Math.min(items.length - 1, Math.floor((index * items.length) / limit)); + sampled.push(items[itemIndex]); + } + return sampled; +} + +export function selectRenderableParticles(params: { + particles: GraphParticle[]; + visibleEdgeIds: ReadonlySet; + focusEdgeIds?: ReadonlySet | null; + budget: number; +}): GraphParticle[] { + const visibleParticles = params.particles.filter( + (particle) => + params.visibleEdgeIds.has(particle.edgeId) || + (params.focusEdgeIds?.has(particle.edgeId) ?? false) + ); + if (visibleParticles.length <= params.budget) { + return visibleParticles; + } + + const indexed = visibleParticles.map((particle, index) => ({ particle, index })); + const focused = params.focusEdgeIds + ? indexed.filter(({ particle }) => params.focusEdgeIds?.has(particle.edgeId)) + : []; + const nonFocused = + focused.length === indexed.length + ? [] + : indexed.filter(({ particle }) => !(params.focusEdgeIds?.has(particle.edgeId) ?? false)); + + const selectedById = new Set(); + const seenEdges = new Set(); + const seed: Array<{ particle: GraphParticle; index: number }> = []; + + for (const pool of [focused, nonFocused]) { + for (let cursor = pool.length - 1; cursor >= 0; cursor -= 1) { + const candidate = pool[cursor]; + if (seenEdges.has(candidate.particle.edgeId)) { + continue; + } + seenEdges.add(candidate.particle.edgeId); + selectedById.add(candidate.particle.id); + seed.push(candidate); + } + } + + const seedSorted = seed.sort((left, right) => left.index - right.index); + if (seedSorted.length >= params.budget) { + return sampleEvenly(seedSorted, params.budget).map(({ particle }) => particle); + } + + const remaining = indexed.filter(({ particle }) => !selectedById.has(particle.id)); + const remainingBudget = params.budget - seedSorted.length; + const extra = sampleEvenly(remaining, remainingBudget); + + return [...seedSorted, ...extra] + .sort((left, right) => left.index - right.index) + .map(({ particle }) => particle); +} diff --git a/packages/agent-graph/src/ui/transientHandoffs.ts b/packages/agent-graph/src/ui/transientHandoffs.ts new file mode 100644 index 00000000..78465e0f --- /dev/null +++ b/packages/agent-graph/src/ui/transientHandoffs.ts @@ -0,0 +1,163 @@ +import { HANDOFF_CARD } from '../constants/canvas-constants'; +import type { GraphEdge, GraphNode, GraphParticle, GraphParticleKind } from '../ports/types'; + +type HandoffParticleKind = Exclude; + +export interface TransientHandoffCard { + key: string; + edgeId: string; + sourceNodeId: string; + destinationNodeId: string; + sourceLabel: string; + destinationLabel: string; + destinationKind: GraphNode['kind']; + kind: HandoffParticleKind; + color: string; + preview?: string; + count: number; + activatedAt: number; + updatedAt: number; + expiresAt: number; +} + +export interface TransientHandoffState { + cardsByKey: Map; + triggeredParticleIds: Set; +} + +export function createTransientHandoffState(): TransientHandoffState { + return { + cardsByKey: new Map(), + triggeredParticleIds: new Set(), + }; +} + +export function updateTransientHandoffState( + state: TransientHandoffState, + params: { + particles: GraphParticle[]; + edgeMap: Map; + nodeMap: Map; + time: number; + } +): void { + const { particles, edgeMap, nodeMap, time } = params; + + const activeParticleIds = new Set(); + for (const particle of particles) activeParticleIds.add(particle.id); + for (const particleId of Array.from(state.triggeredParticleIds)) { + if (!activeParticleIds.has(particleId)) { + state.triggeredParticleIds.delete(particleId); + } + } + + for (const [cardKey, card] of Array.from(state.cardsByKey.entries())) { + if (card.expiresAt <= time) { + state.cardsByKey.delete(cardKey); + } + } + + for (const particle of particles) { + if (!isTransientHandoffKind(particle.kind)) continue; + if (particle.progress < HANDOFF_CARD.triggerProgress) continue; + if (state.triggeredParticleIds.has(particle.id)) continue; + + const edge = edgeMap.get(particle.edgeId); + if (!edge) continue; + + const sourceNodeId = particle.reverse ? edge.target : edge.source; + const destinationNodeId = particle.reverse ? edge.source : edge.target; + const sourceNode = nodeMap.get(sourceNodeId); + const destinationNode = nodeMap.get(destinationNodeId); + if (!sourceNode || !destinationNode) continue; + + const previewText = normalizePreviewText(particle.preview ?? particle.label); + if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) { + state.triggeredParticleIds.add(particle.id); + continue; + } + + const cardKey = `${edge.id}:${particle.reverse ? 'rev' : 'fwd'}:${particle.kind}`; + const existing = state.cardsByKey.get(cardKey); + const nextCount = (existing?.count ?? 0) + 1; + + state.cardsByKey.set(cardKey, { + key: cardKey, + edgeId: edge.id, + sourceNodeId, + destinationNodeId, + sourceLabel: sourceNode.label, + destinationLabel: destinationNode.label, + destinationKind: destinationNode.kind, + kind: particle.kind, + color: particle.color, + preview: previewText ?? existing?.preview, + count: nextCount, + activatedAt: existing?.activatedAt ?? time, + updatedAt: time, + expiresAt: time + HANDOFF_CARD.lingerSeconds, + }); + state.triggeredParticleIds.add(particle.id); + } +} + +export function selectRenderableTransientHandoffCards( + state: TransientHandoffState, + options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + } +): TransientHandoffCard[] { + const focusNodeIds = options?.focusNodeIds ?? null; + const focusEdgeIds = options?.focusEdgeIds ?? null; + const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0; + + const byDestination = new Map(); + for (const card of state.cardsByKey.values()) { + if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue; + const destinationCards = byDestination.get(card.destinationNodeId); + if (destinationCards) { + destinationCards.push(card); + } else { + byDestination.set(card.destinationNodeId, [card]); + } + } + + const selected: TransientHandoffCard[] = []; + for (const cards of byDestination.values()) { + cards.sort((a, b) => b.updatedAt - a.updatedAt); + selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination)); + } + + selected.sort((a, b) => b.updatedAt - a.updatedAt); + return selected; +} + +function isTransientHandoffKind(kind: GraphParticleKind): kind is HandoffParticleKind { + return kind !== 'spawn'; +} + +function isCardInFocus( + card: TransientHandoffCard, + focusNodeIds: ReadonlySet | null, + focusEdgeIds: ReadonlySet | null +): boolean { + return ( + !!focusEdgeIds?.has(card.edgeId) || + !!focusNodeIds?.has(card.sourceNodeId) || + !!focusNodeIds?.has(card.destinationNodeId) + ); +} + +function normalizePreviewText(text: string | undefined): string | undefined { + if (!text) return undefined; + const normalized = text + .replace(/^(?:✉|💬)\s*/u, '') + .replace(/\s+/g, ' ') + .trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function isLowSignalInboxPreview(preview: string | undefined): boolean { + return preview === 'idle'; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 972d9968..66dc8138 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: mermaid: specifier: ^11.12.3 version: 11.12.3 + motion: + specifier: 12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) node-diff3: specifier: ^3.2.0 version: 3.2.0 @@ -248,6 +251,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + react-modal-sheet: + specifier: 5.6.0 + version: 5.6.0(motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-resizable: specifier: ^3.1.3 version: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -525,13 +531,13 @@ importers: version: 3.0.0 lucide-react: specifier: '>=0.300.0' - version: 0.577.0(react@18.3.1) + version: 0.577.0(react@19.2.4) react: - specifier: ^18.0.0 - version: 18.3.1 + specifier: ^18.0.0 || ^19.0.0 + version: 19.2.4 react-dom: - specifier: ^18.0.0 - version: 18.3.1(react@18.3.1) + specifier: ^18.0.0 || ^19.0.0 + version: 19.2.4(react@19.2.4) devDependencies: '@types/d3-force': specifier: ^3.0.10 @@ -4796,6 +4802,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -6755,6 +6762,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -8128,6 +8149,26 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -9067,11 +9108,6 @@ packages: rc9@3.0.0: resolution: {integrity: sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA==} - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 - react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -9098,6 +9134,13 @@ packages: '@types/react': '>=18' react: '>=18' + react-modal-sheet@5.6.0: + resolution: {integrity: sha512-+WE2nVPdB/Jx0QbndIBqGvy6k0IXriW2lFaPeZSW1xOVri6rWhAwrSnArtsR1rxOxW8HBdAYeIPUcbjMvNeeyw==} + engines: {node: '>=20'} + peerDependencies: + motion: '>=11' + react: '>=16' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -9138,9 +9181,14 @@ packages: '@types/react': optional: true - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} @@ -9408,9 +9456,6 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -15343,14 +15388,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0) - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@vitest/spy': 3.2.4 @@ -18173,6 +18210,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@0.5.2: {} fresh@2.0.0: {} @@ -19298,10 +19344,6 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@0.577.0(react@18.3.1): - dependencies: - react: 18.3.1 - lucide-react@0.577.0(react@19.2.4): dependencies: react: 19.2.4 @@ -19882,6 +19924,20 @@ snapshots: module-details-from-path@1.0.4: {} + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + mrmime@2.0.1: {} ms@2.1.3: {} @@ -21088,12 +21144,6 @@ snapshots: defu: 6.1.4 destr: 2.0.5 - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -21137,6 +21187,14 @@ snapshots: transitivePeerDependencies: - supports-color + react-modal-sheet@5.6.0(motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + transitivePeerDependencies: + - react-dom + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -21173,9 +21231,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react@18.3.1: + react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - loose-envify: 1.4.0 + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) react@19.2.4: {} @@ -21552,10 +21612,6 @@ snapshots: sax@1.6.0: {} - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - scheduler@0.27.0: {} scslre@0.3.0: @@ -22817,7 +22873,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/runtime.lock.json b/runtime.lock.json index 07d4df6a..e0e70603 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,26 +1,27 @@ { - "version": "0.0.1", - "sourceRef": "v0.0.1", + "version": "0.0.2", + "sourceRef": "v0.0.2", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", + "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.1.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.2.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.1.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.2.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.1.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.2.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.1.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.2.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index bf2cf46b..b0baa0d1 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -19,10 +19,21 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim() ? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim()) : defaultRuntimeCacheRoot; const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path'); +const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); + +function shouldUseWindowsShell(cmd) { + if (process.platform !== 'win32') { + return false; + } + + const commandName = path.basename(cmd).toLowerCase(); + return WINDOWS_SHELL_COMMANDS.has(commandName); +} function runOrExit(cmd, args, options = {}) { const result = spawnSync(cmd, args, { stdio: 'inherit', + shell: shouldUseWindowsShell(cmd), ...options, }); @@ -39,6 +50,7 @@ function runOrExit(cmd, args, options = {}) { function runAndCapture(cmd, args, options = {}) { const result = spawnSync(cmd, args, { encoding: 'utf8', + shell: shouldUseWindowsShell(cmd), ...options, }); @@ -96,7 +108,10 @@ function getPlatformAssetKey() { } function getReleaseAssetUrl(runtimeLock, asset) { - return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${runtimeLock.sourceRef}/${encodeURIComponent(asset.file)}`; + const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0 + ? runtimeLock.releaseTag.trim() + : runtimeLock.sourceRef; + return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`; } function ensureDir(dirPath) { @@ -503,8 +518,9 @@ async function main() { CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: resolvedRuntime.binaryPath, }; delete uiEnv.CLAUDE_CLI_PATH; + const uiPackageManager = readPackageManagerCommand(uiRepoRoot); - runOrExit('pnpm', ['exec', 'electron-vite', 'dev'], { + runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev'], { cwd: uiRepoRoot, env: uiEnv, }); diff --git a/scripts/diagnose-task-log-stream.ts b/scripts/diagnose-task-log-stream.ts new file mode 100644 index 00000000..fc01d956 --- /dev/null +++ b/scripts/diagnose-task-log-stream.ts @@ -0,0 +1,92 @@ +import { BoardTaskLogDiagnosticsService } from '../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; + +function usage(): string { + return 'Usage: pnpm exec tsx scripts/diagnose-task-log-stream.ts [--json]'; +} + +function formatExamples( + title: string, + examples: Array<{ + timestamp: string; + toolName: string; + toolUseId?: string; + filePath: string; + messageUuid: string; + isSidechain: boolean; + agentId?: string; + }>, +): string[] { + if (examples.length === 0) { + return []; + } + + return [ + title, + ...examples.map((example) => { + const parts = [ + `- ${example.timestamp}`, + example.toolName, + `message=${example.messageUuid}`, + `file=${example.filePath}`, + `sidechain=${String(example.isSidechain)}`, + ]; + if (example.toolUseId) { + parts.push(`toolUseId=${example.toolUseId}`); + } + if (example.agentId) { + parts.push(`agentId=${example.agentId}`); + } + return parts.join(' '); + }), + ]; +} + +async function main(): Promise { + const teamName = process.argv[2]; + const taskRef = process.argv[3]; + const jsonMode = process.argv.includes('--json'); + + if (!teamName || !taskRef) { + console.error(usage()); + process.exitCode = 1; + return; + } + + const diagnosticsService = new BoardTaskLogDiagnosticsService(); + const report = await diagnosticsService.diagnose(teamName, taskRef); + + if (jsonMode) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + const lines = [ + `Task log diagnostics for ${report.teamName} #${report.task.displayId}`, + `Task: ${report.task.subject}`, + `Status: ${report.task.status}${report.task.owner ? ` owner=${report.task.owner}` : ''}`, + `Transcript files: ${report.transcript.fileCount}`, + `Explicit records: total=${report.explicitRecords.total} execution=${report.explicitRecords.execution} lifecycle=${report.explicitRecords.lifecycle} boardAction=${report.explicitRecords.boardAction}`, + `Explicit participants: ${report.explicitRecords.participants.join(', ') || 'none'}`, + `Explicit tool names: ${report.explicitRecords.toolNames.join(', ') || 'none'}`, + `Interval tool results: total=${report.intervalToolResults.total} boardMcp=${report.intervalToolResults.boardMcp} worker=${report.intervalToolResults.worker.total} explicitWorker=${report.intervalToolResults.worker.explicitLinked} missingWorker=${report.intervalToolResults.worker.missingExplicit}`, + `Stream: participants=${report.stream.participants.join(', ') || 'none'} defaultFilter=${report.stream.defaultFilter} segments=${report.stream.segmentCount}`, + `Visible stream tools: ${report.stream.visibleToolNames.join(', ') || 'none'}`, + 'Diagnosis:', + ...report.diagnosis.map((line) => `- ${line}`), + ...formatExamples( + 'Missing worker tool results without explicit links:', + report.intervalToolResults.worker.examples, + ), + ...formatExamples( + 'Empty payload examples from current stream:', + report.stream.emptyPayloadExamples, + ), + ]; + + console.log(lines.join('\n')); +} + +main().catch((error) => { + console.error(String(error)); + process.exitCode = 1; +}); diff --git a/src/main/index.ts b/src/main/index.ts index ad118cec..832baa3e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -63,7 +63,6 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { setReviewMainWindow } from './ipc/review'; import { ApiKeyService, - RUNTIME_MANAGED_API_KEY_ENV_VARS, ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, @@ -74,6 +73,7 @@ import { PluginCatalogService, PluginInstallationStateService, PluginInstallService, + RUNTIME_MANAGED_API_KEY_ENV_VARS, SkillsCatalogService, SkillsMutationService, SkillsWatcherService, @@ -102,6 +102,12 @@ import { } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { + BoardTaskActivityRecordSource, + BoardTaskActivityDetailService, + BoardTaskActivityService, + BoardTaskExactLogDetailService, + BoardTaskExactLogsService, + BoardTaskLogStreamService, BranchStatusService, CliInstallerService, configManager, @@ -779,6 +785,16 @@ async function initializeServices(): Promise { cliInstallerService = new CliInstallerService(); ptyTerminalService = new PtyTerminalService(); const teamMemberLogsFinder = new TeamMemberLogsFinder(); + const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource(); + const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource); + const boardTaskActivityDetailService = new BoardTaskActivityDetailService( + boardTaskActivityRecordSource + ); + const boardTaskExactLogsService = new BoardTaskExactLogsService(boardTaskActivityRecordSource); + const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService( + boardTaskActivityRecordSource + ); + const boardTaskLogStreamService = new BoardTaskLogStreamService(boardTaskActivityRecordSource); const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService( teamMemberLogsFinder ); @@ -924,6 +940,11 @@ async function initializeServices(): Promise { teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, + boardTaskActivityService, + boardTaskActivityDetailService, + boardTaskLogStreamService, + boardTaskExactLogsService, + boardTaskExactLogDetailService, teammateToolTracker ?? undefined, branchStatusService ?? undefined, { diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 2906e42d..c1ddec7b 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -8,8 +8,8 @@ */ import { - CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_GET_PROVIDER_STATUS, + CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload @@ -17,8 +17,9 @@ import { import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; -import type { CliInstallerService } from '../services'; import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver'; + +import type { CliInstallerService } from '../services'; import type { CliInstallationStatus, CliProviderId, diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 514a9a15..7b51dfc1 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -28,12 +28,12 @@ import { } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; +import { + type ApiKeyService, + RUNTIME_MANAGED_API_KEY_ENV_VARS, +} from '../services/extensions/apikeys/ApiKeyService'; import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService'; -import { - RUNTIME_MANAGED_API_KEY_ENV_VARS, - type ApiKeyService, -} from '../services/extensions/apikeys/ApiKeyService'; import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c6b72c9f..772501e1 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -89,6 +89,11 @@ import { registerValidationHandlers, removeValidationHandlers } from './validati import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { + BoardTaskActivityDetailService, + BoardTaskActivityService, + BoardTaskExactLogDetailService, + BoardTaskExactLogsService, + BoardTaskLogStreamService, BranchStatusService, ChangeExtractorService, CliInstallerService, @@ -130,6 +135,11 @@ export function initializeIpcHandlers( teamProvisioningService: TeamProvisioningService, teamMemberLogsFinder: TeamMemberLogsFinder, memberStatsComputer: MemberStatsComputer, + boardTaskActivityService: BoardTaskActivityService, + boardTaskActivityDetailService: BoardTaskActivityDetailService, + boardTaskLogStreamService: BoardTaskLogStreamService, + boardTaskExactLogsService: BoardTaskExactLogsService, + boardTaskExactLogDetailService: BoardTaskExactLogDetailService, teammateToolTracker: TeammateToolTracker | undefined, branchStatusService: BranchStatusService | undefined, contextCallbacks: { @@ -174,7 +184,12 @@ export function initializeIpcHandlers( memberStatsComputer, teamBackupService, teammateToolTracker, - branchStatusService + branchStatusService, + boardTaskActivityService, + boardTaskActivityDetailService, + boardTaskLogStreamService, + boardTaskExactLogsService, + boardTaskExactLogDetailService ); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 6ce68389..c6d9dee7 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -21,14 +21,19 @@ import { TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, - TEAM_GET_MESSAGES_PAGE, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, + TEAM_GET_MESSAGES_PAGE, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_ACTIVITY_DETAIL, TEAM_GET_TASK_ATTACHMENT, TEAM_GET_TASK_CHANGE_PRESENCE, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, + TEAM_GET_TASK_LOG_STREAM, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -98,15 +103,15 @@ import { buildActionModeAgentBlock, isAgentActionMode, } from '../services/team/actionModeInstructions'; +import { + buildReplaceMembersDiff, + buildReplaceMembersSummaryMessage, +} from '../services/team/memberUpdateNotifications'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; -import { - buildReplaceMembersDiff, - buildReplaceMembersSummaryMessage, -} from '../services/team/memberUpdateNotifications'; import { validateFromField, @@ -117,6 +122,11 @@ import { } from './guards'; import type { + BoardTaskActivityService, + BoardTaskActivityDetailService, + BoardTaskExactLogDetailService, + BoardTaskExactLogsService, + BoardTaskLogStreamService, BranchStatusService, MemberStatsComputer, TeamDataService, @@ -131,6 +141,11 @@ import type { AttachmentFileData, AttachmentMeta, AttachmentPayload, + BoardTaskActivityEntry, + BoardTaskActivityDetailResult, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, + BoardTaskLogStreamResponse, CreateTaskRequest, EffortLevel, GlobalTask, @@ -143,6 +158,7 @@ import type { MemberLogSummary, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, + MessagesPage, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -155,7 +171,6 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamData, - MessagesPage, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -184,7 +199,7 @@ const SEEN_RATE_LIMIT_KEYS_MAX = 500; async function getDurableLeadTeammateRoster( teamName: string, leadName: string -): Promise> { +): Promise<{ name: string; role?: string }[]> { const normalize = (name: string | undefined | null): string => name?.trim().toLowerCase() ?? ''; const leadLower = normalize(leadName); const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0)); @@ -241,7 +256,7 @@ async function getDurableLeadTeammateRoster( function buildLeadRosterContextBlock( teamName: string, leadName: string, - teammates: Array<{ name: string; role?: string }> + teammates: { name: string; role?: string }[] ): string | null { if (teammates.length === 0) return null; @@ -377,6 +392,11 @@ let memberStatsComputer: MemberStatsComputer | null = null; let teamBackupService: TeamBackupService | null = null; let teammateToolTracker: TeammateToolTracker | null = null; let branchStatusService: BranchStatusService | null = null; +let boardTaskActivityService: BoardTaskActivityService | null = null; +let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null; +let boardTaskLogStreamService: BoardTaskLogStreamService | null = null; +let boardTaskExactLogsService: BoardTaskExactLogsService | null = null; +let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null; const attachmentStore = new TeamAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore(); @@ -407,7 +427,12 @@ export function initializeTeamHandlers( statsComputer?: MemberStatsComputer, backupService?: TeamBackupService, toolTracker?: TeammateToolTracker, - branchTracker?: BranchStatusService + branchTracker?: BranchStatusService, + taskActivityService?: BoardTaskActivityService, + taskActivityDetailService?: BoardTaskActivityDetailService, + taskLogStreamService?: BoardTaskLogStreamService, + taskExactLogsService?: BoardTaskExactLogsService, + taskExactLogDetailService?: BoardTaskExactLogDetailService ): void { teamDataService = service; teamProvisioningService = provisioningService; @@ -416,6 +441,11 @@ export function initializeTeamHandlers( teamBackupService = backupService ?? null; teammateToolTracker = toolTracker ?? null; branchStatusService = branchTracker ?? null; + boardTaskActivityService = taskActivityService ?? null; + boardTaskActivityDetailService = taskActivityDetailService ?? null; + boardTaskLogStreamService = taskLogStreamService ?? null; + boardTaskExactLogsService = taskExactLogsService ?? null; + boardTaskExactLogDetailService = taskExactLogDetailService ?? null; } export function registerTeamHandlers(ipcMain: IpcMain): void { @@ -450,6 +480,11 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig); ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs); ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); + ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity); + ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail); + ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream); + ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries); + ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail); ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_START_TASK, handleStartTask); @@ -517,6 +552,11 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_CREATE_CONFIG); ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); + ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY); + ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL); + ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM); + ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); + ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL); ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_START_TASK); @@ -579,6 +619,41 @@ function getBranchStatusService(): BranchStatusService { return branchStatusService; } +function getBoardTaskActivityService(): BoardTaskActivityService { + if (!boardTaskActivityService) { + throw new Error('Board task activity service is not initialized'); + } + return boardTaskActivityService; +} + +function getBoardTaskActivityDetailService(): BoardTaskActivityDetailService { + if (!boardTaskActivityDetailService) { + throw new Error('Board task activity detail service is not initialized'); + } + return boardTaskActivityDetailService; +} + +function getBoardTaskLogStreamService(): BoardTaskLogStreamService { + if (!boardTaskLogStreamService) { + throw new Error('Board task log stream service is not initialized'); + } + return boardTaskLogStreamService; +} + +function getBoardTaskExactLogsService(): BoardTaskExactLogsService { + if (!boardTaskExactLogsService) { + throw new Error('Board task exact logs service is not initialized'); + } + return boardTaskExactLogsService; +} + +function getBoardTaskExactLogDetailService(): BoardTaskExactLogDetailService { + if (!boardTaskExactLogDetailService) { + throw new Error('Board task exact log detail service is not initialized'); + } + return boardTaskExactLogDetailService; +} + async function wrapTeamHandler( operation: string, handler: () => Promise @@ -1371,7 +1446,7 @@ async function handlePrepareProvisioning( ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; - let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined; + let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; @@ -1391,7 +1466,7 @@ async function handlePrepareProvisioning( if (!Array.isArray(providerIds)) { return { success: false, error: 'providerIds must be an array when provided' }; } - const normalized: Array<'anthropic' | 'codex' | 'gemini'> = []; + const normalized: ('anthropic' | 'codex' | 'gemini')[] = []; for (const entry of providerIds) { if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') { return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' }; @@ -2440,6 +2515,120 @@ async function handleGetLogsForTask( ); } +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', () => + getBoardTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskActivityDetail( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + activityId: 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' }; + } + if (typeof activityId !== 'string' || activityId.trim().length === 0) { + return { success: false, error: 'activityId must be a non-empty string' }; + } + return wrapTeamHandler('getTaskActivityDetail', () => + getBoardTaskActivityDetailService().getTaskActivityDetail( + vTeam.value!, + vTask.value!, + activityId.trim() + ) + ); +} + +async function handleGetTaskLogStream( + _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('getTaskLogStream', () => + getBoardTaskLogStreamService().getTaskLogStream(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskExactLogSummaries( + _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('getTaskExactLogSummaries', () => + getBoardTaskExactLogsService().getTaskExactLogSummaries(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskExactLogDetail( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + exactLogId: unknown, + expectedSourceGeneration: 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' }; + } + if (typeof exactLogId !== 'string' || exactLogId.trim().length === 0) { + return { success: false, error: 'exactLogId must be a non-empty string' }; + } + if ( + typeof expectedSourceGeneration !== 'string' || + expectedSourceGeneration.trim().length === 0 + ) { + return { success: false, error: 'expectedSourceGeneration must be a non-empty string' }; + } + return wrapTeamHandler('getTaskExactLogDetail', () => + getBoardTaskExactLogDetailService().getTaskExactLogDetail( + vTeam.value!, + vTask.value!, + exactLogId.trim(), + expectedSourceGeneration.trim() + ) + ); +} + function getMemberStatsComputer(): MemberStatsComputer { if (!memberStatsComputer) { throw new Error('Member stats computer is not initialized'); diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts index 784959c7..e1e6eab3 100644 --- a/src/main/ipc/terminal.ts +++ b/src/main/ipc/terminal.ts @@ -91,7 +91,7 @@ async function handleSpawn( options?: PtySpawnOptions ): Promise> { try { - const id = service.spawn(options); + const id = await service.spawn(options); return { success: true, data: id }; } catch (error) { const msg = getErrorMessage(error); diff --git a/src/main/ipc/tmux.ts b/src/main/ipc/tmux.ts index 1b6301fc..ff98ee84 100644 --- a/src/main/ipc/tmux.ts +++ b/src/main/ipc/tmux.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; -import type { TmuxPlatform, TmuxStatus, IpcResult } from '@shared/types'; +import type { IpcResult, TmuxPlatform, TmuxStatus } from '@shared/types'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; const logger = createLogger('IPC:tmux'); diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index f1e9eb23..da24adb1 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -1002,6 +1002,7 @@ export class ProjectScanner { hasSubagents, messageCount: metadata.messageCount, isOngoing, + model: metadata.model ?? undefined, gitBranch: metadata.gitBranch ?? undefined, metadataLevel, contextConsumption: metadata.contextConsumption, @@ -1050,6 +1051,7 @@ export class ProjectScanner { messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } } @@ -1069,6 +1071,7 @@ export class ProjectScanner { messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents: false, messageCount: metadata.messageCount, + model: metadata.model ?? undefined, metadataLevel, }; } diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index dab24266..14b888e0 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -11,7 +11,7 @@ */ import { type FileChangeEvent, type ParsedMessage } from '@main/types'; -import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl'; +import { parseJsonlFileWithStats, parseJsonlStream } from '@main/utils/jsonl'; import { getProjectsBasePath, getTasksBasePath, @@ -765,12 +765,12 @@ export class FileWatcher extends EventEmitter { const currentSize = fileStats.size; // Fast path: no size change means no new data - if (currentSize === lastSize && lastLineCount > 0) { + if (currentSize === lastSize && lastSize > 0) { return; } const isFirstRead = lastLineCount === 0 && lastSize === 0; - const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize; + const canUseIncrementalAppend = lastSize > 0 && currentSize > lastSize; let newMessages: ParsedMessage[] = []; let currentLineCount: number; let processedSize: number; @@ -782,12 +782,10 @@ export class FileWatcher extends EventEmitter { processedSize = lastSize + appended.consumedBytes; } else { // Fallback for first-read, truncation, or rewrite scenarios - const messages = await parseJsonlFile(filePath); - currentLineCount = messages.length; - newMessages = messages.slice(lastLineCount); - // Re-stat after full parse to capture bytes written during the parse - const postParseStats = await this.fsProvider.stat(filePath); - processedSize = postParseStats.size; + const parsedFile = await parseJsonlFileWithStats(filePath, this.fsProvider); + currentLineCount = parsedFile.parsedLineCount; + newMessages = parsedFile.messages.slice(lastLineCount); + processedSize = parsedFile.consumedBytes; } // If no new lines, skip processing @@ -895,56 +893,15 @@ export class FileWatcher extends EventEmitter { filePath: string, startOffset: number ): Promise { - const parsedMessages: ParsedMessage[] = []; const stream = this.fsProvider.createReadStream(filePath, { start: startOffset, - encoding: 'utf8', }); - - let buffer = ''; - let consumedBytes = 0; - let parsedLineCount = 0; - for await (const chunk of stream) { - buffer += chunk; - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const rawLine of lines) { - consumedBytes += Buffer.byteLength(`${rawLine}\n`, 'utf8'); - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; - if (!line.trim()) { - continue; - } - try { - const parsed = parseJsonlLine(line); - if (parsed) { - parsedMessages.push(parsed); - parsedLineCount++; - } - } catch { - // Ignore malformed appended lines; full parse path will recover on next rewrite. - } - } - } - - // Handle final line without trailing newline - if (buffer.trim()) { - try { - const parsed = parseJsonlLine(buffer); - if (parsed) { - parsedMessages.push(parsed); - parsedLineCount++; - consumedBytes += Buffer.byteLength(buffer, 'utf8'); - } - } catch { - // Keep offset pinned until this trailing partial becomes a complete line. - } - } + const parsed = await parseJsonlStream(stream); return { - messages: parsedMessages, - parsedLineCount, - consumedBytes, + messages: parsed.messages, + parsedLineCount: parsed.parsedLineCount, + consumedBytes: parsed.consumedBytes, }; } diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index ba23ebb5..64b43bdc 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -7,13 +7,14 @@ import crypto from 'node:crypto'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getHomeDir } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; +import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; + import type { PtySpawnOptions } from '@shared/types/terminal'; import type { BrowserWindow } from 'electron'; @@ -46,7 +47,7 @@ export class PtyTerminalService { * @returns Unique PTY ID for subsequent write/resize/kill calls. * @throws If node-pty native module is not available. */ - spawn(options?: PtySpawnOptions): string { + async spawn(options?: PtySpawnOptions): Promise { if (!nodePty) { throw new Error( 'Terminal not available: node-pty native module not found. Run: pnpm install' @@ -54,11 +55,15 @@ export class PtyTerminalService { } const id = crypto.randomUUID(); + const { env } = await buildProviderAwareCliEnv({ + env: options?.env, + connectionMode: 'augment', + }); const shell = options?.command ?? (process.platform === 'win32' - ? (process.env.COMSPEC ?? 'powershell.exe') - : (process.env.SHELL ?? '/bin/bash')); + ? (env.COMSPEC ?? process.env.COMSPEC ?? 'powershell.exe') + : (env.SHELL ?? process.env.SHELL ?? '/bin/bash')); const home = getHomeDir(); const pty = nodePty.spawn(shell, options?.args ?? [], { @@ -66,10 +71,7 @@ export class PtyTerminalService { cols: options?.cols ?? 80, rows: options?.rows ?? 24, cwd: options?.cwd ?? home, - env: { - ...buildEnrichedEnv(), - ...options?.env, - } as Record, + env: env as Record, }); pty.onData((data) => this.send(TERMINAL_DATA, id, data)); diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 8f093da0..a479024d 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -20,14 +20,15 @@ const { autoUpdater } = electronUpdater; import { app, net } from 'electron'; -import type { UpdaterStatus } from '@shared/types'; -import type { BrowserWindow } from 'electron'; import { getExpectedReleaseAssetUrl, getLatestMacMetadataUrl, isLatestMacMetadataCompatible, } from './updaterReleaseMetadata'; +import type { UpdaterStatus } from '@shared/types'; +import type { BrowserWindow } from 'electron'; + const logger = createLogger('UpdaterService'); /** diff --git a/src/main/services/infrastructure/updaterReleaseMetadata.ts b/src/main/services/infrastructure/updaterReleaseMetadata.ts index 8a32524c..cdded6b7 100644 --- a/src/main/services/infrastructure/updaterReleaseMetadata.ts +++ b/src/main/services/infrastructure/updaterReleaseMetadata.ts @@ -58,7 +58,7 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set { + return models.flatMap((model) => { if (typeof model === 'string') { - return model; + return [model]; } if (typeof model?.id === 'string' && model.id.trim().length > 0) { - return model.id.trim(); + return [model.id.trim()]; } return []; }); } export class ClaudeMultimodelBridgeService { - private async buildCliEnv(binaryPath: string): Promise { - const shellEnv = getCachedShellEnv() ?? {}; - const home = - getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE; - const env = { - ...buildEnrichedEnv(binaryPath), - ...shellEnv, - }; - if (home) { - env.HOME = home; - } - applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); - return providerConnectionService.applyAllConfiguredConnectionEnv(env); + private async buildCliEnv( + binaryPath: string + ): Promise>> { + return buildProviderAwareCliEnv({ binaryPath }); } private async buildProviderCliEnv( binaryPath: string, providerId: CliProviderId - ): Promise { - return applyProviderRuntimeEnv({ ...(await this.buildCliEnv(binaryPath)) }, providerId); + ): Promise>> { + return buildProviderAwareCliEnv({ binaryPath, providerId }); } private isUnifiedRuntimeUnsupported(error: unknown): boolean { @@ -252,12 +236,38 @@ export class ClaudeMultimodelBridgeService { }; } + private applyConnectionIssue( + provider: CliProviderStatus, + connectionIssues: Partial> + ): CliProviderStatus { + const issue = connectionIssues[provider.providerId]; + if (!issue) { + return provider; + } + + return { + ...provider, + authenticated: false, + authMethod: null, + verificationState: 'error', + statusMessage: issue, + backend: null, + }; + } + + private applyConnectionIssues( + providers: CliProviderStatus[], + connectionIssues: Partial> + ): CliProviderStatus[] { + return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues)); + } + async getProviderStatus( binaryPath: string, providerId: CliProviderId ): Promise { await resolveInteractiveShellEnv(); - const env = await this.buildCliEnv(binaryPath); + const { env, connectionIssues } = await this.buildCliEnv(binaryPath); try { const { stdout } = await execCli( @@ -270,7 +280,10 @@ export class ClaudeMultimodelBridgeService { ); const parsed = extractJsonObject(stdout); return providerConnectionService.enrichProviderStatus( - this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]) + this.applyConnectionIssue( + this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]), + connectionIssues + ) ); } catch (error) { if (!this.isUnifiedRuntimeUnsupported(error)) { @@ -291,7 +304,7 @@ export class ClaudeMultimodelBridgeService { private async buildGeminiStatus(binaryPath: string): Promise { const provider = createDefaultProviderStatus('gemini'); - const env = await this.buildProviderCliEnv(binaryPath, 'gemini'); + const { env } = await this.buildProviderCliEnv(binaryPath, 'gemini'); try { const { stdout } = await execCli( @@ -350,7 +363,7 @@ export class ClaudeMultimodelBridgeService { onUpdate?: (providers: CliProviderStatus[]) => void ): Promise { await resolveInteractiveShellEnv(); - const env = await this.buildCliEnv(binaryPath); + const { env, connectionIssues } = await this.buildCliEnv(binaryPath); try { const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], { @@ -359,8 +372,11 @@ export class ClaudeMultimodelBridgeService { }); const parsed = extractJsonObject(stdout); const providers = await providerConnectionService.enrichProviderStatuses( - ORDERED_PROVIDER_IDS.map((providerId) => - this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]) + this.applyConnectionIssues( + ORDERED_PROVIDER_IDS.map((providerId) => + this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]) + ), + connectionIssues ) ); onUpdate?.(providers); @@ -470,7 +486,10 @@ export class ClaudeMultimodelBridgeService { onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!)); const enrichedProviders = await providerConnectionService.enrichProviderStatuses( - ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!) + this.applyConnectionIssues( + ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!), + connectionIssues + ) ); onUpdate?.(enrichedProviders); diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 8eff71de..f49e732d 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -144,6 +144,108 @@ export class ProviderConnectionService { return nextEnv; } + async augmentConfiguredConnectionEnv( + env: NodeJS.ProcessEnv, + providerId: CliProviderId + ): Promise { + if (providerId === 'anthropic') { + if (this.getConfiguredAuthMode(providerId) !== 'api_key') { + return env; + } + + const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY'); + if (storedKey?.value.trim()) { + env.ANTHROPIC_API_KEY = storedKey.value; + } + return env; + } + + if (providerId !== 'codex') { + return env; + } + + const codexConnection = this.configManager.getConfig().providerConnections.codex; + if (!codexConnection.apiKeyBetaEnabled) { + return env; + } + + env[CODEX_API_KEY_BETA_ENV_VAR] = '1'; + env.CLAUDE_CODE_CODEX_BACKEND = codexConnection.authMode === 'oauth' ? 'adapter' : 'api'; + + if (codexConnection.authMode !== 'api_key') { + return env; + } + + const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); + if (storedKey?.value.trim()) { + env.OPENAI_API_KEY = storedKey.value; + } + + return env; + } + + async augmentAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise { + let nextEnv = env; + for (const providerId of ['anthropic', 'codex', 'gemini'] as const) { + nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId); + } + return nextEnv; + } + + async getConfiguredConnectionIssue( + env: NodeJS.ProcessEnv, + providerId: CliProviderId + ): Promise { + if (providerId === 'anthropic') { + if (this.getConfiguredAuthMode(providerId) !== 'api_key') { + return null; + } + + if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim()) { + return null; + } + + return ( + 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured. ' + + 'Add a stored/environment API key or switch Anthropic auth mode back to Auto or OAuth.' + ); + } + + if (providerId !== 'codex') { + return null; + } + + const codexConnection = this.configManager.getConfig().providerConnections.codex; + if (!codexConnection.apiKeyBetaEnabled || codexConnection.authMode !== 'api_key') { + return null; + } + + if (typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()) { + return null; + } + + return ( + 'Codex API key mode is enabled, but no OPENAI_API_KEY is configured. ' + + 'Add a stored/environment API key or switch Codex auth mode back to OAuth.' + ); + } + + async getConfiguredConnectionIssues( + env: NodeJS.ProcessEnv, + providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'] + ): Promise>> { + const issues: Partial> = {}; + + for (const providerId of providerIds) { + const issue = await this.getConfiguredConnectionIssue(env, providerId); + if (issue) { + issues[providerId] = issue; + } + } + + return issues; + } + async enrichProviderStatus(provider: CliProviderStatus): Promise { return { ...provider, diff --git a/src/main/services/runtime/geminiRuntimeAuth.ts b/src/main/services/runtime/geminiRuntimeAuth.ts index bc320daf..8a709bda 100644 --- a/src/main/services/runtime/geminiRuntimeAuth.ts +++ b/src/main/services/runtime/geminiRuntimeAuth.ts @@ -1,20 +1,20 @@ import * as fs from 'fs'; import * as path from 'path'; -export type GeminiGlobalConfig = { +export interface GeminiGlobalConfig { geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk'; geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk'; geminiLastAuthMethod?: string; geminiProjectId?: string; -}; +} -export type GeminiRuntimeAuthState = { +export interface GeminiRuntimeAuthState { authenticated: boolean; authMethod: string | null; resolvedBackend: 'auto' | 'api' | 'cli-sdk'; projectId: string | null; statusMessage: string | null; -}; +} function normalizeGeminiBackend( value: string | null | undefined diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts new file mode 100644 index 00000000..2793d710 --- /dev/null +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -0,0 +1,105 @@ +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv'; + +import { configManager } from '../infrastructure/ConfigManager'; + +import { providerConnectionService } from './ProviderConnectionService'; +import { + applyConfiguredRuntimeBackendsEnv, + applyProviderRuntimeEnv, + resolveTeamProviderId, +} from './providerRuntimeEnv'; + +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined; + +export interface ProviderAwareCliEnvOptions { + binaryPath?: string | null; + providerId?: ProviderEnvTargetId; + shellEnv?: NodeJS.ProcessEnv | null; + env?: NodeJS.ProcessEnv; + connectionMode?: 'strict' | 'augment'; +} + +export interface ProviderAwareCliEnvResult { + env: NodeJS.ProcessEnv; + connectionIssues: Partial>; +} + +function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +export async function buildProviderAwareCliEnv( + options: ProviderAwareCliEnvOptions = {} +): Promise { + const connectionMode = options.connectionMode ?? 'strict'; + const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {}; + const env = { + ...buildEnrichedEnv(options.binaryPath), + ...shellEnv, + }; + + applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); + + Object.assign(env, options.env ?? {}); + + const explicitHome = getFirstNonEmptyEnvValue(options.env?.HOME, options.env?.USERPROFILE); + const fallbackHome = getFirstNonEmptyEnvValue( + env.HOME, + env.USERPROFILE, + getShellPreferredHome(), + shellEnv.HOME, + process.env.HOME, + process.env.USERPROFILE + ); + + if (explicitHome) { + env.HOME = getFirstNonEmptyEnvValue(options.env?.HOME, explicitHome); + env.USERPROFILE = getFirstNonEmptyEnvValue(options.env?.USERPROFILE, explicitHome); + } else if (fallbackHome) { + env.HOME = getFirstNonEmptyEnvValue(env.HOME, fallbackHome); + env.USERPROFILE = getFirstNonEmptyEnvValue(env.USERPROFILE, fallbackHome); + } + + if (options.providerId) { + const resolvedProviderId = resolveTeamProviderId(options.providerId); + applyProviderRuntimeEnv(env, options.providerId); + if (connectionMode === 'augment') { + await providerConnectionService.augmentConfiguredConnectionEnv(env, resolvedProviderId); + return { + env, + connectionIssues: {}, + }; + } + + await providerConnectionService.applyConfiguredConnectionEnv(env, resolvedProviderId); + + return { + env, + connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env, [ + resolvedProviderId, + ]), + }; + } + + if (connectionMode === 'augment') { + await providerConnectionService.augmentAllConfiguredConnectionEnv(env); + return { + env, + connectionIssues: {}, + }; + } + + await providerConnectionService.applyAllConfiguredConnectionEnv(env); + return { + env, + connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env), + }; +} diff --git a/src/main/services/runtime/providerRuntimeEnv.ts b/src/main/services/runtime/providerRuntimeEnv.ts index b2fa257c..26ea3771 100644 --- a/src/main/services/runtime/providerRuntimeEnv.ts +++ b/src/main/services/runtime/providerRuntimeEnv.ts @@ -1,7 +1,7 @@ -import type { TeamProviderId } from '@shared/types'; - import { ConfigManager } from '../infrastructure/ConfigManager'; +import type { TeamProviderId } from '@shared/types'; + const PROVIDER_ROUTING_ENV_KEYS = [ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', 'CLAUDE_CODE_ENTRY_PROVIDER', diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index aa1b99e5..a98bde32 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -9,15 +9,10 @@ */ import { killProcessTree, spawnCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; -import { providerConnectionService } from '../runtime/ProviderConnectionService'; -import { - applyConfiguredRuntimeBackendsEnv, - applyProviderRuntimeEnv, -} from '../runtime/providerRuntimeEnv'; +import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types'; @@ -106,19 +101,23 @@ export class ScheduledTaskExecutor { logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); - const env = await providerConnectionService.applyConfiguredConnectionEnv( - applyProviderRuntimeEnv( - applyConfiguredRuntimeBackendsEnv({ - ...buildEnrichedEnv(binaryPath), - ...shellEnv, - CLAUDECODE: undefined, - }), - request.config.providerId - ), + const providerId = request.config.providerId === 'codex' || request.config.providerId === 'gemini' ? request.config.providerId - : 'anthropic' - ); + : 'anthropic'; + const { env, connectionIssues } = await buildProviderAwareCliEnv({ + binaryPath, + providerId, + shellEnv, + env: { + ...shellEnv, + CLAUDECODE: undefined, + }, + }); + const connectionIssue = connectionIssues[providerId]; + if (connectionIssue) { + throw new Error(connectionIssue); + } const child = spawnCli(binaryPath, args, { cwd: request.config.cwd, diff --git a/src/main/services/team/BranchStatusService.ts b/src/main/services/team/BranchStatusService.ts index 544ec4f2..c67a58cd 100644 --- a/src/main/services/team/BranchStatusService.ts +++ b/src/main/services/team/BranchStatusService.ts @@ -105,7 +105,7 @@ export class BranchStatusService { try { const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh }); const latestTracked = this.trackedPaths.get(normalizedPath); - if (!latestTracked || latestTracked.token !== expectedToken) return; + if (latestTracked?.token !== expectedToken) return; const previous = this.lastEmittedBranchByPath.get(normalizedPath) ?? UNSET_BRANCH; if (previous !== UNSET_BRANCH && previous === branch) { diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts index d3b7512d..59991c70 100644 --- a/src/main/services/team/TaskBoundaryParser.ts +++ b/src/main/services/team/TaskBoundaryParser.ts @@ -3,6 +3,11 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import * as readline from 'readline'; +import { + canonicalizeAgentTeamsToolName, + isAgentTeamsTaskBoundaryToolName, +} from './agentTeamsToolNames'; + import type { TaskBoundariesResult, TaskBoundary, @@ -31,8 +36,6 @@ interface ToolUseInfo { filePath?: string; } -const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']); - type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none'; function extractTaskId(input: Record): string { @@ -102,7 +105,7 @@ export class TaskBoundaryParser { const b = block as Record; if (b.type !== 'tool_use') continue; const rawName = typeof b.name === 'string' ? b.name : ''; - const toolName = rawName.replace(/^proxy_/, ''); + const toolName = canonicalizeAgentTeamsToolName(rawName); const toolUseId = typeof b.id === 'string' ? b.id : ''; const input = b.input as Record | undefined; const fp = typeof input?.file_path === 'string' ? input.file_path : undefined; @@ -238,8 +241,8 @@ export class TaskBoundaryParser { if (b.type !== 'tool_use') continue; const rawName = typeof b.name === 'string' ? b.name : ''; - const toolName = rawName.replace(/^proxy_/, ''); - if (!MCP_TASK_BOUNDARY_TOOLS.has(toolName)) continue; + const toolName = canonicalizeAgentTeamsToolName(rawName); + if (!isAgentTeamsTaskBoundaryToolName(toolName)) continue; const input = b.input as Record | undefined; if (!input) continue; diff --git a/src/main/services/team/TeamBootstrapStateReader.ts b/src/main/services/team/TeamBootstrapStateReader.ts index 97aba073..dc808c5d 100644 --- a/src/main/services/team/TeamBootstrapStateReader.ts +++ b/src/main/services/team/TeamBootstrapStateReader.ts @@ -1,8 +1,9 @@ import { getTeamsBasePath } from '@main/utils/pathDecoder'; -import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; import * as fs from 'fs'; import * as path from 'path'; +import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; + import type { PersistedTeamLaunchMemberState, PersistedTeamLaunchSnapshot, @@ -19,15 +20,15 @@ const MAX_BOOTSTRAP_JOURNAL_BYTES = 256 * 1024; const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024; const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000; -type RawBootstrapMemberState = { +interface RawBootstrapMemberState { name?: unknown; status?: unknown; lastAttemptAt?: unknown; lastObservedAt?: unknown; failureReason?: unknown; -}; +} -type RawBootstrapState = { +interface RawBootstrapState { version?: unknown; runId?: unknown; teamName?: unknown; @@ -38,7 +39,7 @@ type RawBootstrapState = { realTaskSubmissionState?: unknown; members?: unknown; terminal?: unknown; -}; +} type RawBootstrapJournalRecord = | { ts?: unknown; type?: 'phase'; phase?: unknown } @@ -47,31 +48,31 @@ type RawBootstrapJournalRecord = | { ts?: unknown; type?: 'terminal'; status?: unknown; reason?: unknown } | { ts?: unknown; type?: 'real_task'; state?: unknown; detail?: unknown }; -type RawBootstrapLockMetadata = { +interface RawBootstrapLockMetadata { pid?: unknown; runId?: unknown; requestHash?: unknown; ownerStartedAt?: unknown; createdAt?: unknown; nonce?: unknown; -}; +} -type BootstrapStateInspection = { +interface BootstrapStateInspection { raw: RawBootstrapState | null; issue?: string; -}; +} -type BootstrapJournalInspection = { +interface BootstrapJournalInspection { warnings?: string[]; issue?: string; lastPhase?: BootstrapRuntimePhase; -}; +} -type BootstrapLockMetadata = { +interface BootstrapLockMetadata { pid: number; runId: string; ownerStartedAt?: number; -}; +} type BootstrapRuntimePhase = | 'validating_spec' @@ -84,13 +85,13 @@ type BootstrapRuntimePhase = | 'failed' | 'canceled'; -type ComparableStat = { +interface ComparableStat { dev?: number; ino?: number; size: number; mode?: number; mtimeMs?: number; -}; +} function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index 314fe1a0..89d60c73 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -13,8 +13,8 @@ import { Worker } from 'node:worker_threads'; import { createLogger } from '@shared/utils/logger'; -import type { MemberLogSummary, TeamData } from '@shared/types'; import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes'; +import type { MemberLogSummary, TeamData } from '@shared/types'; const logger = createLogger('Service:TeamDataWorkerClient'); const WORKER_CALL_TIMEOUT_MS = 30_000; @@ -49,10 +49,10 @@ function resolveWorkerPath(): string | null { return null; } -type PendingEntry = { +interface PendingEntry { resolve: (v: unknown) => void; reject: (e: Error) => void; -}; +} export class TeamDataWorkerClient { private worker: Worker | null = null; diff --git a/src/main/services/team/TeamLaunchStateStore.ts b/src/main/services/team/TeamLaunchStateStore.ts index 0f154c5b..556f3b0f 100644 --- a/src/main/services/team/TeamLaunchStateStore.ts +++ b/src/main/services/team/TeamLaunchStateStore.ts @@ -3,8 +3,8 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; -import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; import { atomicWriteAsync } from './atomicWrite'; +import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; import type { PersistedTeamLaunchSnapshot } from '@shared/types'; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 6b514754..c25220ac 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -7,6 +7,10 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as readline from 'readline'; +import { + canonicalizeAgentTeamsToolName, + lineHasAgentTeamsTaskBoundaryToolName, +} from './agentTeamsToolNames'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; @@ -684,7 +688,7 @@ export class TeamMemberLogsFinder { async listAttributedSubagentFiles( teamName: string - ): Promise> { + ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { const discovery = await this.discoverProjectSessions(teamName); if (!discovery) return []; @@ -700,12 +704,12 @@ export class TeamMemberLogsFinder { ? [currentLeadSessionId] : sessionIds; const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds); - const results: Array<{ + const results: { memberName: string; sessionId: string; filePath: string; mtimeMs: number; - }> = []; + }[] = []; const settled = await Promise.all( candidates.map(async (candidate) => { @@ -764,12 +768,7 @@ export class TeamMemberLogsFinder { stream.destroy(); return true; } - if ( - (line.includes('"task_start"') || - line.includes('"task_complete"') || - line.includes('"task_set_status"')) && - pattern.test(line) - ) { + if (lineHasAgentTeamsTaskBoundaryToolName(line) && pattern.test(line)) { rl.close(); stream.destroy(); return true; @@ -1146,13 +1145,9 @@ export class TeamMemberLogsFinder { // Skip read-only task tools — they reference taskId but don't indicate // that this session actually WORKED on the task. Agents commonly call // task_get to check dependencies from other tasks, producing false matches. - const toolName = typeof b.name === 'string' ? b.name : ''; - if ( - toolName === 'task_get' || - toolName === 'mcp__agent-teams__task_get' || - toolName === 'TaskGet' - ) - continue; + const rawToolName = typeof b.name === 'string' ? b.name : ''; + const toolName = canonicalizeAgentTeamsToolName(rawToolName); + if (toolName === 'task_get' || toolName === 'TaskGet') continue; const input = b.input as Record | undefined; if (!input) continue; diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 4b4e4560..4e221bc2 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -1,10 +1,10 @@ +import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs/promises'; -import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types'; -import { createLogger } from '@shared/utils/logger'; - import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types'; + const LOOKBACK_MS = 10 * 60 * 1000; const CACHE_TTL_MS = 5_000; const TAIL_BYTES = 64 * 1024; @@ -102,11 +102,7 @@ export class TeamMemberRuntimeAdvisoryService { const membersSignature = this.buildMembersSignature(activeMembers); const now = Date.now(); const cachedBatch = this.teamBatchCacheByTeam.get(teamKey); - if ( - cachedBatch && - cachedBatch.membersSignature === membersSignature && - cachedBatch.expiresAt > now - ) { + if (cachedBatch?.membersSignature === membersSignature && cachedBatch.expiresAt > now) { return this.materializeBatchAdvisories(activeMembers, cachedBatch.value); } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index f03cc458..4baf9776 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -1,7 +1,7 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; -import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index afddfdd9..1dc1a7ba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -65,12 +65,8 @@ import { type GeminiRuntimeAuthState, resolveGeminiRuntimeAuth, } from '../runtime/geminiRuntimeAuth'; -import { providerConnectionService } from '../runtime/ProviderConnectionService'; -import { - applyConfiguredRuntimeBackendsEnv, - applyProviderRuntimeEnv, - resolveTeamProviderId, -} from '../runtime/providerRuntimeEnv'; +import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; +import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; import { buildActionModeProtocol } from './actionModeInstructions'; import { atomicWriteAsync } from './atomicWrite'; @@ -704,6 +700,7 @@ type LeadActivityState = 'active' | 'idle' | 'offline'; type ProvisioningAuthSource = | 'anthropic_api_key' | 'anthropic_auth_token' + | 'configured_api_key_missing' | 'codex_runtime' | 'gemini_runtime' | 'none'; @@ -712,6 +709,7 @@ interface ProvisioningEnvResolution { env: NodeJS.ProcessEnv; authSource: ProvisioningAuthSource; geminiRuntimeAuth: GeminiRuntimeAuthState | null; + warning?: string; } interface PromptSizeSummary { @@ -3562,7 +3560,9 @@ export class TeamProvisioningService { const prefixedWarning = providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning; const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); - if ( + if (authSource === 'configured_api_key_missing') { + blockingMessages.push(prefixedWarning); + } else if ( (authSource === 'none' || authSource === 'codex_runtime' || authSource === 'gemini_runtime') && @@ -3664,7 +3664,15 @@ export class TeamProvisioningService { const claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) return null; - const { env, authSource } = await this.buildProvisioningEnv(providerId); + const { env, authSource, warning } = await this.buildProvisioningEnv(providerId); + if (warning) { + return { + claudePath, + authSource, + warning, + }; + } + const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId); const result = { claudePath, @@ -4556,9 +4564,14 @@ export class TeamProvisioningService { const initialUserPrompt = request.prompt?.trim() ?? ''; const promptSize = getPromptSizeSummary(initialUserPrompt); let child: ReturnType; - const { env: shellEnv, geminiRuntimeAuth } = await this.buildProvisioningEnv( - request.providerId - ); + const { + env: shellEnv, + geminiRuntimeAuth, + warning: envWarning, + } = await this.buildProvisioningEnv(request.providerId); + if (envWarning) { + throw new Error(envWarning); + } shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); if (teammateModeDecision.forceProcessTeammates) { @@ -5087,9 +5100,14 @@ export class TeamProvisioningService { ); const promptSize = getPromptSizeSummary(prompt); let child: ReturnType; - const { env: shellEnv, geminiRuntimeAuth } = await this.buildProvisioningEnv( - request.providerId - ); + const { + env: shellEnv, + geminiRuntimeAuth, + warning: envWarning, + } = await this.buildProvisioningEnv(request.providerId); + if (envWarning) { + throw new Error(envWarning); + } shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); if (teammateModeDecision.forceProcessTeammates) { @@ -10305,21 +10323,23 @@ export class TeamProvisioningService { : {}), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; - applyConfiguredRuntimeBackendsEnv(env); - applyProviderRuntimeEnv(env, providerId); - await providerConnectionService.applyConfiguredConnectionEnv( + const resolvedProviderId = resolveTeamProviderId(providerId); + const providerEnvResult = await buildProviderAwareCliEnv({ + providerId, + shellEnv, env, - resolveTeamProviderId(providerId) - ); + }); + const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId]; + const providerEnv = providerEnvResult.env; const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); if (controlApiBaseUrl) { - env.CLAUDE_TEAM_CONTROL_URL = controlApiBaseUrl; + providerEnv.CLAUDE_TEAM_CONTROL_URL = controlApiBaseUrl; } // SHELL is a Unix concept — only set it on non-Windows platforms. if (!isWindows) { - env.SHELL = shell; + providerEnv.SHELL = shell; } // XDG directories are a freedesktop.org (Linux/macOS) convention. @@ -10333,35 +10353,47 @@ export class TeamProvisioningService { shellEnv.XDG_STATE_HOME?.trim() || process.env.XDG_STATE_HOME?.trim() || `${home}/.local/state`; - env.XDG_CONFIG_HOME = xdgConfigHome; - env.XDG_STATE_HOME = xdgStateHome; + providerEnv.XDG_CONFIG_HOME = xdgConfigHome; + providerEnv.XDG_STATE_HOME = xdgStateHome; } - if (resolveTeamProviderId(providerId) === 'codex') { - return { env, authSource: 'codex_runtime', geminiRuntimeAuth: null }; - } - - if (resolveTeamProviderId(providerId) === 'gemini') { + if (providerConnectionIssue) { return { - env, + env: providerEnv, + authSource: 'configured_api_key_missing', + geminiRuntimeAuth: null, + warning: providerConnectionIssue, + }; + } + + if (resolvedProviderId === 'codex') { + return { env: providerEnv, authSource: 'codex_runtime', geminiRuntimeAuth: null }; + } + + if (resolvedProviderId === 'gemini') { + return { + env: providerEnv, authSource: 'gemini_runtime', - geminiRuntimeAuth: await resolveGeminiRuntimeAuth(env), + geminiRuntimeAuth: await resolveGeminiRuntimeAuth(providerEnv), }; } // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly - if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) { - return { env, authSource: 'anthropic_api_key', geminiRuntimeAuth: null }; + if ( + typeof providerEnv.ANTHROPIC_API_KEY === 'string' && + providerEnv.ANTHROPIC_API_KEY.trim().length > 0 + ) { + return { env: providerEnv, authSource: 'anthropic_api_key', geminiRuntimeAuth: null }; } // 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var, // so we must copy it into ANTHROPIC_API_KEY for it to work. if ( - typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && - env.ANTHROPIC_AUTH_TOKEN.trim().length > 0 + typeof providerEnv.ANTHROPIC_AUTH_TOKEN === 'string' && + providerEnv.ANTHROPIC_AUTH_TOKEN.trim().length > 0 ) { - env.ANTHROPIC_API_KEY = env.ANTHROPIC_AUTH_TOKEN; - return { env, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null }; + providerEnv.ANTHROPIC_API_KEY = providerEnv.ANTHROPIC_AUTH_TOKEN; + return { env: providerEnv, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null }; } // 3. No explicit API key — let the CLI handle its own OAuth auth. @@ -10369,7 +10401,7 @@ export class TeamProvisioningService { // tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the // credentials file causes 401 errors because the stored token is // often stale (CLI refreshes in-memory but rarely writes back). - return { env, authSource: 'none', geminiRuntimeAuth: null }; + return { env: providerEnv, authSource: 'none', geminiRuntimeAuth: null }; } private async resolveControlApiBaseUrl(): Promise { diff --git a/src/main/services/team/agentTeamsToolNames.ts b/src/main/services/team/agentTeamsToolNames.ts new file mode 100644 index 00000000..19457c80 --- /dev/null +++ b/src/main/services/team/agentTeamsToolNames.ts @@ -0,0 +1,43 @@ +const AGENT_TEAMS_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; + +const TASK_BOUNDARY_TOOL_NAMES = ['task_start', 'task_complete', 'task_set_status'] as const; +const TASK_BOUNDARY_TOOL_SET = new Set(TASK_BOUNDARY_TOOL_NAMES); + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const TASK_BOUNDARY_TOOL_LINE_PATTERN = new RegExp( + `"name"\\s*:\\s*"(?:${[ + ...TASK_BOUNDARY_TOOL_NAMES, + ...TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `proxy_${toolName}`), + ...AGENT_TEAMS_PREFIXES.flatMap((prefix) => + TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `${prefix}${toolName}`) + ), + ...AGENT_TEAMS_PREFIXES.flatMap((prefix) => + TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `proxy_${prefix}${toolName}`) + ), + ] + .map(escapeRegex) + .join('|')})"` +); + +export function canonicalizeAgentTeamsToolName(rawName: string): string { + const normalized = rawName.replace(/^proxy_/, ''); + + for (const prefix of AGENT_TEAMS_PREFIXES) { + if (normalized.startsWith(prefix)) { + return normalized.slice(prefix.length); + } + } + + return normalized; +} + +export function isAgentTeamsTaskBoundaryToolName(rawName: string): boolean { + return TASK_BOUNDARY_TOOL_SET.has(canonicalizeAgentTeamsToolName(rawName)); +} + +export function lineHasAgentTeamsTaskBoundaryToolName(line: string): boolean { + return TASK_BOUNDARY_TOOL_LINE_PATTERN.test(line); +} diff --git a/src/main/services/team/idleNotificationMainProcessSemantics.ts b/src/main/services/team/idleNotificationMainProcessSemantics.ts index 36f336e0..e8eaf311 100644 --- a/src/main/services/team/idleNotificationMainProcessSemantics.ts +++ b/src/main/services/team/idleNotificationMainProcessSemantics.ts @@ -5,12 +5,12 @@ import { export type MainProcessIdleHandling = 'silent_noise' | 'passive_activity' | 'visible_actionable'; -export type ClassifiedMainProcessIdle = { +export interface ClassifiedMainProcessIdle { primaryKind: MainProcessIdlePrimaryKind; hasPeerSummary: boolean; peerSummary: string | null; handling: MainProcessIdleHandling; -}; +} export function classifyIdleNotificationForMainProcess( text: string diff --git a/src/main/services/team/inboxMessageIdentity.ts b/src/main/services/team/inboxMessageIdentity.ts index 8e699d86..cae49e5f 100644 --- a/src/main/services/team/inboxMessageIdentity.ts +++ b/src/main/services/team/inboxMessageIdentity.ts @@ -1,11 +1,11 @@ import { createHash } from 'crypto'; -type InboxIdentityLike = { +interface InboxIdentityLike { messageId?: unknown; from?: unknown; timestamp?: unknown; text?: unknown; -}; +} export function buildLegacyInboxMessageId(from: string, timestamp: string, text: string): string { return `inbox-${createHash('sha256').update(`${from}\n${timestamp}\n${text}`).digest('hex').slice(0, 16)}`; diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index ddc17421..ed3deb52 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -10,6 +10,12 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher'; export { MemberStatsComputer } from './MemberStatsComputer'; export { ReviewApplierService } from './ReviewApplierService'; export { TaskBoundaryParser } from './TaskBoundaryParser'; +export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; +export { BoardTaskActivityDetailService } from './taskLogs/activity/BoardTaskActivityDetailService'; +export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; +export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; +export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; +export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamBackupService } from './TeamBackupService'; export { TeamConfigReader } from './TeamConfigReader'; diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts index c9cbbf77..bb5acde8 100644 --- a/src/main/services/team/memberUpdateNotifications.ts +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -1,26 +1,26 @@ -export type MemberDiffInput = { +export interface MemberDiffInput { name: string; role?: string; workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; removedAt?: number | string | null; -}; +} -export type ReplaceMembersDiff = { - added: Array<{ +export interface ReplaceMembersDiff { + added: { name: string; role?: string; workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - }>; + }[]; removed: string[]; - updated: Array<{ + updated: { name: string; changes: string[]; - }>; -}; + }[]; +} function normalizeOptionalText(value: string | undefined): string | undefined { const normalized = value?.trim(); @@ -61,13 +61,13 @@ function describeWorkflowChange( export function buildReplaceMembersDiff( previousMembers: MemberDiffInput[], - nextMembers: Array<{ + nextMembers: { name: string; role?: string; workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - }> + }[] ): ReplaceMembersDiff { const previousByName = new Map( previousMembers diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index 2c743d6c..4dbed40b 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -1,13 +1,12 @@ -import { execFile } from 'child_process'; - import { parseCliArgs } from '@shared/utils/cliArgsParser'; +import { execFile } from 'child_process'; const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000; -type DesktopTeammateModeDecision = { +interface DesktopTeammateModeDecision { injectedTeammateMode: 'tmux' | null; forceProcessTeammates: boolean; -}; +} let tmuxAvailabilityCache: { value: boolean; at: number } | null = null; let tmuxAvailablePromise: Promise | null = null; diff --git a/src/main/services/team/taskChangePresenceUtils.ts b/src/main/services/team/taskChangePresenceUtils.ts index 3ef3b3c8..869140c3 100644 --- a/src/main/services/team/taskChangePresenceUtils.ts +++ b/src/main/services/team/taskChangePresenceUtils.ts @@ -1,8 +1,8 @@ +import { deriveTaskSince } from '@shared/utils/taskChangeSince'; import { getTaskChangeStateBucket, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; -import { deriveTaskSince } from '@shared/utils/taskChangeSince'; import { createHash } from 'crypto'; export interface TaskChangePresenceInterval { diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts new file mode 100644 index 00000000..dd5805cc --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts @@ -0,0 +1,613 @@ +import { + describeBoardTaskActivityLabel, + formatBoardTaskActivityTaskLabel, +} from '@shared/utils/boardTaskActivityLabels'; +import { + describeBoardTaskActivityActorLabel, + describeBoardTaskActivityContextLines, +} from '@shared/utils/boardTaskActivityPresentation'; +import { isEnhancedAIChunk } from '@main/types'; + +import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource'; +import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; +import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; + +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; +import type { + BoardTaskActivityDetail, + BoardTaskActivityDetailMetadataRow, + BoardTaskActivityDetailResult, +} from '@shared/types'; +import type { BoardTaskExactLogBundleCandidate } from '../exact/BoardTaskExactLogTypes'; +import type { ContentBlock, EnhancedChunk, ParsedMessage, ToolUseResultData } from '@main/types'; + +const READ_ONLY_TOOL_NAMES = new Set(['task_get', 'task_get_comment']); + +function scopeLabel(record: BoardTaskActivityRecord): string { + switch (record.actorContext.relation) { + case 'same_task': + return 'same task'; + case 'other_active_task': + return 'other active task'; + case 'idle': + return 'idle'; + case 'ambiguous': + return 'ambiguous'; + default: + return record.actorContext.relation; + } +} + +function formatTaskLabelOrLocator(record: BoardTaskActivityRecord['task']): string { + return formatBoardTaskActivityTaskLabel(record) ?? `#${record.locator.ref}`; +} + +function relationshipValue(record: BoardTaskActivityRecord): string | null { + const relationship = record.action?.details?.relationship; + const peerTaskLabel = formatBoardTaskActivityTaskLabel(record.action?.peerTask); + + if (relationship && peerTaskLabel) { + return `${relationship} ${peerTaskLabel}`; + } + if (relationship) { + return relationship; + } + if (peerTaskLabel) { + return peerTaskLabel; + } + return null; +} + +function buildMetadataRows(record: BoardTaskActivityRecord): BoardTaskActivityDetailMetadataRow[] { + const rows: BoardTaskActivityDetailMetadataRow[] = [ + { + label: 'Task', + value: formatTaskLabelOrLocator(record.task), + }, + { + label: 'Scope', + value: scopeLabel(record), + }, + ]; + + if (record.action?.canonicalToolName) { + rows.push({ label: 'Tool', value: record.action.canonicalToolName }); + } + if (record.action?.details?.status) { + rows.push({ label: 'Status', value: record.action.details.status }); + } + if ('owner' in (record.action?.details ?? {})) { + rows.push({ label: 'Owner', value: record.action?.details?.owner ?? 'cleared' }); + } + if ('clarification' in (record.action?.details ?? {})) { + rows.push({ + label: 'Clarification', + value: record.action?.details?.clarification ?? 'cleared', + }); + } + if (record.action?.details?.reviewer) { + rows.push({ label: 'Reviewer', value: record.action.details.reviewer }); + } + if (record.action?.details?.commentId) { + rows.push({ label: 'Comment', value: record.action.details.commentId }); + } + if (record.action?.details?.attachmentId) { + rows.push({ label: 'Attachment ID', value: record.action.details.attachmentId }); + } + if (record.action?.details?.filename) { + rows.push({ label: 'File', value: record.action.details.filename }); + } + const relationship = relationshipValue(record); + if (relationship) { + rows.push({ label: 'Relationship', value: relationship }); + } + const activeTaskLabel = formatBoardTaskActivityTaskLabel(record.actorContext.activeTask); + if (activeTaskLabel) { + rows.push({ label: 'Active task', value: activeTaskLabel }); + } + if (record.actorContext.activePhase) { + rows.push({ label: 'Phase', value: record.actorContext.activePhase }); + } + + return rows; +} + +function buildCandidate(record: BoardTaskActivityRecord): BoardTaskExactLogBundleCandidate { + return { + id: `activity:${record.id}`, + timestamp: record.timestamp, + actor: record.actor, + source: { + filePath: record.source.filePath, + messageUuid: record.source.messageUuid, + ...(record.source.toolUseId ? { toolUseId: record.source.toolUseId } : {}), + sourceOrder: record.source.sourceOrder, + }, + records: [record], + anchor: record.source.toolUseId + ? { + kind: 'tool', + filePath: record.source.filePath, + messageUuid: record.source.messageUuid, + toolUseId: record.source.toolUseId, + } + : { + kind: 'message', + filePath: record.source.filePath, + messageUuid: record.source.messageUuid, + }, + actionLabel: describeBoardTaskActivityLabel(record), + ...(record.action?.category ? { actionCategory: record.action.category } : {}), + ...(record.action?.canonicalToolName + ? { canonicalToolName: record.action.canonicalToolName } + : {}), + linkKinds: [record.linkKind], + targetRoles: [record.targetRole], + canLoadDetail: false, + }; +} + +function shouldIncludeLinkedTool(record: BoardTaskActivityRecord): boolean { + const toolName = record.action?.canonicalToolName; + if (!record.source.toolUseId || !toolName) { + return false; + } + + return !READ_ONLY_TOOL_NAMES.has(toolName); +} + +function looksLikeJsonPayload(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function parseJsonLikeString(value: string): unknown { + if (!looksLikeJsonPayload(value)) { + return null; + } + + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function extractBoardToolOutputText( + toolName: string | undefined, + parsedPayload: unknown +): string | null { + if (!toolName || !parsedPayload || typeof parsedPayload !== 'object') { + return null; + } + + const payload = parsedPayload as Record; + if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { + const comment = payload.comment as Record | undefined; + if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { + return comment.text; + } + } + + return null; +} + +function collectTextBlockText(value: unknown): string { + if (!Array.isArray(value)) { + return ''; + } + + return value + .filter( + (child): child is Extract => + typeof child === 'object' && + child !== null && + 'type' in child && + child.type === 'text' && + 'text' in child && + typeof child.text === 'string' + ) + .map((child) => child.text) + .join('\n'); +} + +function cloneBlock(block: T): T { + if (block.type === 'tool_use') { + return { + ...block, + input: { ...(block.input ?? {}) }, + } as T; + } + + if (block.type === 'tool_result') { + return { + ...block, + content: Array.isArray(block.content) + ? block.content.map((child) => cloneBlock(child)) + : block.content, + } as T; + } + + if (block.type === 'image') { + return { + ...block, + source: { ...block.source }, + } as T; + } + + return { ...block } as T; +} + +function sanitizeToolResultContent( + content: ContentBlock, + canonicalToolName?: string +): ContentBlock { + if (content.type !== 'tool_result') { + return cloneBlock(content); + } + + if (typeof content.content === 'string') { + const parsedPayload = parseJsonLikeString(content.content); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: [{ type: 'text', text: extractedText }], + }; + } + return parsedPayload ? { ...content, content: '' } : cloneBlock(content); + } + + if (!Array.isArray(content.content)) { + return cloneBlock(content); + } + + const jsonText = collectTextBlockText(content.content); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: extractedText, + }; + } + + const sanitizedChildren = content.content + .map((child) => { + if (child.type !== 'text') { + return cloneBlock(child); + } + + return looksLikeJsonPayload(child.text) ? null : cloneBlock(child); + }) + .filter((child): child is ContentBlock => child !== null); + + if (sanitizedChildren.length === 0) { + return { + ...content, + content: '', + }; + } + + return { + ...content, + content: sanitizedChildren, + }; +} + +function inferSingleToolUseId(message: ParsedMessage): string | undefined { + if (message.sourceToolUseID) { + return message.sourceToolUseID; + } + + if (message.toolResults.length === 1) { + return message.toolResults[0]?.toolUseId; + } + + if (!Array.isArray(message.content)) { + return undefined; + } + + const uniqueIds = new Set( + message.content + .filter( + (block): block is Extract => + block.type === 'tool_result' + ) + .map((block) => block.tool_use_id) + ); + + return uniqueIds.size === 1 ? uniqueIds.values().next().value : undefined; +} + +function hasMeaningfulToolUseResult(message: ParsedMessage): boolean { + const rawToolUseResult = message.toolUseResult as unknown; + if ( + !rawToolUseResult || + typeof rawToolUseResult !== 'object' || + Array.isArray(rawToolUseResult) + ) { + return false; + } + + const toolUseResult = rawToolUseResult as { + error?: unknown; + stderr?: unknown; + content?: unknown; + message?: unknown; + }; + if (typeof toolUseResult.error === 'string' && toolUseResult.error.trim().length > 0) { + return true; + } + if (typeof toolUseResult.stderr === 'string' && toolUseResult.stderr.trim().length > 0) { + return true; + } + if (typeof toolUseResult.content === 'string' && toolUseResult.content.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.content) && toolUseResult.content.length > 0) { + return true; + } + if (typeof toolUseResult.message === 'string' && toolUseResult.message.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.message) && toolUseResult.message.length > 0) { + return true; + } + return false; +} + +function isEmptyToolPayload(value: unknown): boolean { + if (value == null) { + return true; + } + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +function sanitizeJsonLikeToolResultPayloads( + messages: ParsedMessage[], + canonicalToolName?: string +): ParsedMessage[] { + return messages.map((message) => { + let nextMessage = message; + const rawToolUseResult = message.toolUseResult as unknown; + + if ( + rawToolUseResult && + typeof rawToolUseResult === 'object' && + !Array.isArray(rawToolUseResult) + ) { + const nextToolUseResult: Record & { + content?: unknown; + message?: unknown; + } = { ...(rawToolUseResult as Record) }; + let toolUseResultChanged = false; + const extractedFromContent = + typeof nextToolUseResult.content === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.content) + ) + : null; + const extractedFromMessage = + typeof nextToolUseResult.message === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.message) + ) + : null; + + if (typeof extractedFromContent === 'string') { + nextToolUseResult.content = extractedFromContent; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.content === 'string' && + looksLikeJsonPayload(nextToolUseResult.content) + ) { + nextToolUseResult.content = ''; + toolUseResultChanged = true; + } + + if (typeof extractedFromMessage === 'string') { + nextToolUseResult.message = extractedFromMessage; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.message === 'string' && + looksLikeJsonPayload(nextToolUseResult.message) + ) { + nextToolUseResult.message = ''; + toolUseResultChanged = true; + } + + if (toolUseResultChanged) { + nextMessage = { + ...nextMessage, + toolUseResult: nextToolUseResult as ToolUseResultData, + }; + } + } else if (Array.isArray(rawToolUseResult)) { + const toolUseId = inferSingleToolUseId(message); + const jsonText = collectTextBlockText(rawToolUseResult); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string' || parsedPayload) { + nextMessage = { + ...nextMessage, + toolUseResult: { + ...(toolUseId ? { toolUseId } : {}), + content: typeof extractedText === 'string' ? extractedText : '', + }, + }; + } + } + + if (typeof message.content === 'string') { + return nextMessage; + } + + let changed = false; + const nextContent = message.content.map((block) => { + if (block.type !== 'tool_result') { + return block; + } + + const sanitized = sanitizeToolResultContent(block, canonicalToolName); + if (JSON.stringify(sanitized) !== JSON.stringify(block)) { + changed = true; + } + return sanitized; + }); + + if (!changed) { + return nextMessage; + } + + return { + ...nextMessage, + content: nextContent, + }; + }); +} + +function pruneEmptyInternalToolResultMessages(messages: ParsedMessage[]): ParsedMessage[] { + return messages.filter((message) => { + if ( + message.type !== 'user' || + message.toolResults.length === 0 || + typeof message.content === 'string' + ) { + return true; + } + + const hasNonToolResultContent = message.content.some((block) => block.type !== 'tool_result'); + if (hasNonToolResultContent) { + return true; + } + + const allToolResultsEmpty = message.toolResults.every((toolResult) => + isEmptyToolPayload(toolResult.content) + ); + if (!allToolResultsEmpty) { + return true; + } + + return hasMeaningfulToolUseResult(message); + }); +} + +function hasToolUseBlock( + content: ParsedMessage['content'], + toolUseId: string | undefined +): boolean { + if (!toolUseId || typeof content === 'string') { + return false; + } + + return content.some((block) => block.type === 'tool_use' && block.id === toolUseId); +} + +function pruneToolAnchoredAssistantOutputMessages( + messages: ParsedMessage[], + toolUseId: string | undefined +): ParsedMessage[] { + if (!toolUseId) { + return messages; + } + + return messages.filter((message) => { + if (message.type !== 'assistant') { + return true; + } + if (message.sourceToolUseID !== toolUseId) { + return true; + } + return hasToolUseBlock(message.content, toolUseId); + }); +} + +function sanitizeDetailMessages( + messages: ParsedMessage[], + canonicalToolName: string | undefined, + toolUseId: string | undefined +): ParsedMessage[] { + return pruneEmptyInternalToolResultMessages( + pruneToolAnchoredAssistantOutputMessages( + sanitizeJsonLikeToolResultPayloads(messages, canonicalToolName), + toolUseId + ) + ); +} + +function hasUsefulLinkedToolChunks(chunks: EnhancedChunk[]): boolean { + return chunks.some((chunk) => isEnhancedAIChunk(chunk) && chunk.toolExecutions.length > 0); +} + +export class BoardTaskActivityDetailService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + ) {} + + async getTaskActivityDetail( + teamName: string, + taskId: string, + activityId: string + ): Promise { + const records = await this.recordSource.getTaskRecords(teamName, taskId); + const record = records.find((candidate) => candidate.id === activityId); + if (!record) { + return { status: 'missing' }; + } + + const detail: BoardTaskActivityDetail = { + entryId: record.id, + summaryLabel: describeBoardTaskActivityLabel(record), + actorLabel: describeBoardTaskActivityActorLabel(record.actor), + timestamp: record.timestamp, + contextLines: describeBoardTaskActivityContextLines(record), + metadataRows: buildMetadataRows(record), + }; + + if (shouldIncludeLinkedTool(record)) { + const parsedMessagesByFile = await this.strictParser.parseFiles([record.source.filePath]); + const detailCandidate = this.detailSelector.selectDetail({ + candidate: buildCandidate(record), + records, + parsedMessagesByFile, + }); + + if (detailCandidate) { + const filteredMessages = sanitizeDetailMessages( + detailCandidate.filteredMessages, + record.action?.canonicalToolName, + record.source.toolUseId + ); + const chunks = this.chunkBuilder.buildBundleChunks(filteredMessages); + if (chunks.length > 0 && hasUsefulLinkedToolChunks(chunks)) { + detail.logDetail = { + id: detailCandidate.id, + chunks, + }; + } + } + } + + return { + status: 'ok', + detail, + }; + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts new file mode 100644 index 00000000..9f058609 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts @@ -0,0 +1,83 @@ +import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder'; + +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; +import type { RawTaskActivityMessage } from './BoardTaskActivityTranscriptReader'; +import type { BoardTaskActivityEntry, TeamTask } from '@shared/types'; + +function cloneTaskRef(task: BoardTaskActivityRecord['task']): BoardTaskActivityEntry['task'] { + return { + locator: { ...task.locator }, + resolution: task.resolution, + ...(task.taskRef ? { taskRef: { ...task.taskRef } } : {}), + }; +} + +function cloneActorContext( + actorContext: BoardTaskActivityRecord['actorContext'] +): BoardTaskActivityEntry['actorContext'] { + return { + relation: actorContext.relation, + ...(actorContext.activeTask ? { activeTask: cloneTaskRef(actorContext.activeTask) } : {}), + ...(actorContext.activePhase ? { activePhase: actorContext.activePhase } : {}), + ...(actorContext.activeExecutionSeq + ? { activeExecutionSeq: actorContext.activeExecutionSeq } + : {}), + }; +} + +function cloneAction( + action: BoardTaskActivityRecord['action'] +): BoardTaskActivityEntry['action'] | undefined { + if (!action) return undefined; + + return { + ...(action.canonicalToolName ? { canonicalToolName: action.canonicalToolName } : {}), + ...(action.toolUseId ? { toolUseId: action.toolUseId } : {}), + category: action.category, + ...(action.peerTask ? { peerTask: cloneTaskRef(action.peerTask) } : {}), + ...(action.relationshipPerspective + ? { relationshipPerspective: action.relationshipPerspective } + : {}), + ...(action.details ? { details: { ...action.details } } : {}), + }; +} + +export class BoardTaskActivityEntryBuilder { + constructor( + private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder() + ) {} + + buildForTask(args: { + teamName: string; + targetTask: TeamTask; + tasks: TeamTask[]; + messages: RawTaskActivityMessage[]; + }): BoardTaskActivityEntry[] { + return this.buildFromRecords(this.recordBuilder.buildForTask(args)); + } + + buildFromRecords(records: BoardTaskActivityRecord[]): BoardTaskActivityEntry[] { + return records.map((record) => ({ + id: record.id, + timestamp: record.timestamp, + task: cloneTaskRef(record.task), + linkKind: record.linkKind, + targetRole: record.targetRole, + actor: { + ...(record.actor.memberName ? { memberName: record.actor.memberName } : {}), + role: record.actor.role, + sessionId: record.actor.sessionId, + ...(record.actor.agentId ? { agentId: record.actor.agentId } : {}), + isSidechain: record.actor.isSidechain, + }, + actorContext: cloneActorContext(record.actorContext), + ...(record.action ? { action: cloneAction(record.action) } : {}), + source: { + messageUuid: record.source.messageUuid, + filePath: record.source.filePath, + ...(record.source.toolUseId ? { toolUseId: record.source.toolUseId } : {}), + sourceOrder: record.source.sourceOrder, + }, + })); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts new file mode 100644 index 00000000..34464243 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts @@ -0,0 +1,54 @@ +interface CacheEntry { + mtimeMs: number; + size: number; + value: T; +} + +export class BoardTaskActivityParseCache { + private readonly cache = new Map>(); + private readonly inFlight = new Map>(); + + getIfFresh(filePath: string, mtimeMs: number, size: number): T | null { + const cached = this.cache.get(filePath); + if (!cached) return null; + if (cached.mtimeMs !== mtimeMs || cached.size !== size) { + this.cache.delete(filePath); + return null; + } + return cached.value; + } + + getInFlight(filePath: string): Promise | null { + return this.inFlight.get(filePath) ?? null; + } + + setInFlight(filePath: string, promise: Promise): void { + this.inFlight.set(filePath, promise); + } + + clearInFlight(filePath: string): void { + this.inFlight.delete(filePath); + } + + set(filePath: string, mtimeMs: number, size: number, value: T): void { + this.cache.set(filePath, { mtimeMs, size, value }); + } + + clearForPath(filePath: string): void { + this.cache.delete(filePath); + this.inFlight.delete(filePath); + } + + retainOnly(filePaths: Set): void { + for (const filePath of this.cache.keys()) { + if (!filePaths.has(filePath)) { + this.cache.delete(filePath); + } + } + for (const filePath of this.inFlight.keys()) { + if (!filePaths.has(filePath)) { + this.inFlight.delete(filePath); + } + } + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts new file mode 100644 index 00000000..cf2900b8 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts @@ -0,0 +1,25 @@ +import type { + BoardTaskActivityAction, + BoardTaskActivityActor, + BoardTaskActivityActorContext, + BoardTaskActivityLinkKind, + BoardTaskActivityTargetRole, + BoardTaskActivityTaskRef, +} from '@shared/types'; + +export interface BoardTaskActivityRecord { + id: string; + timestamp: string; + task: BoardTaskActivityTaskRef; + linkKind: BoardTaskActivityLinkKind; + targetRole: BoardTaskActivityTargetRole; + actor: BoardTaskActivityActor; + actorContext: BoardTaskActivityActorContext; + action?: BoardTaskActivityAction; + source: { + messageUuid: string; + filePath: string; + toolUseId?: string; + sourceOrder: number; + }; +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts new file mode 100644 index 00000000..fc58f657 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts @@ -0,0 +1,382 @@ +import { createLogger } from '@shared/utils/logger'; +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import type { + ParsedBoardTaskLink, + ParsedBoardTaskToolAction, +} from '../contract/BoardTaskTranscriptContract'; +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; +import type { RawTaskActivityMessage } from './BoardTaskActivityTranscriptReader'; +import type { + BoardTaskActivityAction, + BoardTaskActivityActor, + BoardTaskActivityCategory, + BoardTaskActivityTaskRef, + BoardTaskLocator, + TaskRef, + TeamTask, +} from '@shared/types'; + +interface TaskLookup { + byId: Map; + byDisplayId: Map; +} + +const logger = createLogger('Service:BoardTaskActivityRecordBuilder'); + +const CANONICAL_TASK_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function noteReadDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function buildTaskRef(teamName: string, task: TeamTask): TaskRef { + return { + taskId: task.id, + displayId: getTaskDisplayId(task), + teamName, + }; +} + +function normalizeDisplayRef(value: string): string { + return value.trim().toLowerCase(); +} + +function looksLikeCanonicalTaskId(value: string): boolean { + return CANONICAL_TASK_ID_PATTERN.test(value.trim()); +} + +function buildTaskLookup(tasks: TeamTask[]): TaskLookup { + const byId = new Map(); + const byDisplayId = new Map(); + + for (const task of tasks) { + byId.set(task.id, task); + const displayId = normalizeDisplayRef(getTaskDisplayId(task)); + const list = byDisplayId.get(displayId) ?? []; + list.push(task); + byDisplayId.set(displayId, list); + } + + return { byId, byDisplayId }; +} + +function resolveLocatorToTaskRef( + teamName: string, + locator: BoardTaskLocator, + lookup: TaskLookup +): BoardTaskActivityTaskRef { + const canonicalCandidate = + (locator.canonicalId && lookup.byId.get(locator.canonicalId)) || + (locator.refKind === 'canonical' ? lookup.byId.get(locator.ref) : undefined) || + (locator.refKind === 'unknown' && looksLikeCanonicalTaskId(locator.ref) + ? lookup.byId.get(locator.ref) + : undefined); + + if (canonicalCandidate) { + return { + locator, + resolution: canonicalCandidate.status === 'deleted' ? 'deleted' : 'resolved', + taskRef: buildTaskRef(teamName, canonicalCandidate), + }; + } + + const displayCandidates = lookup.byDisplayId.get(normalizeDisplayRef(locator.ref)) ?? []; + if (displayCandidates.length === 1) { + const task = displayCandidates[0]; + return { + locator, + resolution: task.status === 'deleted' ? 'deleted' : 'resolved', + taskRef: buildTaskRef(teamName, task), + }; + } + + if (displayCandidates.length > 1) { + noteReadDiagnostic('ambiguous_locator', { refKind: locator.refKind }); + return { + locator, + resolution: 'ambiguous', + }; + } + + noteReadDiagnostic('unresolved_locator', { refKind: locator.refKind }); + return { + locator, + resolution: 'unresolved', + }; +} + +function locatorCouldMatchTask( + locator: BoardTaskLocator, + targetTask: TeamTask, + lookup: TaskLookup +): boolean { + if (locator.canonicalId === targetTask.id) return true; + if (locator.refKind === 'canonical' && locator.ref === targetTask.id) return true; + + const targetDisplayId = getTaskDisplayId(targetTask); + const normalizedLocatorRef = normalizeDisplayRef(locator.ref); + const normalizedTargetDisplayId = normalizeDisplayRef(targetDisplayId); + if (normalizedLocatorRef !== normalizedTargetDisplayId) return false; + + const candidates = lookup.byDisplayId.get(normalizedTargetDisplayId) ?? []; + if (candidates.length === 0) return false; + return candidates.some((candidate) => candidate.id === targetTask.id); +} + +function buildActionMap( + actions: ParsedBoardTaskToolAction[] +): Map { + const actionMap = new Map(); + for (const action of actions) { + if (actionMap.has(action.toolUseId)) { + noteReadDiagnostic('duplicate_action_tool_use_id', { toolUseId: action.toolUseId }); + continue; + } + actionMap.set(action.toolUseId, action); + } + return actionMap; +} + +function buildActionCategory(action: ParsedBoardTaskToolAction): BoardTaskActivityCategory { + switch (action.canonicalToolName) { + case 'task_start': + case 'task_complete': + case 'task_set_status': + return 'status'; + case 'review_start': + case 'review_request': + case 'review_approve': + case 'review_request_changes': + return 'review'; + case 'task_add_comment': + case 'task_get_comment': + return 'comment'; + case 'task_set_owner': + return 'assignment'; + case 'task_get': + return 'read'; + case 'task_attach_file': + case 'task_attach_comment_file': + return 'attachment'; + case 'task_link': + case 'task_unlink': + return 'relationship'; + case 'task_set_clarification': + return 'clarification'; + default: + return 'other'; + } +} + +function buildActionDetails( + action: ParsedBoardTaskToolAction +): BoardTaskActivityAction['details'] | undefined { + const details = { + ...(action.input?.status ? { status: action.input.status } : {}), + ...(action.input && 'owner' in action.input ? { owner: action.input.owner } : {}), + ...(action.input && 'clarification' in action.input + ? { clarification: action.input.clarification } + : {}), + ...(action.input?.reviewer ? { reviewer: action.input.reviewer } : {}), + ...(action.input?.relationship ? { relationship: action.input.relationship } : {}), + ...(action.input?.commentId ? { commentId: action.input.commentId } : {}), + ...(action.resultRefs?.commentId ? { commentId: action.resultRefs.commentId } : {}), + ...(action.resultRefs?.attachmentId ? { attachmentId: action.resultRefs.attachmentId } : {}), + ...(action.resultRefs?.filename ? { filename: action.resultRefs.filename } : {}), + }; + + return Object.keys(details).length > 0 ? details : undefined; +} + +function buildRelationshipPerspective( + link: ParsedBoardTaskLink, + action: ParsedBoardTaskToolAction +): BoardTaskActivityAction['relationshipPerspective'] | undefined { + const relationship = action.input?.relationship; + if (!relationship) { + return undefined; + } + if (relationship === 'related') { + return 'symmetric'; + } + if (relationship === 'blocked-by') { + return link.targetRole === 'subject' ? 'incoming' : 'outgoing'; + } + if (relationship === 'blocks') { + return link.targetRole === 'subject' ? 'outgoing' : 'incoming'; + } + return undefined; +} + +function buildAction(args: { + action: ParsedBoardTaskToolAction | undefined; + link: ParsedBoardTaskLink; + peerTask?: BoardTaskActivityTaskRef; +}): BoardTaskActivityAction | undefined { + const { action, link, peerTask } = args; + if (!action) return undefined; + const category = buildActionCategory(action); + const details = buildActionDetails(action); + const relationshipPerspective = + category === 'relationship' ? buildRelationshipPerspective(link, action) : undefined; + + return { + canonicalToolName: action.canonicalToolName, + toolUseId: action.toolUseId, + category, + ...(details ? { details } : {}), + ...(category === 'relationship' && peerTask ? { peerTask } : {}), + ...(relationshipPerspective ? { relationshipPerspective } : {}), + }; +} + +function resolveActivityActor(message: RawTaskActivityMessage): BoardTaskActivityActor { + const memberName = + typeof message.agentName === 'string' && message.agentName.trim().length > 0 + ? message.agentName.trim() + : undefined; + + return { + ...(memberName ? { memberName } : {}), + role: memberName + ? message.isSidechain + ? 'member' + : 'lead' + : message.isSidechain + ? 'member' + : 'unknown', + sessionId: message.sessionId, + ...(message.agentId ? { agentId: message.agentId } : {}), + isSidechain: message.isSidechain, + }; +} + +function resolvePeerTask( + teamName: string, + currentLink: ParsedBoardTaskLink, + allLinks: ParsedBoardTaskLink[], + targetTask: TeamTask, + lookup: TaskLookup +): BoardTaskActivityTaskRef | undefined { + for (const link of allLinks) { + if (link === currentLink) continue; + if (link.toolUseId !== currentLink.toolUseId) continue; + if (locatorCouldMatchTask(link.task, targetTask, lookup)) continue; + return resolveLocatorToTaskRef(teamName, link.task, lookup); + } + return undefined; +} + +function buildActorContext( + teamName: string, + actorContext: ParsedBoardTaskLink['actorContext'], + lookup: TaskLookup +): BoardTaskActivityRecord['actorContext'] { + return { + relation: actorContext.relation, + ...(actorContext.activeTask + ? { activeTask: resolveLocatorToTaskRef(teamName, actorContext.activeTask, lookup) } + : {}), + ...(actorContext.activePhase ? { activePhase: actorContext.activePhase } : {}), + ...(actorContext.activeExecutionSeq + ? { activeExecutionSeq: actorContext.activeExecutionSeq } + : {}), + }; +} + +function compareRecords(left: BoardTaskActivityRecord, right: BoardTaskActivityRecord): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +export class BoardTaskActivityRecordBuilder { + buildForTask(args: { + teamName: string; + targetTask: TeamTask; + tasks: TeamTask[]; + messages: RawTaskActivityMessage[]; + }): BoardTaskActivityRecord[] { + const lookup = buildTaskLookup(args.tasks); + const records: BoardTaskActivityRecord[] = []; + const seenIds = new Set(); + + for (const message of args.messages) { + const actionMap = buildActionMap(message.boardTaskToolActions); + + for (const link of message.boardTaskLinks) { + const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup); + if ( + resolvedTask.taskRef?.taskId !== args.targetTask.id && + !locatorCouldMatchTask(link.task, args.targetTask, lookup) + ) { + continue; + } + + const action = + link.linkKind === 'execution' || !link.toolUseId + ? undefined + : actionMap.get(link.toolUseId); + const peerTask = resolvePeerTask( + args.teamName, + link, + message.boardTaskLinks, + args.targetTask, + lookup + ); + const record: BoardTaskActivityRecord = { + id: [ + message.uuid, + link.toolUseId ?? 'ambient', + link.task.ref, + link.targetRole, + link.linkKind, + ].join(':'), + timestamp: message.timestamp, + task: resolvedTask, + linkKind: link.linkKind, + targetRole: link.targetRole, + actor: resolveActivityActor(message), + actorContext: buildActorContext(args.teamName, link.actorContext, lookup), + ...(action ? { action: buildAction({ action, link, peerTask }) } : {}), + source: { + messageUuid: message.uuid, + filePath: message.filePath, + ...(link.toolUseId ? { toolUseId: link.toolUseId } : {}), + sourceOrder: message.sourceOrder, + }, + }; + + if (seenIds.has(record.id)) { + continue; + } + seenIds.add(record.id); + records.push(record); + } + } + + return records.sort(compareRecords); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts new file mode 100644 index 00000000..7af3dc1b --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts @@ -0,0 +1,38 @@ +import { TeamTaskReader } from '../../TeamTaskReader'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; + +import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityTranscriptReader } from './BoardTaskActivityTranscriptReader'; + +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; + +export class BoardTaskActivityRecordSource { + constructor( + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(), + private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder() + ) {} + + async getTaskRecords(teamName: string, taskId: string): Promise { + const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.listTranscriptFiles(teamName), + ]); + + const tasks = [...activeTasks, ...deletedTasks]; + const targetTask = tasks.find((task) => task.id === taskId); + if (!targetTask || transcriptFiles.length === 0) { + return []; + } + + const messages = await this.transcriptReader.readFiles(transcriptFiles); + return this.recordBuilder.buildForTask({ + teamName, + targetTask, + tasks, + messages, + }); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts new file mode 100644 index 00000000..47f6b5d9 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts @@ -0,0 +1,21 @@ +import { BoardTaskActivityEntryBuilder } from './BoardTaskActivityEntryBuilder'; +import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource'; +import { isBoardTaskActivityReadEnabled } from './featureGates'; + +import type { BoardTaskActivityEntry } from '@shared/types'; + +export class BoardTaskActivityService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly entryBuilder: BoardTaskActivityEntryBuilder = new BoardTaskActivityEntryBuilder() + ) {} + + async getTaskActivity(teamName: string, taskId: string): Promise { + if (!isBoardTaskActivityReadEnabled()) { + return []; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + return this.entryBuilder.buildFromRecords(records); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts new file mode 100644 index 00000000..ffeeef7d --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts @@ -0,0 +1,125 @@ +import { yieldToEventLoop } from '@main/utils/asyncYield'; +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import * as fs from 'fs/promises'; +import * as readline from 'readline'; + +import { + parseBoardTaskLinks, + parseBoardTaskToolActions, + type ParsedBoardTaskLink, + type ParsedBoardTaskToolAction, +} from '../contract/BoardTaskTranscriptContract'; + +import { BoardTaskActivityParseCache } from './BoardTaskActivityParseCache'; + +const logger = createLogger('Service:BoardTaskActivityTranscriptReader'); + +export interface RawTaskActivityMessage { + filePath: string; + uuid: string; + timestamp: string; + sessionId: string; + agentId?: string; + agentName?: string; + isSidechain: boolean; + boardTaskLinks: ParsedBoardTaskLink[]; + boardTaskToolActions: ParsedBoardTaskToolAction[]; + sourceOrder: number; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +export class BoardTaskActivityTranscriptReader { + private readonly cache = new BoardTaskActivityParseCache(); + + async readFiles(filePaths: string[]): Promise { + const uniqueFilePaths = [...new Set(filePaths)].sort(); + this.cache.retainOnly(new Set(uniqueFilePaths)); + + const parsedFiles = await Promise.all( + uniqueFilePaths.map((filePath) => this.readFile(filePath)) + ); + return parsedFiles.flat(); + } + + private async readFile(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size); + if (cached) { + return cached; + } + + const inFlight = this.cache.getInFlight(filePath); + if (inFlight) { + return inFlight; + } + + const promise = this.parseFile(filePath); + this.cache.setInFlight(filePath, promise); + try { + const parsed = await promise; + this.cache.set(filePath, stat.mtimeMs, stat.size, parsed); + return parsed; + } finally { + this.cache.clearInFlight(filePath); + } + } catch (error) { + logger.debug(`Skipping unreadable task-activity transcript ${filePath}: ${String(error)}`); + this.cache.clearForPath(filePath); + return []; + } + } + + private async parseFile(filePath: string): Promise { + const results: RawTaskActivityMessage[] = []; + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let sourceOrder = 0; + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const parsed = JSON.parse(line) as unknown; + const record = asRecord(parsed); + if (!record) continue; + + const uuid = typeof record.uuid === 'string' ? record.uuid : ''; + const sessionId = typeof record.sessionId === 'string' ? record.sessionId : ''; + const timestamp = typeof record.timestamp === 'string' ? record.timestamp : ''; + if (!uuid || !sessionId || !timestamp) continue; + + const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks); + if (boardTaskLinks.length === 0) continue; + + sourceOrder += 1; + results.push({ + filePath, + uuid, + timestamp, + sessionId, + agentId: typeof record.agentId === 'string' ? record.agentId : undefined, + agentName: typeof record.agentName === 'string' ? record.agentName : undefined, + isSidechain: record.isSidechain === true, + boardTaskLinks, + boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions), + sourceOrder, + }); + } catch (error) { + logger.debug(`Skipping malformed task-activity line in ${filePath}: ${String(error)}`); + } + + if (sourceOrder > 0 && sourceOrder % 250 === 0) { + await yieldToEventLoop(); + } + } + return results; + } +} diff --git a/src/main/services/team/taskLogs/activity/featureGates.ts b/src/main/services/team/taskLogs/activity/featureGates.ts new file mode 100644 index 00000000..c8842d10 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isBoardTaskActivityReadEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_ACTIVITY_READ_ENABLED, true); +} diff --git a/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts b/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts new file mode 100644 index 00000000..a7070335 --- /dev/null +++ b/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts @@ -0,0 +1,309 @@ +import { createLogger } from '@shared/utils/logger'; + +import type { + BoardTaskActivityLinkKind, + BoardTaskActivityPhase, + BoardTaskActivityTargetRole, + BoardTaskActorRelation, + BoardTaskLocator, +} from '@shared/types'; + +const logger = createLogger('Service:BoardTaskTranscriptContract'); + +export interface ParsedBoardTaskActorContext { + relation: BoardTaskActorRelation; + activeTask?: BoardTaskLocator; + activePhase?: BoardTaskActivityPhase; + activeExecutionSeq?: number; +} + +export interface ParsedBoardTaskLink { + schemaVersion: 1; + toolUseId?: string; + task: BoardTaskLocator; + targetRole: BoardTaskActivityTargetRole; + linkKind: BoardTaskActivityLinkKind; + taskArgumentSlot?: 'taskId' | 'targetId'; + actorContext: ParsedBoardTaskActorContext; +} + +export interface ParsedBoardTaskToolAction { + schemaVersion: 1; + toolUseId: string; + canonicalToolName: string; + input?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted'; + owner?: string | null; + clarification?: 'lead' | 'user' | null; + reviewer?: string; + relationship?: 'blocked-by' | 'blocks' | 'related'; + commentId?: string; + }; + resultRefs?: { + commentId?: string; + attachmentId?: string; + filename?: string; + }; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function parseNullableOwner(value: unknown): string | null | undefined { + if (value === null) return null; + const normalized = asNonEmptyString(value); + if (!normalized) return undefined; + if (normalized === 'clear' || normalized === 'none') { + return null; + } + return normalized; +} + +function parseStatus( + value: unknown +): 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined { + const normalized = asNonEmptyString(value); + if ( + normalized === 'pending' || + normalized === 'in_progress' || + normalized === 'completed' || + normalized === 'deleted' + ) { + return normalized; + } + return undefined; +} + +function parseRelationship(value: unknown): 'blocked-by' | 'blocks' | 'related' | undefined { + const normalized = asNonEmptyString(value); + if (normalized === 'blocked-by' || normalized === 'blocks' || normalized === 'related') { + return normalized; + } + return undefined; +} + +function parseClarification(value: unknown): 'lead' | 'user' | null | undefined { + if (value === null) return null; + const normalized = asNonEmptyString(value); + if (!normalized) return undefined; + if (normalized === 'lead' || normalized === 'user') { + return normalized; + } + if (normalized === 'clear') { + return null; + } + return undefined; +} + +function noteReadDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function parseSchemaVersion(record: Record): 1 | null { + if (record.schemaVersion === 1) { + return 1; + } + if (record.version === 1) { + return 1; + } + return null; +} + +export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null { + const record = asRecord(value); + if (!record) return null; + + const ref = asNonEmptyString(record.ref); + const refKind = asNonEmptyString(record.refKind); + if (!ref || (refKind !== 'canonical' && refKind !== 'display' && refKind !== 'unknown')) { + return null; + } + + const canonicalId = asNonEmptyString(record.canonicalId); + return { + ref, + refKind, + ...(canonicalId ? { canonicalId } : {}), + }; +} + +function parseActorContext(value: unknown): ParsedBoardTaskActorContext | null { + const record = asRecord(value); + if (!record) return null; + + const relation = asNonEmptyString(record.relation); + if ( + relation !== 'same_task' && + relation !== 'other_active_task' && + relation !== 'idle' && + relation !== 'ambiguous' + ) { + return null; + } + + const activeTask = parseBoardTaskLocator(record.activeTask); + const activePhase = asNonEmptyString(record.activePhase); + const activeExecutionSeq = + typeof record.activeExecutionSeq === 'number' && Number.isFinite(record.activeExecutionSeq) + ? record.activeExecutionSeq + : undefined; + + if (relation !== 'other_active_task') { + return { relation }; + } + + return { + relation, + ...(activeTask ? { activeTask } : {}), + ...(activePhase === 'work' || activePhase === 'review' ? { activePhase } : {}), + ...(activeExecutionSeq ? { activeExecutionSeq } : {}), + }; +} + +export function parseBoardTaskLinks(value: unknown): ParsedBoardTaskLink[] { + if (!Array.isArray(value)) return []; + + const parsed: ParsedBoardTaskLink[] = []; + for (const item of value) { + const record = asRecord(item); + if (!record) { + noteReadDiagnostic('link_parse_dropped', { reason: 'not_object' }); + continue; + } + + const schemaVersion = parseSchemaVersion(record); + if (schemaVersion !== 1) { + noteReadDiagnostic('link_parse_dropped', { reason: 'unsupported_version' }); + continue; + } + + const task = parseBoardTaskLocator(record.task); + const targetRole = asNonEmptyString(record.targetRole); + const linkKind = asNonEmptyString(record.linkKind); + const actorContext = parseActorContext(record.actorContext); + const rawTaskArgumentSlot = asNonEmptyString(record.taskArgumentSlot); + const taskArgumentSlot = + rawTaskArgumentSlot === 'taskId' || rawTaskArgumentSlot === 'targetId' + ? rawTaskArgumentSlot + : undefined; + const toolUseId = asNonEmptyString(record.toolUseId); + + if (!task) { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_task' }); + continue; + } + if (!actorContext) { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_actor_context' }); + continue; + } + if (targetRole !== 'subject' && targetRole !== 'related') { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_target_role' }); + continue; + } + if (linkKind !== 'execution' && linkKind !== 'lifecycle' && linkKind !== 'board_action') { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_link_kind' }); + continue; + } + const sanitizedToolUseId = toolUseId; + const sanitizedTaskArgumentSlot = linkKind === 'execution' ? undefined : taskArgumentSlot; + + parsed.push({ + schemaVersion: 1, + task, + targetRole, + linkKind, + actorContext, + ...(sanitizedToolUseId ? { toolUseId: sanitizedToolUseId } : {}), + ...(sanitizedTaskArgumentSlot ? { taskArgumentSlot: sanitizedTaskArgumentSlot } : {}), + }); + } + + return parsed; +} + +export function parseBoardTaskToolActions(value: unknown): ParsedBoardTaskToolAction[] { + if (!Array.isArray(value)) return []; + + const parsed: ParsedBoardTaskToolAction[] = []; + for (const item of value) { + const record = asRecord(item); + if (!record) { + noteReadDiagnostic('action_parse_dropped', { reason: 'not_object' }); + continue; + } + if (parseSchemaVersion(record) !== 1) { + noteReadDiagnostic('action_parse_dropped', { reason: 'unsupported_version' }); + continue; + } + + const toolUseId = asNonEmptyString(record.toolUseId); + const canonicalToolName = asNonEmptyString(record.canonicalToolName); + if (!toolUseId || !canonicalToolName) { + noteReadDiagnostic('action_parse_dropped', { reason: 'missing_identity' }); + continue; + } + + const inputRecord = asRecord(record.input); + const resultRefsRecord = asRecord(record.resultRefs); + + parsed.push({ + schemaVersion: 1, + toolUseId, + canonicalToolName, + ...(inputRecord + ? { + input: { + ...(parseStatus(inputRecord.status) !== undefined + ? { status: parseStatus(inputRecord.status) } + : {}), + ...(parseNullableOwner(inputRecord.owner) !== undefined + ? { owner: parseNullableOwner(inputRecord.owner) } + : {}), + ...(parseClarification(inputRecord.clarification) !== undefined + ? { clarification: parseClarification(inputRecord.clarification) } + : {}), + ...(asNonEmptyString(inputRecord.reviewer) + ? { reviewer: asNonEmptyString(inputRecord.reviewer) } + : {}), + ...(parseRelationship(inputRecord.relationship) !== undefined + ? { relationship: parseRelationship(inputRecord.relationship) } + : {}), + ...(asNonEmptyString(inputRecord.commentId) + ? { commentId: asNonEmptyString(inputRecord.commentId) } + : {}), + }, + } + : {}), + ...(resultRefsRecord + ? { + resultRefs: { + ...(asNonEmptyString(resultRefsRecord.commentId) + ? { commentId: asNonEmptyString(resultRefsRecord.commentId) } + : {}), + ...(asNonEmptyString(resultRefsRecord.attachmentId) + ? { attachmentId: asNonEmptyString(resultRefsRecord.attachmentId) } + : {}), + ...(asNonEmptyString(resultRefsRecord.filename) + ? { filename: asNonEmptyString(resultRefsRecord.filename) } + : {}), + }, + } + : {}), + }); + } + + return parsed; +} diff --git a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts new file mode 100644 index 00000000..4df44881 --- /dev/null +++ b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts @@ -0,0 +1,401 @@ +import { getTaskDisplayId, taskMatchesRef } from '@shared/utils/taskIdentity'; + +import { TeamTaskReader } from '../../TeamTaskReader'; +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; +import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; +import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService'; + +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import type { ParsedMessage } from '@main/types'; +import type { TaskWorkInterval, TeamTask } from '@shared/types'; + +const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const MAX_EXAMPLES = 10; + +export interface BoardTaskLogDiagnosticExample { + timestamp: string; + filePath: string; + messageUuid: string; + toolUseId?: string; + toolName: string; + isSidechain: boolean; + agentId?: string; +} + +export interface BoardTaskLogDiagnosticsReport { + teamName: string; + requestedTaskRef: string; + task: { + taskId: string; + displayId: string; + subject: string; + status: TeamTask['status']; + owner?: string; + workIntervals: TaskWorkInterval[]; + }; + transcript: { + fileCount: number; + files: string[]; + }; + explicitRecords: { + total: number; + execution: number; + lifecycle: number; + boardAction: number; + participants: string[]; + toolNames: string[]; + }; + intervalToolResults: { + total: number; + boardMcp: number; + worker: { + total: number; + explicitLinked: number; + missingExplicit: number; + examples: BoardTaskLogDiagnosticExample[]; + }; + }; + stream: { + participants: string[]; + defaultFilter: string; + segmentCount: number; + visibleToolNames: string[]; + emptyPayloadExamples: BoardTaskLogDiagnosticExample[]; + }; + diagnosis: string[]; +} + +function normalizeRequestedTaskRef(taskRef: string): string { + return taskRef.trim().replace(/^#/, ''); +} + +function isBoardMcpToolName(toolName: string | undefined): boolean { + if (!toolName) return false; + const normalized = toolName.trim().toLowerCase(); + return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + +function isWithinWorkIntervals(timestamp: Date, intervals: TaskWorkInterval[]): boolean { + if (!Number.isFinite(timestamp.getTime())) { + return false; + } + if (intervals.length === 0) { + return true; + } + + const time = timestamp.getTime(); + return intervals.some((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt) || time < startedAt) { + return false; + } + if (!interval.completedAt) { + return true; + } + const completedAt = Date.parse(interval.completedAt); + return !Number.isFinite(completedAt) || time <= completedAt; + }); +} + +function pushUnique(values: string[], value: string | undefined): void { + if (!value) return; + if (!values.includes(value)) { + values.push(value); + } +} + +function pushExample( + examples: BoardTaskLogDiagnosticExample[], + example: BoardTaskLogDiagnosticExample +): void { + if (examples.length < MAX_EXAMPLES) { + examples.push(example); + } +} + +function buildParticipantLabel(record: BoardTaskActivityRecord): string { + if (record.actor.memberName) { + return record.actor.memberName; + } + if (!record.actor.isSidechain || record.actor.role === 'lead') { + return 'lead session'; + } + if (record.actor.agentId) { + return `member ${record.actor.agentId.slice(0, 8)}`; + } + return `member session ${record.actor.sessionId.slice(0, 8)}`; +} + +function extractVisibleToolNames( + stream: Awaited> +): string[] { + const toolNames: string[] = []; + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolCall of message.toolCalls) { + pushUnique(toolNames, toolCall.name); + } + } + } + } + return toolNames; +} + +function buildStreamToolNameMap( + stream: Awaited> +): Map { + const toolNameByUseId = new Map(); + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + } + return toolNameByUseId; +} + +function isEmptyToolPayload(value: unknown): boolean { + if (value == null) return true; + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +function collectEmptyPayloadExamples( + stream: Awaited> +): BoardTaskLogDiagnosticExample[] { + const examples: BoardTaskLogDiagnosticExample[] = []; + const toolNameByUseId = buildStreamToolNameMap(stream); + + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolResult of message.toolResults) { + if (!isEmptyToolPayload(toolResult.content)) { + continue; + } + pushExample(examples, { + timestamp: message.timestamp.toISOString(), + filePath: 'stream', + messageUuid: message.uuid, + toolUseId: toolResult.toolUseId, + toolName: toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool', + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + + const toolUseResult = message.toolUseResult; + if (!toolUseResult) { + continue; + } + const toolUseId = + typeof toolUseResult.toolUseId === 'string' + ? toolUseResult.toolUseId + : message.sourceToolUseID; + const contentIsEmpty = + (!('content' in toolUseResult) || isEmptyToolPayload(toolUseResult.content)) && + (!('message' in toolUseResult) || isEmptyToolPayload(toolUseResult.message)); + if (!contentIsEmpty) { + continue; + } + + pushExample(examples, { + timestamp: message.timestamp.toISOString(), + filePath: 'stream', + messageUuid: message.uuid, + ...(toolUseId ? { toolUseId } : {}), + toolName: toolUseId ? (toolNameByUseId.get(toolUseId) ?? 'unknown tool') : 'unknown tool', + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + } + } + + return examples; +} + +function buildToolNameMap(parsedMessagesByFile: Map): Map { + const toolNameByUseId = new Map(); + for (const messages of parsedMessagesByFile.values()) { + for (const message of messages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + return toolNameByUseId; +} + +export class BoardTaskLogDiagnosticsService { + constructor( + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService() + ) {} + + async diagnose(teamName: string, taskRef: string): Promise { + const normalizedRef = normalizeRequestedTaskRef(taskRef); + const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.listTranscriptFiles(teamName), + ]); + + const tasks = [...activeTasks, ...deletedTasks]; + const task = tasks.find((candidate) => taskMatchesRef(candidate, normalizedRef)); + if (!task) { + throw new Error(`Task "${taskRef}" was not found in team "${teamName}"`); + } + + const records = await this.recordSource.getTaskRecords(teamName, task.id); + const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles); + const stream = await this.streamService.getTaskLogStream(teamName, task.id); + + const toolNameByUseId = buildToolNameMap(parsedMessagesByFile); + const explicitExecutionKeys = new Set( + records + .filter((record) => record.linkKind === 'execution') + .map((record) => `${record.source.messageUuid}:${record.source.toolUseId ?? ''}`) + ); + const workIntervals = Array.isArray(task.workIntervals) ? task.workIntervals : []; + + const explicitParticipants: string[] = []; + const explicitToolNames: string[] = []; + for (const record of records) { + pushUnique(explicitParticipants, buildParticipantLabel(record)); + pushUnique(explicitToolNames, record.action?.canonicalToolName); + } + + let intervalToolResultTotal = 0; + let boardMcpToolResultTotal = 0; + let workerToolResultTotal = 0; + let explicitLinkedWorkerResultTotal = 0; + let missingExplicitWorkerResultTotal = 0; + const missingExplicitWorkerExamples: BoardTaskLogDiagnosticExample[] = []; + + for (const [filePath, messages] of parsedMessagesByFile.entries()) { + for (const message of messages) { + if (message.type !== 'user' || message.toolResults.length === 0) { + continue; + } + if (!isWithinWorkIntervals(message.timestamp, workIntervals)) { + continue; + } + + for (const toolResult of message.toolResults) { + intervalToolResultTotal += 1; + const toolName = toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool'; + if (isBoardMcpToolName(toolName)) { + boardMcpToolResultTotal += 1; + continue; + } + + workerToolResultTotal += 1; + const explicitKey = `${message.uuid}:${toolResult.toolUseId}`; + if (explicitExecutionKeys.has(explicitKey)) { + explicitLinkedWorkerResultTotal += 1; + continue; + } + + missingExplicitWorkerResultTotal += 1; + pushExample(missingExplicitWorkerExamples, { + timestamp: message.timestamp.toISOString(), + filePath, + messageUuid: message.uuid, + toolUseId: toolResult.toolUseId, + toolName, + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + } + } + + const diagnosis: string[] = []; + if (transcriptFiles.length === 0) { + diagnosis.push('No transcript files were found for this team.'); + } + if (records.length === 0) { + diagnosis.push('No explicit task-linked activity records were found for this task.'); + } + if (missingExplicitWorkerResultTotal > 0) { + diagnosis.push( + `Only board MCP actions are explicit for part of this task history. Found ${missingExplicitWorkerResultTotal} worker tool result(s) inside task work intervals without boardTaskLinks, so Task Log Stream cannot safely include them.` + ); + } + if ( + missingExplicitWorkerResultTotal > 0 && + extractVisibleToolNames(stream).every((toolName) => isBoardMcpToolName(toolName)) + ) { + diagnosis.push( + 'Current stream visibility matches the data gap: the visible tools are MCP board actions, while worker tools exist in transcript but are unlinked.' + ); + } + + const emptyPayloadExamples = collectEmptyPayloadExamples(stream); + if (emptyPayloadExamples.length > 0) { + diagnosis.push( + `Found ${emptyPayloadExamples.length} tool result payload(s) with empty rendered content in the current stream. This explains empty success/output blocks.` + ); + } + if (diagnosis.length === 0) { + diagnosis.push('No obvious task-log data gap was detected by diagnostics.'); + } + + return { + teamName, + requestedTaskRef: taskRef, + task: { + taskId: task.id, + displayId: getTaskDisplayId(task), + subject: task.subject, + status: task.status, + ...(task.owner ? { owner: task.owner } : {}), + workIntervals, + }, + transcript: { + fileCount: transcriptFiles.length, + files: transcriptFiles, + }, + explicitRecords: { + total: records.length, + execution: records.filter((record) => record.linkKind === 'execution').length, + lifecycle: records.filter((record) => record.linkKind === 'lifecycle').length, + boardAction: records.filter((record) => record.linkKind === 'board_action').length, + participants: explicitParticipants, + toolNames: explicitToolNames, + }, + intervalToolResults: { + total: intervalToolResultTotal, + boardMcp: boardMcpToolResultTotal, + worker: { + total: workerToolResultTotal, + explicitLinked: explicitLinkedWorkerResultTotal, + missingExplicit: missingExplicitWorkerResultTotal, + examples: missingExplicitWorkerExamples, + }, + }, + stream: { + participants: stream.participants.map((participant) => participant.label), + defaultFilter: stream.defaultFilter, + segmentCount: stream.segments.length, + visibleToolNames: extractVisibleToolNames(stream), + emptyPayloadExamples, + }, + diagnosis, + }; + } +} diff --git a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts new file mode 100644 index 00000000..1daee3cb --- /dev/null +++ b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts @@ -0,0 +1,165 @@ +import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { TeamConfigReader } from '../../TeamConfigReader'; + +import type { TeamConfig } from '@shared/types'; + +const logger = createLogger('Service:TeamTranscriptSourceLocator'); + +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0) { + const ch = value.charCodeAt(end - 1); + if (ch === 47 || ch === 92) { + end -= 1; + continue; + } + break; + } + return end === value.length ? value : value.slice(0, end); +} + +export interface TeamTranscriptSourceContext { + projectDir: string; + projectId: string; + config: TeamConfig; + sessionIds: string[]; + transcriptFiles: string[]; +} + +export class TeamTranscriptSourceLocator { + constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + + async getContext(teamName: string): Promise { + const config = await this.configReader.getConfig(teamName); + if (!config?.projectPath) { + return null; + } + + const normalizedProjectPath = trimTrailingSlashes(config.projectPath); + let projectId = encodePath(normalizedProjectPath); + let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + + try { + const stat = await fs.stat(projectDir); + if (!stat.isDirectory()) { + throw new Error('not a directory'); + } + } catch { + const leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId.trim() + : null; + if (leadSessionId) { + try { + const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue; + const candidateDir = path.join(getProjectsBasePath(), entry.name); + try { + await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); + projectDir = candidateDir; + projectId = entry.name; + break; + } catch { + // not this project + } + } + } catch { + // best-effort fallback + } + } + } + + const sessionIds = await this.discoverSessionIds(projectDir, config); + const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds); + return { projectDir, projectId, config, sessionIds, transcriptFiles }; + } + + async listTranscriptFiles(teamName: string): Promise { + const context = await this.getContext(teamName); + return context?.transcriptFiles ?? []; + } + + private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise { + const knownSessionIds = new Set(); + if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { + knownSessionIds.add(config.leadSessionId.trim()); + } + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + if (typeof sessionId === 'string' && sessionId.trim().length > 0) { + knownSessionIds.add(sessionId.trim()); + } + } + } + + let discoveredSessionDirs: string[] = []; + try { + const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + discoveredSessionDirs = dirEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + } + + if (knownSessionIds.size === 0) { + return discoveredSessionDirs.sort(); + } + + const verifiedSessionIds: string[] = []; + for (const sessionId of knownSessionIds) { + try { + const stat = await fs.stat(path.join(projectDir, sessionId)); + if (stat.isDirectory()) { + verifiedSessionIds.push(sessionId); + } + } catch { + // ignore stale config session + } + } + + return Array.from( + new Set([...knownSessionIds, ...verifiedSessionIds, ...discoveredSessionDirs]) + ).sort(); + } + + private async listTranscriptFilesForSessions( + projectDir: string, + sessionIds: string[] + ): Promise { + const transcriptFiles = new Set(); + + for (const sessionId of sessionIds) { + const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`); + try { + const stat = await fs.stat(mainTranscript); + if (stat.isFile()) { + transcriptFiles.add(mainTranscript); + } + } catch { + // ignore missing root transcript + } + + const subagentsDir = path.join(projectDir, sessionId, 'subagents'); + try { + const dirEntries = await fs.readdir(subagentsDir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.jsonl')) continue; + if (!entry.name.startsWith('agent-')) continue; + if (entry.name.startsWith('agent-acompact')) continue; + transcriptFiles.add(path.join(subagentsDir, entry.name)); + } + } catch { + // ignore missing subagent dir + } + } + + return [...transcriptFiles].sort(); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts new file mode 100644 index 00000000..963ee246 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts @@ -0,0 +1,11 @@ +import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder'; + +import type { EnhancedChunk, ParsedMessage } from '@main/types'; + +export class BoardTaskExactLogChunkBuilder { + constructor(private readonly chunkBuilder: ChunkBuilder = new ChunkBuilder()) {} + + buildBundleChunks(messages: ParsedMessage[]): EnhancedChunk[] { + return this.chunkBuilder.buildChunks(messages, [], { includeSidechain: true }); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts new file mode 100644 index 00000000..c64f71f1 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts @@ -0,0 +1,364 @@ +import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; +import { createLogger } from '@shared/utils/logger'; + +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import type { + BoardTaskExactLogBundleCandidate, + BoardTaskExactLogDetailCandidate, +} from './BoardTaskExactLogTypes'; +import type { ContentBlock, ParsedMessage } from '@main/types'; + +const logger = createLogger('Service:BoardTaskExactLogDetailSelector'); + +interface TentativeFilteredMessage { + original: ParsedMessage; + filteredContent: ParsedMessage['content']; + matchedToolUseId?: string; +} + +function isToolAnchoredOutputMessage( + message: ParsedMessage, + toolUseId: string | undefined +): boolean { + return Boolean(toolUseId && message.sourceToolUseID === toolUseId); +} + +function noteExactDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_exact_logs.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function keepExplicitTextualBlock(block: ContentBlock): boolean { + return block.type === 'text' || block.type === 'image'; +} + +function cloneBlock(block: T): T { + if (block.type === 'tool_use') { + return { + ...block, + input: { ...(block.input ?? {}) }, + } as T; + } + + if (block.type === 'tool_result') { + return { + ...block, + content: Array.isArray(block.content) + ? block.content.map((child) => cloneBlock(child)) + : block.content, + } as T; + } + + if (block.type === 'image') { + return { + ...block, + source: { ...block.source }, + } as T; + } + + return { ...block }; +} + +function filterAssistantContent( + content: ContentBlock[], + toolUseId: string | undefined, + explicitMessageLinked: boolean +): ContentBlock[] { + const kept: ContentBlock[] = []; + + for (const block of content) { + if (block.type === 'tool_use') { + if (toolUseId && block.id === toolUseId) { + kept.push(cloneBlock(block)); + } + continue; + } + + if (block.type === 'thinking') { + continue; + } + + if (explicitMessageLinked && keepExplicitTextualBlock(block)) { + kept.push(cloneBlock(block)); + } + } + + return kept; +} + +function filterUserArrayContent( + content: ContentBlock[], + toolUseId: string | undefined, + explicitMessageLinked: boolean +): ContentBlock[] { + const kept: ContentBlock[] = []; + + for (const block of content) { + if (block.type === 'tool_result') { + if (toolUseId && block.tool_use_id === toolUseId) { + kept.push(cloneBlock(block)); + } + continue; + } + + if (explicitMessageLinked && keepExplicitTextualBlock(block)) { + kept.push(cloneBlock(block)); + } + } + + return kept; +} + +function filterMessageForCandidate(args: { + message: ParsedMessage; + candidate: BoardTaskExactLogBundleCandidate; + explicitMessageIds: Set; +}): TentativeFilteredMessage | null { + const { message, candidate, explicitMessageIds } = args; + const explicitMessageLinked = explicitMessageIds.has(message.uuid); + const toolUseId = candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined; + const anchoredOutputLinked = isToolAnchoredOutputMessage(message, toolUseId); + + if (typeof message.content === 'string') { + if (!explicitMessageLinked && !anchoredOutputLinked) { + return null; + } + return { + original: message, + filteredContent: message.content, + ...(toolUseId ? { matchedToolUseId: toolUseId } : {}), + }; + } + + let filteredBlocks: ContentBlock[] = []; + if (message.type === 'assistant') { + filteredBlocks = filterAssistantContent( + message.content, + toolUseId, + explicitMessageLinked || anchoredOutputLinked + ); + } else if (message.type === 'user') { + filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked); + } else { + filteredBlocks = explicitMessageLinked + ? message.content.filter(keepExplicitTextualBlock).map((block) => cloneBlock(block)) + : []; + } + + if (filteredBlocks.length === 0) { + return null; + } + + return { + original: message, + filteredContent: filteredBlocks, + ...(toolUseId ? { matchedToolUseId: toolUseId } : {}), + }; +} + +function rebuildParsedMessage( + message: ParsedMessage, + filteredContent: ParsedMessage['content'], + keptAssistantUuids: Set, + matchedToolUseId?: string +): ParsedMessage { + const { + toolCalls: _originalToolCalls, + toolResults: _originalToolResults, + sourceToolUseID: _originalSourceToolUseID, + sourceToolAssistantUUID: _originalSourceToolAssistantUUID, + toolUseResult: _originalToolUseResult, + ...baseMessage + } = message; + const toolCalls = extractToolCalls(filteredContent); + const toolResults = extractToolResults(filteredContent); + const singleToolResult = toolResults.length === 1 ? toolResults[0] : undefined; + const matchedToolUseResultId = + message.toolUseResult && + typeof message.toolUseResult.toolUseId === 'string' && + message.toolUseResult.toolUseId === matchedToolUseId + ? matchedToolUseId + : undefined; + const matchedSourceToolUseId = + matchedToolUseId && + (message.sourceToolUseID === matchedToolUseId || + singleToolResult?.toolUseId === matchedToolUseId || + matchedToolUseResultId === matchedToolUseId) + ? matchedToolUseId + : undefined; + const matchedSourceToolAssistantUUID = + matchedToolUseId && + message.sourceToolAssistantUUID && + keptAssistantUuids.has(message.sourceToolAssistantUUID) + ? message.sourceToolAssistantUUID + : undefined; + const toolUseResult = + matchedToolUseId && + matchedSourceToolUseId === matchedToolUseId && + singleToolResult?.toolUseId === matchedToolUseId + ? message.toolUseResult + : undefined; + + return { + ...baseMessage, + content: filteredContent, + toolCalls, + toolResults, + ...(matchedSourceToolUseId ? { sourceToolUseID: matchedSourceToolUseId } : {}), + ...(matchedSourceToolAssistantUUID + ? { sourceToolAssistantUUID: matchedSourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + }; +} + +function anchorEvidenceRank(message: ParsedMessage, toolUseId: string | undefined): number { + if (message.type !== 'assistant' || !toolUseId) { + return 0; + } + + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === 'tool_use' && block.id === toolUseId) { + return 2; + } + } + } + + return message.sourceToolUseID === toolUseId ? 1 : 0; +} + +function deduplicateAssistantMessagesByRequestId( + messages: ParsedMessage[], + toolUseId: string | undefined +): ParsedMessage[] { + const preferredAssistantIndexByRequestId = new Map(); + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (message.type === 'assistant' && message.requestId) { + const existingIndex = preferredAssistantIndexByRequestId.get(message.requestId); + if (existingIndex === undefined) { + preferredAssistantIndexByRequestId.set(message.requestId, i); + continue; + } + + const existingRank = anchorEvidenceRank(messages[existingIndex], toolUseId); + const nextRank = anchorEvidenceRank(message, toolUseId); + if (nextRank > existingRank || (nextRank === existingRank && i > existingIndex)) { + preferredAssistantIndexByRequestId.set(message.requestId, i); + } + } + } + + if (preferredAssistantIndexByRequestId.size === 0) { + return messages; + } + + return messages.filter((message, index) => { + if (message.type !== 'assistant' || !message.requestId) { + return true; + } + return preferredAssistantIndexByRequestId.get(message.requestId) === index; + }); +} + +function sanitizeSourceAssistantLinks(messages: ParsedMessage[]): ParsedMessage[] { + const keptAssistantUuids = new Set( + messages.filter((message) => message.type === 'assistant').map((message) => message.uuid) + ); + + return messages.map((message) => { + if ( + !message.sourceToolAssistantUUID || + keptAssistantUuids.has(message.sourceToolAssistantUUID) + ) { + return message; + } + + const { sourceToolAssistantUUID: _ignored, ...rest } = message; + return rest; + }); +} + +export class BoardTaskExactLogDetailSelector { + selectDetail(args: { + candidate: BoardTaskExactLogBundleCandidate; + records: BoardTaskActivityRecord[]; + parsedMessagesByFile: Map; + }): BoardTaskExactLogDetailCandidate | null { + const { candidate, records, parsedMessagesByFile } = args; + const relevantRecords = records.filter((record) => + candidate.records.some((row) => row.id === record.id) + ); + if (relevantRecords.length === 0) { + noteExactDiagnostic('missing_records_for_detail', { id: candidate.id }); + return null; + } + + const parsedMessages = parsedMessagesByFile.get(candidate.source.filePath); + if (!parsedMessages || parsedMessages.length === 0) { + noteExactDiagnostic('missing_parsed_messages', { filePath: candidate.source.filePath }); + return null; + } + + const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid)); + const tentative: TentativeFilteredMessage[] = []; + + for (const message of parsedMessages) { + const filtered = filterMessageForCandidate({ + message, + candidate, + explicitMessageIds, + }); + if (filtered) { + tentative.push(filtered); + } + } + + if (tentative.length === 0) { + noteExactDiagnostic('empty_filtered_bundle', { id: candidate.id }); + return null; + } + + const keptAssistantUuids = new Set( + tentative + .filter((entry) => entry.original.type === 'assistant') + .map((entry) => entry.original.uuid) + ); + + const rebuilt = tentative.map((entry) => + rebuildParsedMessage( + entry.original, + entry.filteredContent, + keptAssistantUuids, + entry.matchedToolUseId + ) + ); + + const deduped = deduplicateAssistantMessagesByRequestId( + rebuilt, + candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined + ); + const sanitized = sanitizeSourceAssistantLinks(deduped); + if (sanitized.length === 0) { + noteExactDiagnostic('empty_deduped_bundle', { id: candidate.id }); + return null; + } + + return { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records: candidate.records, + filteredMessages: sanitized, + }; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts new file mode 100644 index 00000000..469b09bc --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts @@ -0,0 +1,77 @@ +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; + +import { BoardTaskExactLogChunkBuilder } from './BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogDetailSelector } from './BoardTaskExactLogDetailSelector'; +import { BoardTaskExactLogStrictParser } from './BoardTaskExactLogStrictParser'; +import { BoardTaskExactLogSummarySelector } from './BoardTaskExactLogSummarySelector'; +import { isBoardTaskExactLogsReadEnabled } from './featureGates'; +import { getBoardTaskExactLogFileVersions } from './fileVersions'; + +import type { BoardTaskExactLogDetailResult } from '@shared/types'; + +export class BoardTaskExactLogDetailService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + ) {} + + async getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return { status: 'missing' }; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return { status: 'missing' }; + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const candidate = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .find((item) => item.id === exactLogId); + + if (!candidate) { + return { status: 'missing' }; + } + if (!candidate.canLoadDetail) { + return { status: 'missing' }; + } + if (candidate.sourceGeneration !== expectedSourceGeneration) { + return { status: 'stale' }; + } + + const parsedMessagesByFile = await this.strictParser.parseFiles([candidate.source.filePath]); + const detailCandidate = this.detailSelector.selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + + if (!detailCandidate) { + return { status: 'missing' }; + } + + const chunks = this.chunkBuilder.buildBundleChunks(detailCandidate.filteredMessages); + return { + status: 'ok', + detail: { + id: detailCandidate.id, + chunks, + }, + }; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts new file mode 100644 index 00000000..d52274bf --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts @@ -0,0 +1,105 @@ +import { yieldToEventLoop } from '@main/utils/asyncYield'; +import { parseJsonlLine } from '@main/utils/jsonl'; +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import * as fs from 'fs/promises'; +import * as readline from 'readline'; + +import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache'; + +import type { ParsedMessage } from '@main/types'; + +const logger = createLogger('Service:BoardTaskExactLogStrictParser'); + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function hasStrictTimestamp(record: Record): boolean { + if (typeof record.timestamp !== 'string' || record.timestamp.trim().length === 0) { + return false; + } + return Number.isFinite(Date.parse(record.timestamp)); +} + +export class BoardTaskExactLogStrictParser { + constructor( + private readonly cache: BoardTaskExactLogsParseCache = new BoardTaskExactLogsParseCache() + ) {} + + async parseFiles(filePaths: string[]): Promise> { + const uniquePaths = [...new Set(filePaths)].sort(); + this.cache.retainOnly(new Set(uniquePaths)); + + const results = await Promise.all( + uniquePaths.map(async (filePath) => [filePath, await this.parseFile(filePath)] as const) + ); + + return new Map(results); + } + + private async parseFile(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size); + if (cached) { + return cached; + } + + const inFlight = this.cache.getInFlight(filePath); + if (inFlight) { + return inFlight; + } + + const promise = this.readStrictFile(filePath); + this.cache.setInFlight(filePath, promise); + try { + const parsed = await promise; + this.cache.set(filePath, stat.mtimeMs, stat.size, parsed); + return parsed; + } finally { + this.cache.clearInFlight(filePath); + } + } catch (error) { + logger.debug(`Skipping unreadable exact-log transcript ${filePath}: ${String(error)}`); + this.cache.clearForPath(filePath); + return []; + } + } + + private async readStrictFile(filePath: string): Promise { + const results: ParsedMessage[] = []; + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let lineCount = 0; + for await (const line of rl) { + if (!line.trim()) continue; + lineCount += 1; + + try { + const raw = JSON.parse(line) as unknown; + const record = asRecord(raw); + if (!record || !hasStrictTimestamp(record)) { + continue; + } + + const parsed = parseJsonlLine(line); + if (parsed) { + results.push(parsed); + } + } catch (error) { + logger.debug(`Skipping malformed exact-log line in ${filePath}: ${String(error)}`); + } + + if (lineCount % 250 === 0) { + await yieldToEventLoop(); + } + } + + return results; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts new file mode 100644 index 00000000..99950cec --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts @@ -0,0 +1,226 @@ +import { describeBoardTaskActivityLabel } from '@shared/utils/boardTaskActivityLabels'; +import { createLogger } from '@shared/utils/logger'; +import { createHash } from 'crypto'; + +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import type { + BoardTaskExactLogAnchor, + BoardTaskExactLogBundleCandidate, + BoardTaskExactLogFileVersion, +} from './BoardTaskExactLogTypes'; + +const logger = createLogger('Service:BoardTaskExactLogSummarySelector'); + +function noteExactDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_exact_logs.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function compareCandidateTimestamps( + left: BoardTaskActivityRecord, + right: BoardTaskActivityRecord +): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + return left.id.localeCompare(right.id); +} + +function buildMessageGroupKey(record: BoardTaskActivityRecord): string { + return `${record.source.filePath}:${record.source.messageUuid}`; +} + +function buildToolAnchor( + filePath: string, + messageUuid: string, + toolUseId: string +): BoardTaskExactLogAnchor { + return { + kind: 'tool', + filePath, + messageUuid, + toolUseId, + }; +} + +function buildMessageAnchor(filePath: string, messageUuid: string): BoardTaskExactLogAnchor { + return { + kind: 'message', + filePath, + messageUuid, + }; +} + +function anchorId(anchor: BoardTaskExactLogAnchor): string { + return anchor.kind === 'tool' + ? `tool:${anchor.filePath}:${anchor.toolUseId ?? ''}` + : `message:${anchor.filePath}:${anchor.messageUuid}`; +} + +function sourceGenerationFor( + anchor: BoardTaskExactLogAnchor, + version: BoardTaskExactLogFileVersion | undefined +): string | null { + if (!version) return null; + const hash = createHash('sha256'); + hash.update(anchor.filePath); + hash.update('\0'); + hash.update(String(version.size)); + hash.update('\0'); + hash.update(String(version.mtimeMs)); + return hash.digest('hex'); +} + +function chooseSummaryRecord( + records: BoardTaskActivityRecord[], + anchor: BoardTaskExactLogAnchor +): BoardTaskActivityRecord | null { + if (records.length === 0) { + return null; + } + + const anchoredRecords = + anchor.kind === 'tool' && anchor.toolUseId + ? records.filter( + (record) => + record.source.toolUseId === anchor.toolUseId || + record.action?.toolUseId === anchor.toolUseId + ) + : records; + const candidates = anchoredRecords.length > 0 ? anchoredRecords : records; + + return ( + candidates.find((record) => record.action?.canonicalToolName) ?? + candidates.find((record) => record.linkKind !== 'execution' && record.action) ?? + candidates[0] ?? + null + ); +} + +export class BoardTaskExactLogSummarySelector { + selectSummaries(args: { + records: BoardTaskActivityRecord[]; + fileVersionsByPath: Map; + }): BoardTaskExactLogBundleCandidate[] { + const byMessage = new Map(); + for (const record of args.records) { + const key = buildMessageGroupKey(record); + const bucket = byMessage.get(key) ?? []; + bucket.push(record); + byMessage.set(key, bucket); + } + + const groups = new Map< + string, + { anchor: BoardTaskExactLogAnchor; records: BoardTaskActivityRecord[] } + >(); + + for (const messageRecords of byMessage.values()) { + const sortedMessageRecords = [...messageRecords].sort(compareCandidateTimestamps); + const toolUseIds = [ + ...new Set(sortedMessageRecords.map((record) => record.source.toolUseId).filter(Boolean)), + ] as string[]; + const singleToolUseId = toolUseIds.length === 1 ? toolUseIds[0] : null; + + for (const record of sortedMessageRecords) { + let anchor: BoardTaskExactLogAnchor; + if (record.source.toolUseId) { + anchor = buildToolAnchor( + record.source.filePath, + record.source.messageUuid, + record.source.toolUseId + ); + } else if (singleToolUseId) { + anchor = buildToolAnchor( + record.source.filePath, + record.source.messageUuid, + singleToolUseId + ); + } else { + anchor = buildMessageAnchor(record.source.filePath, record.source.messageUuid); + } + + const key = anchorId(anchor); + const existing = groups.get(key); + if (existing) { + existing.records.push(record); + } else { + groups.set(key, { anchor, records: [record] }); + } + } + } + + const candidates: BoardTaskExactLogBundleCandidate[] = []; + + for (const [key, group] of groups) { + const sortedRecords = [...group.records].sort(compareCandidateTimestamps); + const primaryRecord = sortedRecords[0]; + if (!primaryRecord) { + continue; + } + + const linkKinds = [...new Set(sortedRecords.map((record) => record.linkKind))]; + const targetRoles = [...new Set(sortedRecords.map((record) => record.targetRole))]; + const fileVersion = args.fileVersionsByPath.get(primaryRecord.source.filePath); + const sourceGeneration = sourceGenerationFor(group.anchor, fileVersion); + const summaryRecord = chooseSummaryRecord(sortedRecords, group.anchor) ?? primaryRecord; + const actionLabel = describeBoardTaskActivityLabel(summaryRecord); + + const baseCandidate = { + id: key, + timestamp: primaryRecord.timestamp, + actor: primaryRecord.actor, + source: { + filePath: primaryRecord.source.filePath, + messageUuid: primaryRecord.source.messageUuid, + ...(group.anchor.kind === 'tool' && group.anchor.toolUseId + ? { toolUseId: group.anchor.toolUseId } + : {}), + sourceOrder: primaryRecord.source.sourceOrder, + }, + records: sortedRecords, + anchor: group.anchor, + actionLabel, + ...(summaryRecord.action?.category + ? { actionCategory: summaryRecord.action.category } + : {}), + ...(summaryRecord.action?.canonicalToolName + ? { canonicalToolName: summaryRecord.action.canonicalToolName } + : {}), + linkKinds, + targetRoles, + }; + + if (sourceGeneration) { + candidates.push({ + ...baseCandidate, + canLoadDetail: true, + sourceGeneration, + }); + } else { + noteExactDiagnostic('non_expandable_summary', { + filePath: primaryRecord.source.filePath, + toolUseId: group.anchor.toolUseId, + }); + candidates.push({ + ...baseCandidate, + canLoadDetail: false, + }); + } + } + + return candidates; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts new file mode 100644 index 00000000..747038e2 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts @@ -0,0 +1,77 @@ +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import type { ParsedMessage } from '@main/types'; +import type { + BoardTaskActivityCategory, + BoardTaskActivityLinkKind, + BoardTaskActivityTargetRole, + BoardTaskExactLogActor, + BoardTaskExactLogSource, + BoardTaskExactLogSummary, +} from '@shared/types'; + +export interface BoardTaskExactLogFileVersion { + filePath: string; + mtimeMs: number; + size: number; +} + +export interface BoardTaskExactLogAnchor { + kind: 'tool' | 'message'; + filePath: string; + messageUuid: string; + toolUseId?: string; +} + +export type BoardTaskExactLogBundleCandidate = { + id: string; + timestamp: string; + actor: BoardTaskExactLogActor; + source: BoardTaskExactLogSource; + records: BoardTaskActivityRecord[]; + anchor: BoardTaskExactLogAnchor; + actionLabel: string; + actionCategory?: BoardTaskActivityCategory; + canonicalToolName?: string; + linkKinds: BoardTaskActivityLinkKind[]; + targetRoles: BoardTaskActivityTargetRole[]; +} & ({ canLoadDetail: true; sourceGeneration: string } | { canLoadDetail: false }); + +export interface BoardTaskExactLogDetailCandidate { + id: string; + timestamp: string; + actor: BoardTaskExactLogActor; + source: BoardTaskExactLogSource; + records: BoardTaskActivityRecord[]; + filteredMessages: ParsedMessage[]; +} + +export function mapCandidateToSummary( + candidate: BoardTaskExactLogBundleCandidate +): BoardTaskExactLogSummary { + return candidate.canLoadDetail + ? { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + anchorKind: candidate.anchor.kind, + actionLabel: candidate.actionLabel, + ...(candidate.actionCategory ? { actionCategory: candidate.actionCategory } : {}), + ...(candidate.canonicalToolName ? { canonicalToolName: candidate.canonicalToolName } : {}), + linkKinds: candidate.linkKinds, + canLoadDetail: true, + sourceGeneration: candidate.sourceGeneration, + } + : { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + anchorKind: candidate.anchor.kind, + actionLabel: candidate.actionLabel, + ...(candidate.actionCategory ? { actionCategory: candidate.actionCategory } : {}), + ...(candidate.canonicalToolName ? { canonicalToolName: candidate.canonicalToolName } : {}), + linkKinds: candidate.linkKinds, + canLoadDetail: false, + }; +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts new file mode 100644 index 00000000..439fe186 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts @@ -0,0 +1,35 @@ +import { BoardTaskActivityParseCache } from '../activity/BoardTaskActivityParseCache'; + +import type { ParsedMessage } from '@main/types'; + +export class BoardTaskExactLogsParseCache { + private readonly cache = new BoardTaskActivityParseCache(); + + getIfFresh(filePath: string, mtimeMs: number, size: number): ParsedMessage[] | null { + return this.cache.getIfFresh(filePath, mtimeMs, size); + } + + getInFlight(filePath: string): Promise | null { + return this.cache.getInFlight(filePath); + } + + setInFlight(filePath: string, promise: Promise): void { + this.cache.setInFlight(filePath, promise); + } + + clearInFlight(filePath: string): void { + this.cache.clearInFlight(filePath); + } + + set(filePath: string, mtimeMs: number, size: number, value: ParsedMessage[]): void { + this.cache.set(filePath, mtimeMs, size, value); + } + + clearForPath(filePath: string): void { + this.cache.clearForPath(filePath); + } + + retainOnly(filePaths: Set): void { + this.cache.retainOnly(filePaths); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts new file mode 100644 index 00000000..3d8f41a7 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts @@ -0,0 +1,64 @@ +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; + +import { BoardTaskExactLogSummarySelector } from './BoardTaskExactLogSummarySelector'; +import { mapCandidateToSummary } from './BoardTaskExactLogTypes'; +import { isBoardTaskExactLogsReadEnabled } from './featureGates'; +import { getBoardTaskExactLogFileVersions } from './fileVersions'; + +import type { BoardTaskExactLogSummariesResponse } from '@shared/types'; + +function compareSummaries( + left: BoardTaskExactLogSummariesResponse['items'][number], + right: BoardTaskExactLogSummariesResponse['items'][number] +): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +export class BoardTaskExactLogsService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector() + ) {} + + async getTaskExactLogSummaries( + teamName: string, + taskId: string + ): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return { items: [] }; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return { items: [] }; + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const items = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .map(mapCandidateToSummary) + .sort(compareSummaries); + + return { items }; + } +} diff --git a/src/main/services/team/taskLogs/exact/featureGates.ts b/src/main/services/team/taskLogs/exact/featureGates.ts new file mode 100644 index 00000000..f5f86270 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isBoardTaskExactLogsReadEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED, true); +} diff --git a/src/main/services/team/taskLogs/exact/fileVersions.ts b/src/main/services/team/taskLogs/exact/fileVersions.ts new file mode 100644 index 00000000..879ab4b7 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/fileVersions.ts @@ -0,0 +1,33 @@ +import * as fs from 'fs/promises'; + +import type { BoardTaskExactLogFileVersion } from './BoardTaskExactLogTypes'; + +export async function getBoardTaskExactLogFileVersions( + filePaths: Iterable +): Promise> { + const uniqueFilePaths = [...new Set(filePaths)]; + const results = await Promise.all( + uniqueFilePaths.map(async (filePath) => { + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + return null; + } + return { + filePath, + mtimeMs: stat.mtimeMs, + size: stat.size, + } satisfies BoardTaskExactLogFileVersion; + } catch { + return null; + } + }) + ); + + const byPath = new Map(); + for (const item of results) { + if (!item) continue; + byPath.set(item.filePath, item); + } + return byPath; +} diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts new file mode 100644 index 00000000..2a47c4fb --- /dev/null +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -0,0 +1,858 @@ +import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; + +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; +import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; +import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector'; +import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates'; +import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions'; + +import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; +import type { + BoardTaskActivityCategory, + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, + BoardTaskLogStreamResponse, +} from '@shared/types'; + +interface StreamSlice { + id: string; + timestamp: string; + filePath: string; + participantKey: string; + actor: BoardTaskLogActor; + actionCategory?: BoardTaskActivityCategory; + filteredMessages: ParsedMessage[]; +} + +interface MergedMessageAccumulator { + message: ParsedMessage; + content: ParsedMessage['content']; + firstSeenOrder: number; + sourceToolUseIds: Set; + sourceToolAssistantUUIDs: Set; + toolUseResults: ToolUseResultData[]; +} + +function emptyResponse(): BoardTaskLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + }; +} + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor { + return { + ...(detail.memberName ? { memberName: detail.memberName } : {}), + role: detail.role, + sessionId: detail.sessionId, + ...(detail.agentId ? { agentId: detail.agentId } : {}), + isSidechain: detail.isSidechain, + }; +} + +function buildParticipantKey(actor: BoardTaskLogActor): string { + if (actor.memberName) { + return `member:${normalizeMemberName(actor.memberName)}`; + } + if (!actor.isSidechain || actor.role === 'lead') { + return 'lead'; + } + if (actor.agentId) { + return `sidechain-agent:${actor.agentId}`; + } + return `sidechain-session:${actor.sessionId}`; +} + +function buildParticipantLabel(actor: BoardTaskLogActor): string { + if (actor.memberName) { + return actor.memberName; + } + if (!actor.isSidechain || actor.role === 'lead') { + return 'lead session'; + } + if (actor.agentId) { + return `member ${actor.agentId.slice(0, 8)}`; + } + return `member session ${actor.sessionId.slice(0, 8)}`; +} + +function buildParticipant( + actor: BoardTaskLogActor, + participantKey: string +): BoardTaskLogParticipant { + return { + key: participantKey, + label: buildParticipantLabel(actor), + role: actor.role, + isLead: participantKey === 'lead', + isSidechain: actor.isSidechain, + }; +} + +function hasNamedParticipant(actor: BoardTaskLogActor): boolean { + return typeof actor.memberName === 'string' && actor.memberName.trim().length > 0; +} + +function hasToolUseBlock( + content: ParsedMessage['content'], + toolUseId: string | undefined +): boolean { + if (!toolUseId || typeof content === 'string') { + return false; + } + + return content.some((block) => block.type === 'tool_use' && block.id === toolUseId); +} + +function looksLikeJsonPayload(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function parseJsonLikeString(value: string): unknown { + const trimmed = value.trim(); + if (!looksLikeJsonPayload(trimmed)) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function extractBoardToolOutputText( + toolName: string | undefined, + parsedPayload: unknown +): string | null { + if (!toolName || !parsedPayload || typeof parsedPayload !== 'object') { + return null; + } + + const payload = parsedPayload as Record; + if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { + const comment = payload.comment as Record | undefined; + if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { + return comment.text; + } + } + + return null; +} + +function collectTextBlockText(value: unknown): string { + if (!Array.isArray(value)) { + return ''; + } + + return value + .filter( + (child): child is Extract => + typeof child === 'object' && + child !== null && + 'type' in child && + child.type === 'text' && + 'text' in child && + typeof child.text === 'string' + ) + .map((child) => child.text) + .join('\n'); +} + +function isEmptyToolPayload(value: unknown): boolean { + if (value == null) { + return true; + } + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +function inferSingleToolUseId(message: ParsedMessage): string | undefined { + if (message.sourceToolUseID) { + return message.sourceToolUseID; + } + + if (message.toolResults.length === 1) { + return message.toolResults[0]?.toolUseId; + } + + if (!Array.isArray(message.content)) { + return undefined; + } + + const uniqueIds = new Set( + message.content + .filter( + (block): block is Extract => + block.type === 'tool_result' + ) + .map((block) => block.tool_use_id) + ); + + return uniqueIds.size === 1 ? uniqueIds.values().next().value : undefined; +} + +function sanitizeToolResultContent( + content: ContentBlock, + canonicalToolName?: string +): ContentBlock { + if (content.type !== 'tool_result') { + return cloneBlock(content); + } + + if (typeof content.content === 'string') { + const parsedPayload = parseJsonLikeString(content.content); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: [{ type: 'text', text: extractedText }], + }; + } + return parsedPayload ? { ...content, content: '' } : cloneBlock(content); + } + + if (!Array.isArray(content.content)) { + return cloneBlock(content); + } + + const jsonText = content.content + .filter((child): child is Extract => child.type === 'text') + .map((child) => child.text) + .join('\n'); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: extractedText, + }; + } + + const sanitizedChildren = content.content + .map((child) => { + if (child.type !== 'text') { + return cloneBlock(child); + } + + return looksLikeJsonPayload(child.text) ? null : cloneBlock(child); + }) + .filter((child): child is ContentBlock => child !== null); + + if (sanitizedChildren.length === 0) { + return { + ...content, + content: '', + }; + } + + return { + ...content, + content: sanitizedChildren, + }; +} + +function sanitizeJsonLikeToolResultPayloads( + messages: ParsedMessage[], + canonicalToolName?: string +): ParsedMessage[] { + return messages.map((message) => { + let nextMessage = message; + + const rawToolUseResult = message.toolUseResult as unknown; + if ( + rawToolUseResult && + typeof rawToolUseResult === 'object' && + !Array.isArray(rawToolUseResult) + ) { + const nextToolUseResult: Record & { + content?: unknown; + message?: unknown; + } = { ...(rawToolUseResult as Record) }; + let toolUseResultChanged = false; + const extractedFromContent = + typeof nextToolUseResult.content === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.content) + ) + : null; + const extractedFromMessage = + typeof nextToolUseResult.message === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.message) + ) + : null; + + if (typeof extractedFromContent === 'string') { + nextToolUseResult.content = extractedFromContent; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.content === 'string' && + looksLikeJsonPayload(nextToolUseResult.content) + ) { + nextToolUseResult.content = ''; + toolUseResultChanged = true; + } + + if (typeof extractedFromMessage === 'string') { + nextToolUseResult.message = extractedFromMessage; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.message === 'string' && + looksLikeJsonPayload(nextToolUseResult.message) + ) { + nextToolUseResult.message = ''; + toolUseResultChanged = true; + } + + if (toolUseResultChanged) { + nextMessage = { + ...nextMessage, + toolUseResult: nextToolUseResult, + }; + } + } else if (Array.isArray(rawToolUseResult)) { + const toolUseId = inferSingleToolUseId(message); + const jsonText = collectTextBlockText(rawToolUseResult); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string' || parsedPayload) { + nextMessage = { + ...nextMessage, + toolUseResult: { + ...(toolUseId ? { toolUseId } : {}), + content: typeof extractedText === 'string' ? extractedText : '', + }, + }; + } + } + + if (typeof message.content === 'string') { + return nextMessage; + } + + let changed = false; + const nextContent = message.content.map((block) => { + if (block.type !== 'tool_result') { + return block; + } + + const sanitized = sanitizeToolResultContent(block, canonicalToolName); + if (JSON.stringify(sanitized) !== JSON.stringify(block)) { + changed = true; + } + return sanitized; + }); + + if (!changed) { + return nextMessage; + } + + return { + ...nextMessage, + content: nextContent, + }; + }); +} + +function hasMeaningfulToolUseResult(message: ParsedMessage): boolean { + const rawToolUseResult = message.toolUseResult as unknown; + if ( + !rawToolUseResult || + typeof rawToolUseResult !== 'object' || + Array.isArray(rawToolUseResult) + ) { + return false; + } + + const toolUseResult = rawToolUseResult as { + error?: unknown; + stderr?: unknown; + content?: unknown; + message?: unknown; + }; + if (typeof toolUseResult.error === 'string' && toolUseResult.error.trim().length > 0) { + return true; + } + if (typeof toolUseResult.stderr === 'string' && toolUseResult.stderr.trim().length > 0) { + return true; + } + if (typeof toolUseResult.content === 'string' && toolUseResult.content.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.content) && toolUseResult.content.length > 0) { + return true; + } + if (typeof toolUseResult.message === 'string' && toolUseResult.message.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.message) && toolUseResult.message.length > 0) { + return true; + } + return false; +} + +function pruneEmptyInternalToolResultMessages(messages: ParsedMessage[]): ParsedMessage[] { + return messages.filter((message) => { + if ( + message.type !== 'user' || + message.toolResults.length === 0 || + typeof message.content === 'string' + ) { + return true; + } + + const hasNonToolResultContent = message.content.some((block) => block.type !== 'tool_result'); + if (hasNonToolResultContent) { + return true; + } + + const allToolResultsEmpty = message.toolResults.every((toolResult) => + isEmptyToolPayload(toolResult.content) + ); + if (!allToolResultsEmpty) { + return true; + } + + return hasMeaningfulToolUseResult(message); + }); +} + +function pruneToolAnchoredAssistantOutputMessages( + messages: ParsedMessage[], + toolUseId: string | undefined +): ParsedMessage[] { + if (!toolUseId) { + return messages; + } + + return messages.filter((message) => { + if (message.type !== 'assistant') { + return true; + } + if (message.sourceToolUseID !== toolUseId) { + return true; + } + return hasToolUseBlock(message.content, toolUseId); + }); +} + +function filterReadOnlySlices(slices: StreamSlice[]): StreamSlice[] { + const participantHasNonRead = new Map(); + + for (const slice of slices) { + if (slice.actionCategory && slice.actionCategory !== 'read') { + participantHasNonRead.set(slice.participantKey, true); + } + } + + return slices.filter((slice) => { + const hasNonReadForParticipant = participantHasNonRead.get(slice.participantKey) === true; + if (!hasNonReadForParticipant) { + return true; + } + return slice.actionCategory !== 'read'; + }); +} + +function compareCandidates( + left: { + id: string; + timestamp: string; + source: { filePath: string; sourceOrder: number; toolUseId?: string }; + }, + right: { + id: string; + timestamp: string; + source: { filePath: string; sourceOrder: number; toolUseId?: string }; + } +): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +function blockKey(block: ContentBlock): string { + return JSON.stringify(block); +} + +function cloneBlock(block: T): T { + if (block.type === 'tool_use') { + return { + ...block, + input: { ...(block.input ?? {}) }, + } as T; + } + + if (block.type === 'tool_result') { + return { + ...block, + content: Array.isArray(block.content) + ? block.content.map((child) => cloneBlock(child)) + : block.content, + } as T; + } + + if (block.type === 'image') { + return { + ...block, + source: { ...block.source }, + } as T; + } + + return { ...block }; +} + +function cloneMessageContent(content: ParsedMessage['content']): ParsedMessage['content'] { + if (typeof content === 'string') { + return content; + } + return content.map((block) => cloneBlock(block)); +} + +function mergeMessageContent( + current: ParsedMessage['content'], + incoming: ParsedMessage['content'] +): ParsedMessage['content'] { + if (typeof current === 'string') { + return current; + } + if (typeof incoming === 'string') { + return current; + } + + const merged = current.map((block) => cloneBlock(block)); + const seen = new Set(merged.map((block) => blockKey(block))); + for (const block of incoming) { + const key = blockKey(block); + if (seen.has(key)) continue; + merged.push(cloneBlock(block)); + seen.add(key); + } + return merged; +} + +function createAccumulator( + message: ParsedMessage, + firstSeenOrder: number +): MergedMessageAccumulator { + return { + message, + content: cloneMessageContent(message.content), + firstSeenOrder, + sourceToolUseIds: new Set(message.sourceToolUseID ? [message.sourceToolUseID] : []), + sourceToolAssistantUUIDs: new Set( + message.sourceToolAssistantUUID ? [message.sourceToolAssistantUUID] : [] + ), + toolUseResults: message.toolUseResult ? [message.toolUseResult] : [], + }; +} + +function updateAccumulator(accumulator: MergedMessageAccumulator, message: ParsedMessage): void { + accumulator.content = mergeMessageContent(accumulator.content, message.content); + if (message.sourceToolUseID) { + accumulator.sourceToolUseIds.add(message.sourceToolUseID); + } + if (message.sourceToolAssistantUUID) { + accumulator.sourceToolAssistantUUIDs.add(message.sourceToolAssistantUUID); + } + if (message.toolUseResult) { + accumulator.toolUseResults.push(message.toolUseResult); + } +} + +function selectSingleValue(values: Set): string | undefined { + if (values.size !== 1) return undefined; + return values.values().next().value; +} + +function selectSingleToolUseResult(values: ToolUseResultData[]): ToolUseResultData | undefined { + if (values.length !== 1) return undefined; + return values[0]; +} + +function extractToolUseIdFromToolUseResult( + value: ToolUseResultData | undefined +): string | undefined { + if (!value || typeof value.toolUseId !== 'string') { + return undefined; + } + const trimmed = value.toolUseId.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function rebuildMergedMessage( + accumulator: MergedMessageAccumulator, + keptAssistantUuids: Set +): ParsedMessage { + const { + toolCalls: _toolCalls, + toolResults: _toolResults, + sourceToolUseID: _sourceToolUseID, + sourceToolAssistantUUID: _sourceToolAssistantUUID, + toolUseResult: _toolUseResult, + ...base + } = accumulator.message; + + const toolCalls = extractToolCalls(accumulator.content); + const toolResults = extractToolResults(accumulator.content); + const singleToolUseResult = selectSingleToolUseResult(accumulator.toolUseResults); + const derivedToolUseId = + selectSingleValue(accumulator.sourceToolUseIds) ?? + (toolResults.length === 1 ? toolResults[0]?.toolUseId : undefined) ?? + extractToolUseIdFromToolUseResult(singleToolUseResult); + const sourceToolAssistantUUID = selectSingleValue(accumulator.sourceToolAssistantUUIDs); + const preservedSourceToolAssistantUUID = + sourceToolAssistantUUID && keptAssistantUuids.has(sourceToolAssistantUUID) + ? sourceToolAssistantUUID + : undefined; + const toolUseResult = singleToolUseResult; + + return { + ...base, + content: accumulator.content, + toolCalls, + toolResults, + ...(derivedToolUseId ? { sourceToolUseID: derivedToolUseId } : {}), + ...(preservedSourceToolAssistantUUID + ? { sourceToolAssistantUUID: preservedSourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + }; +} + +function mergeMessages( + details: { filePath: string; filteredMessages: ParsedMessage[] }[] +): ParsedMessage[] { + const byMessageKey = new Map(); + let order = 0; + + for (const detail of details) { + for (const message of detail.filteredMessages) { + const key = `${detail.filePath}:${message.uuid}`; + const existing = byMessageKey.get(key); + if (existing) { + updateAccumulator(existing, message); + } else { + byMessageKey.set(key, createAccumulator(message, order)); + order += 1; + } + } + } + + const mergedAccumulators = [...byMessageKey.values()].sort( + (left, right) => left.firstSeenOrder - right.firstSeenOrder + ); + const keptAssistantUuids = new Set( + mergedAccumulators + .filter((entry) => entry.message.type === 'assistant') + .map((entry) => entry.message.uuid) + ); + + return mergedAccumulators.map((entry) => rebuildMergedMessage(entry, keptAssistantUuids)); +} + +function buildSegmentId(participantKey: string, slices: StreamSlice[]): string { + const first = slices[0]; + const last = slices[slices.length - 1]; + return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`; +} + +export class BoardTaskLogStreamService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + ) {} + + async getTaskLogStream(teamName: string, taskId: string): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return emptyResponse(); + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return emptyResponse(); + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const candidates = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .filter((candidate) => candidate.canLoadDetail) + .sort(compareCandidates); + + if (candidates.length === 0) { + return emptyResponse(); + } + + const parsedMessagesByFile = await this.strictParser.parseFiles( + candidates.map((candidate) => candidate.source.filePath) + ); + + const slices: StreamSlice[] = []; + for (const candidate of candidates) { + const detail = this.detailSelector.selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + if (!detail || detail.filteredMessages.length === 0) { + continue; + } + + const filteredMessages = + candidate.anchor.kind === 'tool' + ? pruneToolAnchoredAssistantOutputMessages( + detail.filteredMessages, + candidate.anchor.toolUseId + ) + : detail.filteredMessages; + const sanitizedMessages = sanitizeJsonLikeToolResultPayloads( + filteredMessages, + candidate.canonicalToolName + ); + const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages); + if (prunedMessages.length === 0) { + continue; + } + + const actor = toStreamActor(detail.actor); + slices.push({ + id: detail.id, + timestamp: detail.timestamp, + filePath: detail.source.filePath, + participantKey: buildParticipantKey(actor), + actor, + actionCategory: candidate.actionCategory, + filteredMessages: prunedMessages, + }); + } + + if (slices.length === 0) { + return emptyResponse(); + } + + const deNoisedSlices = filterReadOnlySlices(slices); + + const namedParticipantSlices = deNoisedSlices.filter((slice) => + hasNamedParticipant(slice.actor) + ); + const visibleSlices = + namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices; + + const participantsByKey = new Map(); + const participantOrder: string[] = []; + for (const slice of visibleSlices) { + if (participantsByKey.has(slice.participantKey)) { + continue; + } + participantsByKey.set( + slice.participantKey, + buildParticipant(slice.actor, slice.participantKey) + ); + participantOrder.push(slice.participantKey); + } + + const orderedParticipants = participantOrder + .map((key) => participantsByKey.get(key)) + .filter((participant): participant is BoardTaskLogParticipant => Boolean(participant)) + .sort((left, right) => { + if (left.isLead && !right.isLead) return 1; + if (!left.isLead && right.isLead) return -1; + return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key); + }); + + const segments: BoardTaskLogSegment[] = []; + let currentSegmentSlices: StreamSlice[] = []; + + const flushSegment = (): void => { + if (currentSegmentSlices.length === 0) return; + const participantKey = currentSegmentSlices[0].participantKey; + const actor = currentSegmentSlices[0].actor; + const mergedMessages = mergeMessages( + currentSegmentSlices.map((slice) => ({ + filePath: slice.filePath, + filteredMessages: slice.filteredMessages, + })) + ); + const cleanedMessages = pruneEmptyInternalToolResultMessages(mergedMessages); + if (cleanedMessages.length === 0) { + currentSegmentSlices = []; + return; + } + const chunks = this.chunkBuilder.buildBundleChunks(cleanedMessages); + if (chunks.length > 0) { + segments.push({ + id: buildSegmentId(participantKey, currentSegmentSlices), + participantKey, + actor, + startTimestamp: currentSegmentSlices[0].timestamp, + endTimestamp: currentSegmentSlices[currentSegmentSlices.length - 1].timestamp, + chunks, + }); + } + currentSegmentSlices = []; + }; + + for (const slice of visibleSlices) { + if ( + currentSegmentSlices.length > 0 && + currentSegmentSlices[0].participantKey !== slice.participantKey + ) { + flushSegment(); + } + currentSegmentSlices.push(slice); + } + flushSegment(); + + const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead); + const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all'; + + return { + participants: orderedParticipants, + defaultFilter, + segments, + }; + } +} diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 71a56b19..39d44938 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -18,12 +18,12 @@ import { createLogger } from '@shared/utils/logger'; +import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider'; import { getProjectsBasePath, getTodosBasePath, setClaudeBasePathOverride, } from './utils/pathDecoder'; -import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider'; import type { HttpServices } from './http'; import type { HttpServer } from './services/infrastructure/HttpServer'; diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index b9600e24..85a67e06 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -104,6 +104,8 @@ export interface Session { messageCount: number; /** Whether the session is ongoing (last AI response has no output yet) */ isOngoing?: boolean; + /** Latest main-thread model seen in the session metadata scan */ + model?: string; /** Git branch name if available */ gitBranch?: string; /** Metadata completeness level */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 59fe25d5..3a345d5d 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -34,6 +34,7 @@ import { extractToolCalls, extractToolResults } from './toolExtraction'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; import type { PhaseTokenBreakdown } from '../types/domain'; +import type { Readable } from 'stream'; const logger = createLogger('Util:jsonl'); @@ -47,6 +48,12 @@ export { checkMessagesOngoing } from './sessionStateDetection'; // Core Parsing Functions // ============================================================================= +export interface JsonlParseResult { + messages: ParsedMessage[]; + parsedLineCount: number; + consumedBytes: number; +} + /** * Parse a JSONL file line by line using streaming. * This avoids loading the entire file into memory. @@ -55,38 +62,130 @@ export async function parseJsonlFile( filePath: string, fsProvider: FileSystemProvider = defaultProvider ): Promise { - const messages: ParsedMessage[] = []; - if (!(await fsProvider.exists(filePath))) { - return messages; + return []; } - const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); + const result = await parseJsonlStream(fsProvider.createReadStream(filePath), filePath); + return result.messages; +} - let lineCount = 0; - for await (const line of rl) { - if (!line.trim()) continue; +/** + * Parse a JSONL file and return byte accounting details for incremental readers. + */ +export async function parseJsonlFileWithStats( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { + if (!(await fsProvider.exists(filePath))) { + return { messages: [], parsedLineCount: 0, consumedBytes: 0 }; + } + + return parseJsonlStream(fsProvider.createReadStream(filePath), filePath); +} + +/** + * Parse JSONL data from a readable stream while tracking how many bytes were + * safely consumed as complete lines. + */ +export async function parseJsonlStream( + stream: Readable, + filePath?: string +): Promise { + const messages: ParsedMessage[] = []; + let pending = Buffer.alloc(0); + let parsedLineCount = 0; + let consumedBytes = 0; + let completeLineCount = 0; + let malformedLineCount = 0; + let skippedNonJsonCount = 0; + + const processLine = (lineBuffer: Buffer): void => { + let effectiveBuffer = lineBuffer; + if (effectiveBuffer.length > 0 && effectiveBuffer[effectiveBuffer.length - 1] === 0x0d) { + effectiveBuffer = effectiveBuffer.subarray(0, -1); + } + + const line = effectiveBuffer.toString('utf8'); + if (!line.trim()) { + return; + } + + const normalized = normalizeJsonlLine(line); + if (!looksLikeJsonObjectLine(normalized)) { + skippedNonJsonCount += 1; + return; + } try { - const parsed = parseJsonlLine(line); + const parsed = parseJsonlLine(normalized); if (parsed) { messages.push(parsed); + parsedLineCount += 1; } - } catch (error) { - logger.error(`Error parsing line in ${filePath}:`, error); + } catch { + malformedLineCount += 1; } + }; - lineCount++; - if (lineCount % 250 === 0) { - await yieldToEventLoop(); + for await (const chunk of stream) { + const chunkBuffer = + typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk as Uint8Array); + pending = + pending.length === 0 + ? chunkBuffer + : Buffer.concat([pending, chunkBuffer], pending.length + chunkBuffer.length); + + while (true) { + const newlineIndex = pending.indexOf(0x0a); + if (newlineIndex === -1) { + break; + } + + const lineBuffer = pending.subarray(0, newlineIndex); + pending = pending.subarray(newlineIndex + 1); + consumedBytes += lineBuffer.length + 1; + completeLineCount += 1; + processLine(lineBuffer); + + if (completeLineCount % 250 === 0) { + await yieldToEventLoop(); + } } } - return messages; + if (pending.length > 0) { + try { + const trailingLine = pending.toString('utf8'); + const normalized = normalizeJsonlLine(trailingLine); + if (looksLikeJsonObjectLine(normalized)) { + const parsed = parseJsonlLine(normalized); + if (parsed) { + messages.push(parsed); + parsedLineCount += 1; + consumedBytes += pending.length; + } + } else if (normalized.length > 0) { + // Treat non-JSON tail text as a complete malformed line and advance. + consumedBytes += pending.length; + } + } catch { + // Ignore trailing partial JSON. Callers should keep their offset pinned + // until the line is completed by a future append. + } + } + + if (filePath && (malformedLineCount > 0 || skippedNonJsonCount > 0)) { + logger.debug( + `Skipped invalid JSONL lines in ${filePath} malformed=${malformedLineCount} nonJson=${skippedNonJsonCount}` + ); + } + + return { + messages, + parsedLineCount, + consumedBytes, + }; } /** @@ -94,14 +193,28 @@ export async function parseJsonlFile( * Returns null for invalid/unsupported lines. */ export function parseJsonlLine(line: string): ParsedMessage | null { - if (!line.trim()) { + const normalized = normalizeJsonlLine(line); + if (!normalized) { return null; } - const entry = JSON.parse(line) as ChatHistoryEntry; + if (!looksLikeJsonObjectLine(normalized)) { + return null; + } + + const entry = JSON.parse(normalized) as ChatHistoryEntry; return parseChatHistoryEntry(entry); } +function normalizeJsonlLine(line: string): string { + const trimmed = line.trim(); + return trimmed.charCodeAt(0) === 0xfeff ? trimmed.slice(1) : trimmed; +} + +function looksLikeJsonObjectLine(line: string): boolean { + return line.startsWith('{'); +} + // ============================================================================= // Entry Parsing // ============================================================================= @@ -367,6 +480,7 @@ export interface SessionFileMetadata { messageCount: number; isOngoing: boolean; gitBranch: string | null; + model?: string | null; /** Total context consumed (compaction-aware) */ contextConsumption?: number; /** Number of compaction events */ @@ -389,6 +503,7 @@ export async function analyzeSessionFileMetadata( messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } @@ -401,6 +516,7 @@ export async function analyzeSessionFileMetadata( messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } if (stat.size > MAX_DEEP_SCAN_BYTES) { @@ -413,6 +529,7 @@ export async function analyzeSessionFileMetadata( messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } catch { return { @@ -420,6 +537,7 @@ export async function analyzeSessionFileMetadata( messageCount: 0, isOngoing: false, gitBranch: null, + model: null, }; } } @@ -439,6 +557,7 @@ export async function analyzeSessionFileMetadata( // After a UserGroup, await the first main-thread assistant message to count the AIGroup let awaitingAIGroup = false; let gitBranch: string | null = null; + let model: string | null = null; let activityIndex = 0; let lastEndingIndex = -1; @@ -494,6 +613,10 @@ export async function analyzeSessionFileMetadata( gitBranch = entry.gitBranch; } + if (parsed.type === 'assistant' && !parsed.isSidechain && parsed.model !== '') { + model = parsed.model ?? model; + } + if (!firstUserMessage && entry.type === 'user') { const content = entry.message?.content; if (typeof content === 'string') { @@ -690,6 +813,7 @@ export async function analyzeSessionFileMetadata( messageCount, isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding, gitBranch, + model, contextConsumption, compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined, phaseBreakdown, diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 06fa12c2..99d1a0dd 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -11,12 +11,12 @@ import { parentPort } from 'node:worker_threads'; import { TeamDataService } from '@main/services/team/TeamDataService'; import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import { createLogger } from '@shared/utils/logger'; -import type { MemberLogSummary } from '@shared/types'; import type { TeamDataWorkerRequest, TeamDataWorkerResponse, } from '@main/services/team/teamDataWorkerTypes'; +import type { MemberLogSummary } from '@shared/types'; const logger = createLogger('Worker:TeamData'); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index e5517368..89c83b83 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -2,8 +2,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parentPort } from 'node:worker_threads'; -import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; +import { isLeadMember } from '@shared/utils/leadDetection'; interface ListTeamsPayload { teamsDir: string; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 45fd3dfe..6c25b52d 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -301,6 +301,21 @@ export const TEAM_GET_MEMBER_LOGS = 'team:getMemberLogs'; /** Get session logs that reference a task */ export const TEAM_GET_LOGS_FOR_TASK = 'team:getLogsForTask'; +/** Get explicit board-task activity derived from transcript metadata */ +export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity'; + +/** Get focused inline detail for one task-activity entry */ +export const TEAM_GET_TASK_ACTIVITY_DETAIL = 'team:getTaskActivityDetail'; + +/** Get one task-scoped log stream derived from explicit board-task activity */ +export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream'; + +/** Get exact task-log summaries derived from explicit board-task activity records */ +export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries'; + +/** Get one exact task-log detail bundle for renderer reuse */ +export const TEAM_GET_TASK_EXACT_LOG_DETAIL = 'team:getTaskExactLogDetail'; + /** Update team config (name, description) */ export const TEAM_UPDATE_CONFIG = 'team:updateConfig'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 55c4e3d1..79c12a8d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,12 +8,11 @@ import { API_KEYS_SAVE, API_KEYS_STORAGE_STATUS, APP_RELAUNCH, - CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_GET_PROVIDER_STATUS, + CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, - TMUX_GET_STATUS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -127,10 +126,16 @@ import { TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, + TEAM_GET_MESSAGES_PAGE, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_ACTIVITY_DETAIL, TEAM_GET_TASK_ATTACHMENT, TEAM_GET_TASK_CHANGE_PRESENCE, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, + TEAM_GET_TASK_LOG_STREAM, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -152,7 +157,6 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, - TEAM_GET_MESSAGES_PAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, @@ -180,6 +184,7 @@ import { TERMINAL_RESIZE, TERMINAL_SPAWN, TERMINAL_WRITE, + TMUX_GET_STATUS, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -228,6 +233,11 @@ import type { ApplyReviewRequest, ApplyReviewResult, AttachmentFileData, + BoardTaskActivityDetailResult, + BoardTaskActivityEntry, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, + BoardTaskLogStreamResponse, ChangeStats, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -252,6 +262,7 @@ import type { MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, + MessagesPage, NotificationTrigger, ProjectBranchChangeEvent, RejectResult, @@ -261,7 +272,6 @@ import type { ScheduleRun, SendMessageRequest, SendMessageResult, - MessagesPage, SessionsByIdsOptions, SessionsPaginationOptions, SnippetDiff, @@ -290,10 +300,10 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TmuxStatus, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, - TmuxStatus, TriggerTestResult, UpdateKanbanPatch, UpdateSchedulePatch, @@ -954,6 +964,49 @@ const electronAPI: ElectronAPI = { options ); }, + getTaskActivity: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_ACTIVITY, + teamName, + taskId + ); + }, + getTaskActivityDetail: async (teamName: string, taskId: string, activityId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_ACTIVITY_DETAIL, + teamName, + taskId, + activityId + ); + }, + getTaskLogStream: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_LOG_STREAM, + teamName, + taskId + ); + }, + getTaskExactLogSummaries: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, + teamName, + taskId + ); + }, + getTaskExactLogDetail: async ( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ) => { + return invokeIpcWithResult( + TEAM_GET_TASK_EXACT_LOG_DETAIL, + teamName, + taskId, + exactLogId, + expectedSourceGeneration + ); + }, getMemberStats: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_GET_MEMBER_STATS, teamName, memberName); }, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9469af07..fc9771c4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -37,7 +37,7 @@ export const App = (): React.JSX.Element => { return ( - + diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d07a16f4..b205a4f6 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -9,6 +9,10 @@ import type { AppConfig, AttachmentFileData, + BoardTaskActivityDetailResult, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, + BoardTaskLogStreamResponse, ClaudeMdFileInfo, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -804,6 +808,30 @@ export class HttpAPIClient implements ElectronAPI { getLogsForTask: async () => { return []; }, + getTaskActivity: async () => { + console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode'); + return []; + }, + getTaskActivityDetail: async (): Promise => { + console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode'); + return { status: 'missing' }; + }, + getTaskLogStream: async (): Promise => { + console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode'); + return { + participants: [], + defaultFilter: 'all', + segments: [], + }; + }, + getTaskExactLogSummaries: async (): Promise => { + console.warn('[HttpAPIClient] getTaskExactLogSummaries is not available in browser mode'); + return { items: [] }; + }, + getTaskExactLogDetail: async (): Promise => { + console.warn('[HttpAPIClient] getTaskExactLogDetail is not available in browser mode'); + return { status: 'missing' }; + }, getMemberStats: async () => { console.warn('[HttpAPIClient] getMemberStats is not available in browser mode'); return { diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 4adad158..7cd94409 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -19,6 +19,7 @@ import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; import { CopyButton } from '../common/CopyButton'; + import { extractTextFromReactNode } from './markdownCopyUtils'; import { createSearchContext, diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index 69bf60ae..c2aeeca4 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -20,7 +20,6 @@ import { import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers'; import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters'; @@ -37,6 +36,7 @@ import { Sigma, Terminal, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ExecutionTrace } from './ExecutionTrace'; import { MetricsPill } from './MetricsPill'; diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 69a58aaf..81ab8195 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -10,7 +10,6 @@ import { import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -19,6 +18,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { format } from 'date-fns'; import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { MarkdownViewer } from '../viewers/MarkdownViewer'; diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index c0a06fcf..38f8ec70 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { type ItemStatus } from '../BaseItem'; import { CollapsibleOutputSection } from './CollapsibleOutputSection'; -import { renderInput, renderOutput } from './renderHelpers'; +import { extractOutputText, renderInput, renderOutput } from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -19,6 +19,13 @@ interface DefaultToolViewerProps { } export const DefaultToolViewer: React.FC = ({ linkedTool, status }) => { + const hasMeaningfulOutput = + linkedTool.result && + (() => { + const text = extractOutputText(linkedTool.result.content).trim(); + return text.length > 0 && text !== '[]' && text !== '{}'; + })(); + return ( <> {/* Input Section */} @@ -39,7 +46,7 @@ export const DefaultToolViewer: React.FC = ({ linkedTool
{/* Output Section — Collapsed by default */} - {!linkedTool.isOrphaned && linkedTool.result && ( + {!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && ( {renderOutput(linkedTool.result.content)} diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx index 1298010a..940c07bf 100644 --- a/src/renderer/components/chat/markdownComponents.tsx +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -3,9 +3,9 @@ import React from 'react'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { PROSE_BODY } from '@renderer/constants/cssVariables'; +import { FileLink, isRelativeUrl } from './viewers/FileLink'; import { extractTextFromReactNode } from './markdownCopyUtils'; import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils'; -import { FileLink, isRelativeUrl } from './viewers/FileLink'; import type { Components } from 'react-markdown'; diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 980e03f6..a9b9454c 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -42,6 +42,7 @@ import { type SearchContext, } from '../searchHighlightUtils'; import { highlightLine } from '../viewers/syntaxHighlighter'; + import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index d707e24a..471c10f9 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -331,6 +331,14 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo ); } +function getApiKeyActionRequiredProviders( + providers: readonly CliProviderStatus[] +): CliProviderStatus[] { + return providers.filter( + (provider) => !provider.authenticated && provider.connection?.configuredAuthMode === 'api_key' + ); +} + function formatRuntimeLabel( cliStatus: NonNullable['cliStatus']> ): string | null { @@ -1232,6 +1240,29 @@ export const CliStatusBanner = (): React.JSX.Element | null => { !cliStatus.authStatusChecking && !cliStatus.authLoggedIn ) { + const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders(cliStatus.providers); + const hasApiKeyModeIssue = apiKeyActionRequiredProviders.length > 0; + const primaryApiKeyProvider = apiKeyActionRequiredProviders[0] ?? null; + const apiKeyMissingProviders = apiKeyActionRequiredProviders.filter( + (provider) => provider.connection?.apiKeyConfigured !== true + ); + const allApiKeyIssuesAreMissingKeys = + hasApiKeyModeIssue && apiKeyMissingProviders.length === apiKeyActionRequiredProviders.length; + const warningTitle = hasApiKeyModeIssue + ? allApiKeyIssuesAreMissingKeys + ? 'API key required' + : 'Provider action required' + : 'Not logged in'; + const warningMessage = hasApiKeyModeIssue + ? allApiKeyIssuesAreMissingKeys + ? apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider + ? `${primaryApiKeyProvider.displayName} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.` + : 'One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.' + : apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider + ? `${primaryApiKeyProvider.displayName} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.` + : 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.' + : `${cliStatus.displayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`; + return ( <> {

- Not logged in + {warningTitle}

- {cliStatus.displayName} is installed but you are not authenticated. Login is - required for team provisioning and AI features. + {warningMessage}

- - + {hasApiKeyModeIssue ? ( + + ) : ( + <> + + + + )}
- {showTroubleshoot && ( + {!hasApiKeyModeIssue && showTroubleshoot && (
; + title: string; + steps: PlatformInstallGuideStep[]; + sources: SourceLink[]; +} type BannerState = | { loading: true; status: null; error: null } @@ -14,85 +35,168 @@ type BannerState = const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; -function PlatformInstallMatrix(): React.JSX.Element { +const PLATFORM_INSTALL_GUIDES: readonly PlatformInstallGuide[] = [ + { + platform: 'darwin', + title: 'macOS', + steps: [ + { kind: 'text', value: 'Recommended: Homebrew' }, + { kind: 'code', value: 'brew install tmux' }, + { kind: 'text', value: 'Alternative: MacPorts' }, + { kind: 'code', value: 'sudo port install tmux' }, + ], + sources: [ + { label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }, + { label: 'Homebrew', url: HOMEBREW_TMUX_URL }, + { label: 'MacPorts', url: MACPORTS_TMUX_URL }, + ], + }, + { + platform: 'linux', + title: 'Linux', + steps: [ + { kind: 'text', value: 'Use your distro package manager:' }, + { kind: 'code', value: 'sudo apt install tmux' }, + { kind: 'code', value: 'sudo dnf install tmux' }, + { kind: 'code', value: 'sudo yum install tmux' }, + { kind: 'code', value: 'sudo zypper install tmux' }, + { kind: 'code', value: 'sudo pacman -S tmux' }, + ], + sources: [{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }], + }, + { + platform: 'win32', + title: 'Windows', + steps: [ + { + kind: 'text', + value: 'The tmux docs do not provide an official native Windows install command.', + }, + { kind: 'text', value: '1. Install WSL' }, + { kind: 'code', value: 'wsl --install' }, + { kind: 'text', value: '2. Inside Ubuntu or another distro' }, + { kind: 'code', value: 'sudo apt install tmux' }, + ], + sources: [ + { label: 'tmux README', url: TMUX_README_URL }, + { label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }, + { label: 'Microsoft WSL', url: MICROSOFT_WSL_INSTALL_URL }, + ], + }, +] as const; + +const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => { return ( -
+
-
- macOS -
-
-
Homebrew
- brew install tmux -
MacPorts
- port install tmux -
+ Sources
- -
-
- Linux -
-
- apt install tmux - dnf install tmux - yum install tmux - zypper install tmux - pacman -S tmux -
-
- -
-
- Windows -
-
-

В official tmux wiki нет native Windows install command.

-

- Рекомендуемый путь: WSL, затем внутри Linux-дистрибутива использовать одну из Linux - команд выше, например apt install tmux. -

-
+
+ {links.map((link) => ( + + ))}
); +}; + +function getPlatformLabel(platform: TmuxPlatform): string { + if (platform === 'darwin') return 'macOS'; + if (platform === 'linux') return 'Linux'; + if (platform === 'win32') return 'Windows'; + return 'your OS'; } +const PlatformInstallCard = ({ guide }: { guide: PlatformInstallGuide }): React.JSX.Element => { + return ( +
+
+ {guide.title} +
+
+ {guide.steps.map((step) => + step.kind === 'code' ? ( + + {step.value} + + ) : ( +
{step.value}
+ ) + )} + +
+
+ ); +}; + +const PlatformInstallMatrix = ({ platform }: { platform: TmuxPlatform }): React.JSX.Element => { + const guides = + platform === 'unknown' + ? PLATFORM_INSTALL_GUIDES + : PLATFORM_INSTALL_GUIDES.filter((guide) => guide.platform === platform); + const singleGuide = guides.length === 1; + + return ( +
+ {singleGuide && ( +
+ Detected OS: {getPlatformLabel(platform)} +
+ )} +
+ {guides.map((guide) => ( + + ))} +
+
+ ); +}; + function getPrimaryDetail(status: TmuxStatus): string { if (status.platform === 'darwin') { - return 'На macOS проще всего поставить tmux через Homebrew или MacPorts.'; + return 'On macOS, the simplest options are Homebrew or MacPorts.'; } if (status.platform === 'linux') { - return 'На Linux команда зависит от дистрибутива: apt, dnf, yum, zypper или pacman.'; + return 'On Linux, install tmux with your distro package manager.'; } if (status.platform === 'win32') { - return 'На Windows у official tmux wiki нет native installer; safest путь — WSL и установка tmux внутри Linux-дистрибутива.'; + return 'On Windows, the clearest path is WSL, then installing tmux inside your Linux distro.'; } - return 'Поставь tmux через пакетный менеджер своей ОС.'; + return 'Install tmux with your operating system package manager.'; } export const TmuxStatusBanner = (): React.JSX.Element | null => { const isElectron = useMemo(() => isElectronMode(), []); const [state, setState] = useState(INITIAL_STATE); + const loadStatus = useCallback(async () => { + return api.tmux.getStatus(); + }, []); + const fetchStatus = useCallback(async () => { setState( (prev) => @@ -104,7 +208,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => { ); try { - const status = await api.tmux.getStatus(); + const status = await loadStatus(); setState({ loading: false, status, error: null }); } catch (error) { setState({ @@ -113,14 +217,38 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => { error: error instanceof Error ? error.message : 'Failed to check tmux status', }); } - }, []); + }, [loadStatus]); useEffect(() => { if (!isElectron) { return; } - void fetchStatus(); - }, [fetchStatus, isElectron]); + + let cancelled = false; + + const loadInitialStatus = async (): Promise => { + try { + const status = await loadStatus(); + if (!cancelled) { + setState({ loading: false, status, error: null }); + } + } catch (error) { + if (!cancelled) { + setState({ + loading: false, + status: null, + error: error instanceof Error ? error.message : 'Failed to check tmux status', + }); + } + } + }; + + void loadInitialStatus(); + + return () => { + cancelled = true; + }; + }, [isElectron, loadStatus]); if (!isElectron) return null; if (state.loading && !state.status) return null; @@ -182,8 +310,8 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => { className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }} > - Persistent team agents работают стабильнее в process/tmux path. Без tmux app остаётся - на более тяжёлом in-process пути. {getPrimaryDetail(state.status)} + Persistent team agents are more reliable on the process/tmux path. Without tmux, the + app falls back to the heavier in-process path. {getPrimaryDetail(state.status)}

{state.status.error && (

@@ -208,12 +336,12 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => { style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }} > - Open guide + Official guide

- +
); }; diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index f4a9f824..951aecb0 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -7,8 +7,8 @@ import { useEffect, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ApiKeyCard } from './ApiKeyCard'; import { ApiKeyFormDialog } from './ApiKeyFormDialog'; diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index f7e7ee3b..93bdf907 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -13,8 +13,8 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { Check, Loader2, Trash2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { ExtensionOperationState } from '@shared/types/extensions'; diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 468df397..60ae4157 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -14,11 +14,11 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { formatRelativeTime } from '@renderer/utils/formatters'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SearchInput } from '../common/SearchInput'; diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 500e4e98..23721946 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -24,13 +24,13 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { getCapabilityLabel, inferCapabilities, normalizeCategory, } from '@shared/utils/extensionNormalizers'; import { ExternalLink, Loader2, Mail } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { InstallButton } from '../common/InstallButton'; import { InstallCountBadge } from '../common/InstallCountBadge'; diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 258c35ce..ab8d94e9 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -16,9 +16,9 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers'; import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SearchInput } from '../common/SearchInput'; diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index 1f4c3e25..d70238d6 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -23,8 +23,8 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; interface SkillDetailDialogProps { skillId: string | null; diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 1898f14d..f2fd8792 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -6,7 +6,6 @@ import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, ArrowUpAZ, @@ -19,6 +18,7 @@ import { Plus, Search, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SearchInput } from '../common/SearchInput'; diff --git a/src/renderer/components/layout/CustomTitleBar.tsx b/src/renderer/components/layout/CustomTitleBar.tsx index f8b38a04..7a1593dc 100644 --- a/src/renderer/components/layout/CustomTitleBar.tsx +++ b/src/renderer/components/layout/CustomTitleBar.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import { isElectronMode } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import faviconUrl from '@renderer/favicon.png'; import { useStore } from '@renderer/store'; import { Minus, Square, X } from 'lucide-react'; @@ -68,36 +69,48 @@ export const CustomTitleBar = (): React.JSX.Element | null => { {/* Window controls — no-drag so they receive clicks */}
- - - + + + + + Minimize + + + + + + {isMaximized ? 'Restore' : 'Maximize'} + + + + + + Close +
); diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index ccc70c57..cba228ba 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -1,16 +1,29 @@ /** * MoreMenu - Dropdown menu behind a "..." icon for less-frequent toolbar actions. * - * Groups: Search, Export (session-only), Analyze (session-only). + * Groups: Teams, Settings, Extensions, Search, Schedules, Export (session-only), Analyze (session-only). * Closes on outside click or Escape. */ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { triggerDownload } from '@renderer/utils/sessionExporter'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Activity, Braces, Calendar, FileText, MoreHorizontal, Search, Type } from 'lucide-react'; +import { + Activity, + Braces, + Calendar, + FileText, + MoreHorizontal, + Puzzle, + Search, + Settings, + Type, + Users, +} from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { SessionDetail } from '@renderer/types/data'; import type { Tab } from '@renderer/types/tabs'; @@ -40,9 +53,23 @@ export const MoreMenu = ({ const [hoveredId, setHoveredId] = useState(null); const containerRef = useRef(null); - const openCommandPalette = useStore((s) => s.openCommandPalette); - const openSessionReport = useStore((s) => s.openSessionReport); - const openSchedulesTab = useStore((s) => s.openSchedulesTab); + const { + openCommandPalette, + openExtensionsTab, + openSessionReport, + openSchedulesTab, + openSettingsTab, + openTeamsTab, + } = useStore( + useShallow((s) => ({ + openCommandPalette: () => s.openCommandPalette(), + openExtensionsTab: () => s.openExtensionsTab(), + openSessionReport: (tabId: string) => s.openSessionReport(tabId), + openSchedulesTab: () => s.openSchedulesTab(), + openSettingsTab: () => s.openSettingsTab(), + openTeamsTab: () => s.openTeamsTab(), + })) + ); // Close on outside click useEffect(() => { @@ -86,6 +113,33 @@ export const MoreMenu = ({ // Build menu sections const topItems: MenuItem[] = [ + { + id: 'teams', + label: 'Teams', + icon: Users, + onClick: () => { + openTeamsTab(); + setIsOpen(false); + }, + }, + { + id: 'settings', + label: 'Settings', + icon: Settings, + onClick: () => { + openSettingsTab(); + setIsOpen(false); + }, + }, + { + id: 'extensions', + label: 'Extensions', + icon: Puzzle, + onClick: () => { + openExtensionsTab(); + setIsOpen(false); + }, + }, { id: 'search', label: 'Search', @@ -171,19 +225,25 @@ export const MoreMenu = ({ return (
{/* Trigger button */} - + + + + + More actions + {/* Dropdown menu */} {isOpen && ( diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index e6674307..6fbba201 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -7,7 +7,13 @@ import { useCallback, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { + getTeamColorSet, + getThemedBadge, + getThemedBorder, + getThemedText, +} from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { nameColorSet } from '@renderer/utils/projectColor'; @@ -78,7 +84,7 @@ export const SortableTab = ({ const teamColorSet = useStore( useShallow((s) => { - if (tab.type !== 'team' || !tab.teamName) return null; + if ((tab.type !== 'team' && tab.type !== 'graph') || !tab.teamName) return null; const team = s.teamByName[tab.teamName]; const explicitColor = team?.color ?? @@ -86,10 +92,13 @@ export const SortableTab = ({ if (explicitColor) return getTeamColorSet(explicitColor); // Fallback: deterministic color derived from display name const displayName = team?.displayName ?? tab.label; - return nameColorSet(displayName); + return nameColorSet(displayName, isLight); }) ); - const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)'; + const activeBorderColor = teamColorSet + ? getThemedBorder(teamColorSet, isLight) + : 'var(--color-accent, #6366f1)'; + const inactiveTeamTextColor = teamColorSet ? getThemedText(teamColorSet, isLight) : null; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id, @@ -116,11 +125,12 @@ export const SortableTab = ({ : teamColorSet ? getThemedBadge(teamColorSet, isLight) : 'transparent', - color: - isActive || isHovered - ? 'var(--color-text)' - : teamColorSet - ? teamColorSet.text + color: isActive + ? 'var(--color-text)' + : inactiveTeamTextColor + ? inactiveTeamTextColor + : isHovered + ? 'var(--color-text)' : 'var(--color-text-muted)', outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none', outlineOffset: '-1px', @@ -202,18 +212,23 @@ export const SortableTab = ({ }} /> )} - + + + + + Close tab +
); }; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 9e079e03..2b7586be 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { isElectronMode } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; import { RefreshCw } from 'lucide-react'; @@ -293,19 +294,24 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { {/* Refresh button - show only for session tabs */} {activeTab?.type === 'session' && ( - + + + + + {`Refresh Session (${formatShortcut('R')})`} + )}
diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx index 82924d9d..b0f6f75e 100644 --- a/src/renderer/components/layout/TabBarActions.tsx +++ b/src/renderer/components/layout/TabBarActions.tsx @@ -9,7 +9,7 @@ import { useMemo, useState } from 'react'; import { isElectronMode } from '@renderer/api'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { Bell, PanelRight, Puzzle, Settings, Users } from 'lucide-react'; +import { Bell, PanelRight } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { MoreMenu } from './MoreMenu'; @@ -18,9 +18,6 @@ export const TabBarActions = (): React.JSX.Element => { const { unreadCount, openNotificationsTab, - openTeamsTab, - openExtensionsTab, - openSettingsTab, activeTabId, openTabs, tabSessionData, @@ -32,9 +29,6 @@ export const TabBarActions = (): React.JSX.Element => { useShallow((s) => ({ unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, - openTeamsTab: s.openTeamsTab, - openExtensionsTab: s.openExtensionsTab, - openSettingsTab: s.openSettingsTab, activeTabId: s.activeTabId, openTabs: s.openTabs, tabSessionData: s.tabSessionData, @@ -47,10 +41,8 @@ export const TabBarActions = (): React.JSX.Element => { // Hover states for buttons const [notificationsHover, setNotificationsHover] = useState(false); - const [teamsHover, setTeamsHover] = useState(false); - const [extensionsHover, setExtensionsHover] = useState(false); const [githubHover, setGithubHover] = useState(false); - const [settingsHover, setSettingsHover] = useState(false); + const [discordHover, setDiscordHover] = useState(false); const [expandHover, setExpandHover] = useState(false); const [updateHover, setUpdateHover] = useState(false); @@ -94,92 +86,95 @@ export const TabBarActions = (): React.JSX.Element => { )} {/* Notifications bell icon */} - - - {/* Teams icon */} - - - {/* Extensions icon */} - + + + + + Notifications + {/* GitHub link */} - + + + + window.open( + 'https://github.com/777genius/claude_agent_teams_ui', + '_blank', + 'noopener,noreferrer' + ); + }} + onMouseEnter={() => setGithubHover(true)} + onMouseLeave={() => setGithubHover(false)} + className="rounded-md p-2 transition-colors" + style={{ + color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)', + backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent', + }} + aria-label="GitHub" + > + + + + + + GitHub + - {/* More menu (Search, Export, Analyze, Schedules) */} + {/* Discord link */} + + + + + Discord + + + {/* More menu (Teams, Settings, Extensions, Search, Export, Analyze, Schedules) */} { {/* Expand sidebar — rightmost, only when collapsed */} {sidebarCollapsed && ( - + + + + + Expand sidebar + )}
); diff --git a/src/renderer/components/layout/TabBarRow.tsx b/src/renderer/components/layout/TabBarRow.tsx index 77f4d90c..7f1ef8a8 100644 --- a/src/renderer/components/layout/TabBarRow.tsx +++ b/src/renderer/components/layout/TabBarRow.tsx @@ -7,6 +7,7 @@ import { Fragment, useState } from 'react'; import { isElectronMode } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; import { Plus } from 'lucide-react'; @@ -70,22 +71,27 @@ export const TabBarRow = (): React.JSX.Element => { ))} {/* New tab button — right after last tab */} - + + + + + New tab (Dashboard) +
{/* Action buttons — right side */} diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx index e3466adf..2ba585ff 100644 --- a/src/renderer/components/layout/TeamTabSectionNav.tsx +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -29,10 +29,15 @@ export const TeamTabSectionNav = ({ const buttonRef = useRef(null); const menuRef = useRef(null); const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); - const visibleSections = SECTIONS.filter( - (section) => - messagesPanelMode !== 'sidebar' || (section.id !== 'messages' && section.id !== 'claude-logs') - ); + const visibleSections = SECTIONS.filter((section) => { + if (messagesPanelMode === 'sidebar') { + return section.id !== 'messages' && section.id !== 'claude-logs'; + } + if (messagesPanelMode === 'bottom-sheet') { + return section.id !== 'messages'; + } + return true; + }); const handleNavigate = useCallback( (sectionId: string) => { diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx index 4ddf47bf..df2e9aa0 100644 --- a/src/renderer/components/report/SessionReportTab.tsx +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -1,9 +1,9 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { computeTakeaways } from '@renderer/utils/reportAssessments'; import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; +import { useShallow } from 'zustand/react/shallow'; import { CostSection } from './sections/CostSection'; import { ErrorSection } from './sections/ErrorSection'; diff --git a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx index c7316f5b..3c90d885 100644 --- a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx @@ -1,4 +1,3 @@ -import type { CliProviderStatus } from '@shared/types'; import { Select, SelectContent, @@ -13,11 +12,13 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; -type Props = { +import type { CliProviderStatus } from '@shared/types'; + +interface Props { provider: CliProviderStatus; disabled?: boolean; onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void; -}; +} export function getOptionDisplayLabel( option: NonNullable[number], @@ -47,11 +48,11 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s return getOptionDisplayLabel(selectedOption, resolvedOption); } -export function ProviderRuntimeBackendSelector({ +export const ProviderRuntimeBackendSelector = ({ provider, disabled = false, onSelect, -}: Props): React.JSX.Element | null { +}: Props): React.JSX.Element | null => { const options = provider.availableBackends ?? []; if (options.length === 0) { return null; @@ -191,4 +192,4 @@ export function ProviderRuntimeBackendSelector({ )}
); -} +}; diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 42ecd1b9..62f80d62 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -488,12 +489,21 @@ export const ProviderRuntimeSettingsDialog = ({ const runtimeSummary = selectedProvider ? getProviderRuntimeBackendSummary(selectedProvider) : null; + const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; + const configuredAuthMode: CliProviderAuthMode | undefined = + selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined; + const connectionMethodCardOptions = selectedProvider + ? getConnectionMethodCardOptions(selectedProvider) + : null; + const showConnectionMethodCards = + connectionMethodCardOptions !== null && typeof configuredAuthMode !== 'undefined'; const managedRuntimeSummary = selectedProvider ? getProviderCurrentRuntimeSummary(selectedProvider) : null; const connectionManagedRuntime = selectedProvider ? isConnectionManagedRuntimeProvider(selectedProvider) : false; + const hideConnectionMethodMeta = showConnectionMethodCards; const canConfigureRuntime = !connectionManagedRuntime && (selectedProvider?.availableBackends?.length ?? 0) > 0; @@ -505,19 +515,14 @@ export const ProviderRuntimeSettingsDialog = ({ selectedProvider && isApiKeyProviderId(selectedProvider.providerId) && activeApiKeyFormProviderId === selectedProvider.providerId; - const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; - const configuredAuthMode: CliProviderAuthMode | undefined = - selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined; const codexApiKeyBetaEnabled = selectedProvider?.connection?.apiKeyBetaEnabled === true; - const connectionMethodCardOptions = selectedProvider - ? getConnectionMethodCardOptions(selectedProvider) - : null; + const showApiKeySection = Boolean( + apiKeyConfig && (selectedProvider?.providerId !== 'codex' || codexApiKeyBetaEnabled) + ); const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider) : null; const connectionLoading = selectedProviderLoading || connectionSaving; const connectionBusy = disabled || connectionLoading; const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving; - const showConnectionMethodCards = - connectionMethodCardOptions !== null && typeof configuredAuthMode !== 'undefined'; const connectionMethodCardsHint = selectedProvider ? getConnectionMethodCardsHint(selectedProvider) : null; @@ -531,6 +536,18 @@ export const ProviderRuntimeSettingsDialog = ({ Boolean(selectedProvider?.connection?.supportsOAuth && onRequestLogin) && configuredAuthMode !== 'api_key' && (!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth'); + let connectionStatusLabel: string | null = null; + if (selectedProvider) { + if (!hideConnectionMethodMeta && selectedProvider.authenticated) { + connectionStatusLabel = `Using ${formatProviderAuthMethodLabelForProvider( + selectedProvider.providerId, + selectedProvider.authMethod + )}`; + } else if (!hideConnectionMethodMeta) { + connectionStatusLabel = 'Not connected'; + } + } + const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime; const connectionProgressMessage = useMemo(() => { if (!connectionLoading || !selectedProvider) { @@ -783,7 +800,13 @@ export const ProviderRuntimeSettingsDialog = ({ value={provider.providerId} className="relative rounded-b-none data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:bg-[var(--color-surface)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:-bottom-px data-[state=active]:after:h-1 data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']" > - {provider.displayName} + + + {provider.displayName} + ))} @@ -791,7 +814,7 @@ export const ProviderRuntimeSettingsDialog = ({ - {selectedProvider ? ( + {showSelectedProviderSummary && selectedProvider ? (
- {managedRuntimeSummary ? ( + {managedRuntimeSummary && !hideConnectionMethodMeta ? ( {managedRuntimeSummary} @@ -987,7 +1010,7 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null}
- {configuredAuthMode ? ( + {configuredAuthMode && !hideConnectionMethodMeta ? ( ) : null} - - {selectedProvider.authenticated - ? `Using ${formatProviderAuthMethodLabelForProvider( - selectedProvider.providerId, - selectedProvider.authMethod - )}` - : 'Not connected'} - - {selectedProvider.connection?.apiKeyConfigured ? ( + {connectionStatusLabel ? ( + + {connectionStatusLabel} + + ) : null} + {selectedProvider.connection?.apiKeyConfigured && !showApiKeySection ? ( {selectedProvider.connection.apiKeySourceLabel} @@ -1039,8 +1059,7 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null}
- {apiKeyConfig && - (selectedProvider.providerId !== 'codex' || codexApiKeyBetaEnabled) ? ( + {showApiKeySection && apiKeyConfig ? (
{ const { sessions, @@ -233,8 +261,13 @@ export const DateGroupedSessions = (): React.JSX.Element => { const parentRef = useRef(null); const countRef = useRef(null); + const searchInputRef = useRef(null); const [showCountTooltip, setShowCountTooltip] = useState(false); const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedProviderIds, setSelectedProviderIds] = useState>( + () => new Set(SESSION_PROVIDER_IDS) + ); const worktreeDropdownRef = useRef(null); // Fetch project data on mount or when viewMode changes. @@ -318,6 +351,9 @@ export const DateGroupedSessions = (): React.JSX.Element => { const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]); const hasHiddenSessions = hiddenSessionIds.length > 0; + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const hasActiveProviderFilter = selectedProviderIds.size !== SESSION_PROVIDER_IDS.length; + const hasActiveSearch = normalizedSearchQuery.length > 0; // Filter out hidden sessions unless showHiddenSessions is on const visibleSessions = useMemo(() => { @@ -325,10 +361,43 @@ export const DateGroupedSessions = (): React.JSX.Element => { return sessions.filter((s) => !hiddenSet.has(s.id)); }, [sessions, hiddenSet, showHiddenSessions]); + const searchedSessions = useMemo( + () => visibleSessions.filter((session) => matchesSessionSearch(session, normalizedSearchQuery)), + [visibleSessions, normalizedSearchQuery] + ); + + const providerCounts = useMemo>(() => { + const counts: Record = { + anthropic: 0, + codex: 0, + gemini: 0, + }; + + for (const session of searchedSessions) { + const providerId = inferTeamProviderIdFromModel(session.model); + if (providerId) { + counts[providerId] += 1; + } + } + + return counts; + }, [searchedSessions]); + + const filteredSessions = useMemo(() => { + if (!hasActiveProviderFilter) { + return searchedSessions; + } + + return searchedSessions.filter((session) => { + const providerId = inferTeamProviderIdFromModel(session.model); + return providerId ? selectedProviderIds.has(providerId) : false; + }); + }, [searchedSessions, hasActiveProviderFilter, selectedProviderIds]); + // Separate pinned sessions from unpinned const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( - () => separatePinnedSessions(visibleSessions, pinnedSessionIds), - [visibleSessions, pinnedSessionIds] + () => separatePinnedSessions(filteredSessions, pinnedSessionIds), + [filteredSessions, pinnedSessionIds] ); // Group only unpinned sessions by date @@ -343,10 +412,10 @@ export const DateGroupedSessions = (): React.JSX.Element => { // Sessions sorted by context consumption (for most-context sort mode) const contextSortedSessions = useMemo(() => { if (sessionSortMode !== 'most-context') return []; - return [...visibleSessions].sort( + return [...filteredSessions].sort( (a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0) ); - }, [visibleSessions, sessionSortMode]); + }, [filteredSessions, sessionSortMode]); // Flatten sessions with date headers into virtual list items const virtualItems = useMemo((): VirtualItem[] => { @@ -647,6 +716,39 @@ export const DateGroupedSessions = (): React.JSX.Element => { )}
)} + +
+ + setSearchQuery(event.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" + /> + {searchQuery && ( + + )} + +
); @@ -733,26 +835,45 @@ export const DateGroupedSessions = (): React.JSX.Element => { ); } + if (filteredSessions.length === 0 && !sessionsHasMore) { + return ( +
+ {projectSelector} +
+
+ +

No matching sessions

+

+ {hasActiveSearch || hasActiveProviderFilter + ? 'Try another query or reset the provider filter.' + : 'This project has no matching sessions yet.'} +

+
+
+
+ ); + } + return (
{projectSelector}

{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}

{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */} setShowCountTooltip(true)} onMouseLeave={() => setShowCountTooltip(false)} > - ({sessions.length} + ({filteredSessions.length} {sessionsHasMore ? '+' : ''}) {showCountTooltip && @@ -772,8 +893,10 @@ export const DateGroupedSessions = (): React.JSX.Element => { color: 'var(--color-text-secondary)', }} > - {sessions.length} loaded so far — scroll down to load more. Context sorting only ranks - loaded sessions. + {filteredSessions.length} matching sessions loaded so far — scroll down to load more. + {sessionSortMode === 'most-context' + ? ' Context sorting only ranks loaded sessions.' + : ''}
, document.body )} @@ -898,11 +1021,11 @@ export const DateGroupedSessions = (): React.JSX.Element => { > {item.type === 'pinned-header' ? (
@@ -911,11 +1034,11 @@ export const DateGroupedSessions = (): React.JSX.Element => {
) : item.type === 'header' ? (
diff --git a/src/renderer/components/sidebar/SessionFiltersPopover.tsx b/src/renderer/components/sidebar/SessionFiltersPopover.tsx new file mode 100644 index 00000000..f0fe0290 --- /dev/null +++ b/src/renderer/components/sidebar/SessionFiltersPopover.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react'; + +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { getTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { Filter } from 'lucide-react'; + +import type { TeamProviderId } from '@shared/types'; + +export const SESSION_PROVIDER_IDS = [ + 'anthropic', + 'codex', + 'gemini', +] as const satisfies readonly TeamProviderId[]; + +interface SessionFiltersPopoverProps { + selectedProviderIds: Set; + providerCounts: Record; + onProviderIdsChange: (next: Set) => void; +} + +export const SessionFiltersPopover = ({ + selectedProviderIds, + providerCounts, + onProviderIdsChange, +}: SessionFiltersPopoverProps): React.JSX.Element => { + const activeCount = useMemo( + () => (selectedProviderIds.size === SESSION_PROVIDER_IDS.length ? 0 : 1), + [selectedProviderIds] + ); + + const toggleProvider = (providerId: TeamProviderId): void => { + const next = new Set(selectedProviderIds); + if (next.has(providerId)) { + if (next.size === 1) { + return; + } + next.delete(providerId); + } else { + next.add(providerId); + } + onProviderIdsChange(next); + }; + + const handleReset = (): void => { + onProviderIdsChange(new Set(SESSION_PROVIDER_IDS)); + }; + + return ( + + + + + + + + Filter sessions + + +
+
+

+ Provider +

+ +
+
+ {SESSION_PROVIDER_IDS.map((providerId) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index b05565b2..ab6b72a9 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -7,8 +7,11 @@ import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { useStore } from '@renderer/store'; import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser'; +import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; +import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; import { EyeOff, MessageSquare, Pin, Play, RotateCw, Users } from 'lucide-react'; @@ -131,6 +134,28 @@ const ConsumptionBadge = ({ ); }; +const SessionRuntimeBadge = ({ + model, +}: Readonly<{ + model: string | undefined; +}>): React.JSX.Element | null => { + const providerId = inferTeamProviderIdFromModel(model); + if (!providerId) { + return null; + } + + const modelLabel = getProviderScopedTeamModelLabel(providerId, model) ?? model?.trim(); + return ( + + + {modelLabel && {modelLabel}} + + ); +}; + export const SessionItem = ({ session, isActive, @@ -321,6 +346,12 @@ export const SessionItem = ({ · {formatShortTime(new Date(session.createdAt))} + {session.model && ( + <> + · + + + )} {session.contextConsumption != null && session.contextConsumption > 0 && ( <> · diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 6ede9398..5267a413 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -5,7 +5,6 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor'; @@ -13,6 +12,7 @@ import { projectLabelFromPath } from '@renderer/utils/taskGrouping'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { GlobalTask, TeamTaskStatus } from '@shared/types'; import type { LucideIcon } from 'lucide-react'; diff --git a/src/renderer/components/team/ProcessesSection.tsx b/src/renderer/components/team/ProcessesSection.tsx index 81b4cca9..2316fafc 100644 --- a/src/renderer/components/team/ProcessesSection.tsx +++ b/src/renderer/components/team/ProcessesSection.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; + import { formatDistanceToNowStrict } from 'date-fns'; import { ExternalLink, Square, Terminal } from 'lucide-react'; diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index dbb0d319..92b74f3e 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -37,6 +37,8 @@ export interface ProvisioningProgressBlockProps { tone?: 'default' | 'error'; /** Whether Live output is expanded by default */ defaultLiveOutputOpen?: boolean; + /** Whether CLI logs are expanded by default */ + defaultLogsOpen?: boolean; /** Display step index (0-3 for active steps, 4 for ready/all done, -1 for terminal) */ currentStepIndex: number; /** If set, this step index shows a red error indicator */ @@ -139,6 +141,7 @@ export const ProvisioningProgressBlock = ({ messageSeverity, tone = 'default', defaultLiveOutputOpen = true, + defaultLogsOpen, currentStepIndex, errorStepIndex, loading = false, @@ -154,7 +157,9 @@ export const ProvisioningProgressBlock = ({ className, }: ProvisioningProgressBlockProps): React.JSX.Element => { const elapsed = useElapsedTimer(startedAt, loading); - const [logsOpen, setLogsOpen] = useState(() => Boolean(cliLogsTail) && loading); + const [logsOpen, setLogsOpen] = useState( + () => defaultLogsOpen ?? (Boolean(cliLogsTail) && loading) + ); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const outputScrollRef = useRef(null); const isError = tone === 'error'; @@ -172,6 +177,13 @@ export const ProvisioningProgressBlock = ({ setLiveOutputOpen(defaultLiveOutputOpen); }, [defaultLiveOutputOpen]); + useEffect(() => { + if (defaultLogsOpen === undefined) { + return; + } + setLogsOpen(defaultLogsOpen); + }, [defaultLogsOpen]); + // On error with logs available, prioritize logs view over noisy live stream payload. useEffect(() => { if (isError && cliLogsTail) { @@ -207,7 +219,7 @@ export const ProvisioningProgressBlock = ({
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) @@ -92,13 +94,13 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; +import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; +import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { isLeadSessionMissing, shouldSuppressMissingLeadSessionFetch, } from './teamSessionFetchGuards'; -import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; -import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; @@ -736,29 +738,35 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( member, ...props }: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null { - const { leadActivity, progress, members, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry } = - useStore( - useShallow((s) => ({ - leadActivity: s.leadActivityByTeam[teamName], - progress: getCurrentProvisioningProgressForTeam(s, teamName), - members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [], - memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], - memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], - spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, - })) - ); + const { + leadActivity, + progress, + members: launchMembers, + memberSpawnStatuses, + memberSpawnSnapshot, + spawnEntry, + } = useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + progress: getCurrentProvisioningProgressForTeam(s, teamName), + members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [], + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, + })) + ); const isLaunchSettling = useMemo(() => { if (progress?.state !== 'ready') { return false; } return getLaunchJoinState( getLaunchJoinMilestonesFromMembers({ - members, + members: launchMembers, memberSpawnStatuses, memberSpawnSnapshot, }) ).hasMembersStillJoining; - }, [memberSpawnSnapshot, memberSpawnStatuses, members, progress?.state]); + }, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state]); return ( (null); const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); + const [selectedMemberView, setSelectedMemberView] = useState<{ + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } | null>(null); const [pendingRepliesByMember, setPendingRepliesByMember] = useState>({}); const [createTaskDialog, setCreateTaskDialog] = useState({ open: false, @@ -804,9 +816,40 @@ export const TeamDetailView = ({ const [editorOpen, setEditorOpen] = useState(false); const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); const pendingReplyRefreshTimerRef = useRef(null); + const handleOpenGraphTab = useCallback(() => { + const state = useStore.getState(); + const displayName = state.teamByName[teamName]?.displayName ?? teamName; + state.openTab({ + type: 'graph', + label: `${displayName} Graph`, + teamName, + }); + }, [teamName]); + const visualizeButtonStyle = useMemo( + () => + isLight + ? { + background: + 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', + borderColor: 'rgba(59,130,246,0.30)', + color: '#0f172a', + boxShadow: '0 10px 24px rgba(59,130,246,0.12)', + } + : { + background: + 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', + borderColor: 'rgba(56,189,248,0.34)', + color: 'rgba(236,253,255,0.96)', + boxShadow: '0 12px 28px rgba(8,145,178,0.22)', + }, + [isLight] + ); // Set inert on background content when editor/graph overlay is open (a11y focus trap) useEffect(() => { @@ -824,16 +867,12 @@ export const TeamDetailView = ({ const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail?.teamName === teamName) { - useStore.getState().openTab({ - type: 'graph', - label: `${teamName} Graph`, - teamName, - }); + handleOpenGraphTab(); } }; window.addEventListener('toggle-team-graph', handler); return () => window.removeEventListener('toggle-team-graph', handler); - }, [teamName]); + }, [handleOpenGraphTab, teamName]); // Listen for graph tab actions (open task, send message) useEffect(() => { @@ -852,10 +891,21 @@ export const TeamDetailView = ({ setSendDialogOpen(true); }; const onOpenProfile = (e: Event) => { - const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + const { + teamName: tn, + memberName, + initialTab, + initialActivityFilter, + } = (e as CustomEvent).detail ?? {}; if (tn !== teamName || !data) return; const member = data.members.find((m: { name: string }) => m.name === memberName); - if (member) setSelectedMember(member); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab, + initialActivityFilter, + }); + } }; const onCreateTask = (e: Event) => { const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; @@ -1159,9 +1209,12 @@ export const TeamDetailView = ({ side: 'top', }); - const toggleMessagesPanelMode = useCallback(() => { - setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar'); - }, [messagesPanelMode, setMessagesPanelMode]); + const changeMessagesPanelMode = useCallback( + (mode: TeamMessagesPanelMode) => { + setMessagesPanelMode(mode); + }, + [setMessagesPanelMode] + ); useEffect(() => { if (tabId) { @@ -1501,6 +1554,12 @@ export const TeamDetailView = ({ const handleSelectMember = useCallback((member: ResolvedTeamMember) => { setSelectedMember(member); + setSelectedMemberView(null); + }, []); + + const closeSelectedMemberDialog = useCallback(() => { + setSelectedMember(null); + setSelectedMemberView(null); }, []); const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { @@ -1603,6 +1662,7 @@ export const TeamDetailView = ({ const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); if (member) { setSelectedMember(member); + setSelectedMemberView(null); } useStore.getState().closeMemberProfile(); }, [pendingMemberProfile, membersWithLiveBranches]); @@ -1723,7 +1783,8 @@ export const TeamDetailView = ({ const sharedMessagesPanelProps = useMemo( () => ({ teamName, - onTogglePosition: toggleMessagesPanelMode, + onPositionChange: changeMessagesPanelMode, + mountPoint: messagesPanelMountPoint, members: activeMembers, tasks: data?.tasks ?? [], messages: data?.messages ?? [], @@ -1752,11 +1813,12 @@ export const TeamDetailView = ({ handleRestartTeam, handleSelectMember, handleTaskIdClick, + messagesPanelMountPoint, pendingRepliesByMember, teamName, teamSessionIds, timeWindow, - toggleMessagesPanelMode, + changeMessagesPanelMode, ] ); @@ -1925,431 +1987,769 @@ export const TeamDetailView = ({ -
-
- {headerColorSet ? ( +
+
+
+ {headerColorSet ? ( +
+ ) : null}
- ) : null} -
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} + className={cn( + 'flex items-start justify-between gap-2', + headerColorSet && 'relative z-10' + )} + > +
+
+

+ {data.config.name} +

+ {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
-
-
- {data.isAlive && ( +
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + Edit team + - Stop team + Delete team - )} - - - - - Edit team - - - - - - Delete team - +
-
- {data.config.description && ( -

- {data.config.description} -

- )} - {(data.config.projectPath || leadBranch) && ( -
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- )} - {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); - if (!history || history.length === 0) return null; - return ( -
- - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - -
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - setLaunchDialogOpen(true)} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. -
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
- - -
- } - > - -
- - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - contentClassName="overflow-x-visible" - action={ - - } - > - +
+ {data.config.projectPath && ( + + + + + + {data.config.projectPath + .replace(/\\/g, '/') + .split('/') + .filter(Boolean) + .pop() ?? data.config.projectPath} + + + + + {formatProjectPath(data.config.projectPath)} + + + + + + + + Open project in built-in editor + + + )} + {leadBranch && ( + + + {leadBranch} + + )} +
+ + + + + Open team graph + +
+ {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()} +
+ + {!data.isAlive && !isTeamProvisioning ? ( + setLaunchDialogOpen(true)} + /> + ) : null} + +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
+ +
} - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { - op: 'set_column', - column: 'approved', - }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` + > + +
+ + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ + + } + > + + } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); + }} + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` : ''; await api.teams.processSend( teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); + } catch { + // best-effort } - } catch { - // best-effort } + } catch { + // error via store } - } catch { - // error via store + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } + } + defaultOpen={false} + > + + - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } void (async () => { try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); } catch { - // error via store + // error state is handled in the store and shown in the view } })(); }} + /> + + { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + openCreateTaskDialog('', '', name); + }} + onTaskClick={(task) => { + closeSelectedMemberDialog(); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + closeSelectedMemberDialog(); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + + + !isLeadMember(m))} + isTeamAlive={data.isAlive && !isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onSaved={() => void selectTeam(teamName)} + /> + + m.name)} + existingMembers={membersWithLiveBranches} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); + } + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + + setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + { + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + } catch { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -2363,389 +2763,66 @@ export const TeamDetailView = ({ ); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - setSelectedMember(null)} - onSendMessage={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - openCreateTaskDialog('', '', name); - }} - onTaskClick={(task) => { - setSelectedMember(null); - setSelectedTask(task); - }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - setSelectedMember(null); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} - /> - - - - !isLeadMember(m))} - isTeamAlive={data.isAlive && !isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onSaved={() => void selectTeam(teamName)} - /> - - m.name)} - existingMembers={membersWithLiveBranches} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(entries: AddMemberEntry[]) => { - setAddingMemberLoading(true); - void (async () => { - try { - for (const entry of entries) { - await addMember(teamName, { - name: entry.name, - role: entry.role, - workflow: entry.workflow, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - }); + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} + onDeleteTask={handleDeleteTask} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All - team data and tasks will be deleted. - - - - - - - - - - setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} - /> - - { - void (async () => { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - } catch { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - })(); - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} - /> - - setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open + ? {} + : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })) } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open - ? {} - : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} - projectPath={data.config.projectPath} - onEditorAction={handleEditorAction} + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} + /> +
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )}
@@ -2780,11 +2857,15 @@ export const TeamDetailView = ({ const task = data.tasks.find((t) => t.id === taskId); if (task) setSelectedTask(task); }} - onOpenMemberProfile={(memberName) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); + onOpenMemberProfile={(memberName, options) => { + const member = data.members.find((m) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab: options?.initialTab, + initialActivityFilter: options?.initialActivityFilter, + }); + } }} /> diff --git a/src/renderer/components/team/TeamEmptyState.tsx b/src/renderer/components/team/TeamEmptyState.tsx index 72a3b8e6..fc02d12f 100644 --- a/src/renderer/components/team/TeamEmptyState.tsx +++ b/src/renderer/components/team/TeamEmptyState.tsx @@ -1,9 +1,9 @@ import { Button } from '@renderer/components/ui/button'; -type TeamEmptyStateProps = { +interface TeamEmptyStateProps { canCreate: boolean; onCreateTeam: () => void; -}; +} export const TeamEmptyState = ({ canCreate, diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index 302cf05e..57071f94 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -1,18 +1,6 @@ -import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { memo } from 'react'; -import { Button } from '@renderer/components/ui/button'; -import { useStore } from '@renderer/store'; -import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice'; -import { X } from 'lucide-react'; -import { useShallow } from 'zustand/react/shallow'; - -import { ProvisioningProgressBlock } from './ProvisioningProgressBlock'; -import { - DISPLAY_COMPLETE_STEP_INDEX, - getDisplayStepIndex, - getLaunchJoinMilestonesFromMembers, - getLaunchJoinState, -} from './provisioningSteps'; +import { TeamProvisioningPanel } from './TeamProvisioningPanel'; interface TeamProvisioningBannerProps { teamName: string; @@ -21,205 +9,8 @@ interface TeamProvisioningBannerProps { export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({ teamName, }: TeamProvisioningBannerProps): React.JSX.Element | null { - const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses, memberSpawnSnapshot } = - useStore( - useShallow((s) => ({ - progress: getCurrentProvisioningProgressForTeam(s, teamName), - cancelProvisioning: s.cancelProvisioning, - teamMembers: s.selectedTeamName === teamName ? s.selectedTeamData?.members : undefined, - memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], - memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], - })) - ); - const [dismissed, setDismissed] = useState(false); - const lastActiveStepRef = useRef(-1); - const bannerInstanceKey = useMemo(() => { - if (!progress) return null; - return `${teamName}:${progress.runId}:${progress.startedAt}`; - }, [teamName, progress?.runId, progress?.startedAt]); - - useEffect(() => { - setDismissed(false); - }, [bannerInstanceKey]); - - // NOTE: we intentionally do NOT auto-dismiss "ready" banners. - // Users frequently need to inspect launch output after fast stop→start cycles, - // and auto-dismiss can make it look like no progress/logs were produced. - - if (!progress || dismissed) { - return null; - } - - if (progress.state === 'cancelled' || progress.state === 'disconnected') { - return null; - } - - const isReady = progress.state === 'ready'; - const isFailed = progress.state === 'failed'; - const isActive = - progress.state === 'validating' || - progress.state === 'spawning' || - progress.state === 'configuring' || - progress.state === 'assembling' || - progress.state === 'finalizing' || - progress.state === 'verifying'; - - const canCancel = - progress.state === 'spawning' || - progress.state === 'configuring' || - progress.state === 'assembling' || - progress.state === 'finalizing' || - progress.state === 'verifying'; - - const { - expectedTeammateCount: fallbackTeammateCount, - heartbeatConfirmedCount, - processOnlyAliveCount, - pendingSpawnCount, - failedSpawnCount, - } = getLaunchJoinMilestonesFromMembers({ - members: teamMembers ?? [], - memberSpawnStatuses, - memberSpawnSnapshot, - }); - const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = - getLaunchJoinState({ - expectedTeammateCount: fallbackTeammateCount, - heartbeatConfirmedCount, - processOnlyAliveCount, - pendingSpawnCount, - failedSpawnCount, - }); - const progressStepIndex = getDisplayStepIndex({ - progress, - expectedTeammateCount: fallbackTeammateCount, - heartbeatConfirmedCount, - processOnlyAliveCount, - pendingSpawnCount, - failedSpawnCount, - }); - - // Keep the error marker aligned to the last meaningful UI milestone, not the - // raw backend phase enum. The launch flow now moves through some backend - // states too quickly for the old enum mapping to stay user-meaningful. - if (progressStepIndex >= 0 && !isFailed) { - lastActiveStepRef.current = progressStepIndex; - } - - if (isFailed) { - return ( -
-
-

{progress.message}

- -
- = 0 ? lastActiveStepRef.current : 0} - startedAt={progress.startedAt} - pid={progress.pid} - cliLogsTail={progress.cliLogsTail} - assistantOutput={progress.assistantOutput} - defaultLiveOutputOpen - onCancel={null} - /> -
- ); - } - - if (isReady) { - const joiningPhrase = - remainingJoinCount === 1 - ? '1 teammate still joining' - : `${remainingJoinCount} teammates still joining`; - const readyDetailMessage = - failedSpawnCount > 0 - ? progress.message - : fallbackTeammateCount === 0 - ? 'Team provisioned - lead online' - : allTeammatesConfirmedAlive - ? `Team provisioned - all ${fallbackTeammateCount} teammates joined` - : hasMembersStillJoining - ? joiningPhrase - : 'Team provisioned - teammates are still joining'; - const readyDetailSeverity = - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined; - const readyMessage = - failedSpawnCount > 0 - ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start` - : fallbackTeammateCount === 0 - ? 'Team launched - lead online' - : allTeammatesConfirmedAlive - ? `Team launched - all ${fallbackTeammateCount} teammates joined` - : hasMembersStillJoining - ? 'Finishing launch' - : 'Finishing launch'; - const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX; - - return ( -
- 0 || hasMembersStillJoining ? readyDetailMessage : null} - messageSeverity={readyDetailSeverity} - surface="flat" - currentStepIndex={readyStepIndex} - startedAt={progress.startedAt} - pid={progress.pid} - cliLogsTail={progress.cliLogsTail} - assistantOutput={progress.assistantOutput} - defaultLiveOutputOpen={false} - onCancel={null} - successMessage={readyMessage} - successMessageSeverity={ - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success' - } - onDismiss={() => setDismissed(true)} - /> -
- ); - } - - if (isActive) { - return ( -
- = 0 ? progressStepIndex : -1} - loading - startedAt={progress.startedAt} - pid={progress.pid} - cliLogsTail={progress.cliLogsTail} - assistantOutput={progress.assistantOutput} - defaultLiveOutputOpen - onCancel={ - canCancel - ? () => { - void cancelProvisioning(progress.runId); - } - : null - } - /> -
- ); - } - - return null; + const panel = ( + + ); + return panel; }); diff --git a/src/renderer/components/team/TeamProvisioningPanel.tsx b/src/renderer/components/team/TeamProvisioningPanel.tsx new file mode 100644 index 00000000..1e9cddb0 --- /dev/null +++ b/src/renderer/components/team/TeamProvisioningPanel.tsx @@ -0,0 +1,111 @@ +import { memo, useEffect, useRef, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { cn } from '@renderer/lib/utils'; +import { X } from 'lucide-react'; + +import { ProvisioningProgressBlock } from './ProvisioningProgressBlock'; +import { useTeamProvisioningPresentation } from './useTeamProvisioningPresentation'; + +export interface TeamProvisioningPanelProps { + teamName: string; + surface?: 'raised' | 'flat'; + dismissible?: boolean; + className?: string; + defaultLogsOpen?: boolean; +} + +export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({ + teamName, + surface = 'flat', + dismissible = false, + className, + defaultLogsOpen, +}: TeamProvisioningPanelProps): React.JSX.Element | null { + const { presentation, cancelProvisioning, runInstanceKey } = + useTeamProvisioningPresentation(teamName); + const [dismissed, setDismissed] = useState(false); + const lastActiveStepRef = useRef(-1); + + useEffect(() => { + setDismissed(false); + }, [runInstanceKey]); + + if (!presentation || dismissed) { + return null; + } + + if (presentation.currentStepIndex >= 0 && !presentation.isFailed) { + lastActiveStepRef.current = presentation.currentStepIndex; + } + + const showRunningState = presentation.isActive || presentation.hasMembersStillJoining; + + const block = ( + = 0 + ? lastActiveStepRef.current + : 0 + : undefined + } + loading={showRunningState} + startedAt={presentation.progress.startedAt} + pid={presentation.progress.pid} + cliLogsTail={presentation.progress.cliLogsTail} + assistantOutput={presentation.progress.assistantOutput} + defaultLiveOutputOpen={presentation.defaultLiveOutputOpen} + defaultLogsOpen={defaultLogsOpen} + onCancel={ + presentation.canCancel && cancelProvisioning + ? () => { + void cancelProvisioning(presentation.progress.runId); + } + : null + } + successMessage={presentation.successMessage} + successMessageSeverity={presentation.successMessageSeverity} + onDismiss={ + dismissible && presentation.isReady + ? () => { + setDismissed(true); + } + : null + } + className={!presentation.isFailed ? className : undefined} + /> + ); + + if (!presentation.isFailed) { + return block; + } + + return ( +
+
+

+ {presentation.progress.message} +

+ {dismissible ? ( + + ) : null} +
+ {block} +
+ ); +}); diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 99ebfc62..5666e93d 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -3,10 +3,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ToolApprovalSettingsContent, diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index b9c722cc..8aba0be4 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { type ReactNode, useState } from 'react'; import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; @@ -19,6 +19,7 @@ interface ActiveTasksBlockProps { tasks: TeamTaskWithKanban[]; /** Start collapsed (e.g. when rendered inside the sidebar where MemberList already shows status). */ defaultCollapsed?: boolean; + headerRight?: ReactNode; onMemberClick?: (member: ResolvedTeamMember) => void; onTaskClick?: (task: TeamTaskWithKanban) => void; } @@ -34,6 +35,7 @@ export const ActiveTasksBlock = ({ members, tasks, defaultCollapsed = false, + headerRight, onMemberClick, onTaskClick, }: ActiveTasksBlockProps): React.JSX.Element | null => { @@ -70,23 +72,26 @@ export const ActiveTasksBlock = ({ return (
- +
+ + {headerRight ?
{headerRight}
: null} +
{!collapsed && entries.map(({ member, task, taskId, kind }) => { const colors = getTeamColorSet(colorMap.get(member.name) ?? ''); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 7b524fae..f29ff996 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -17,7 +17,6 @@ import { import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { getMessageTypeLabel, getStructuredMessageSummary, @@ -70,6 +69,7 @@ import { Reply, X, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; @@ -111,7 +111,7 @@ function getCommandOutputSummary(text: string): string { function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } { const trimmed = summary.trim(); - const match = trimmed.match(/^\[to\s+([^\]]+)\]\s*(.*)$/i); + const match = /^\[to\s+([^\]]+)\]\s*(.*)$/i.exec(trimmed); if (!match) { return { recipient: null, body: trimmed }; } @@ -575,8 +575,52 @@ function renderInlineBoldSummary( }); } +const TaskRecipientBadge = ({ + taskId, + displayId, + teamName, + onTaskIdClick, +}: Readonly<{ + taskId: string; + displayId: string; + teamName?: string; + onTaskIdClick?: (taskId: string) => void; +}>): React.JSX.Element => { + const content = ( + + {displayId} + + ); + + if (!onTaskIdClick) { + return content; + } + + return ( + + + + ); +}; + export const ActivityItem = memo( - function ActivityItem({ + ({ message, teamName, localMemberNames, @@ -603,7 +647,7 @@ export const ActivityItem = memo( onExpand, expandItemKey, onExpandContent, - }: ActivityItemProps): React.JSX.Element { + }: Readonly): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const { isLight } = useTheme(); // Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication) @@ -784,6 +828,11 @@ export const ActivityItem = memo( structured, ]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); + const commentTaskRef = + message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null; + const commentTaskDisplayId = + commentTaskRef?.displayId ?? + (commentTaskRef?.taskId ? `#${commentTaskRef.taskId.slice(0, 6)}` : null); // Permission request status icon (check/x/clock) const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals)); @@ -881,6 +930,146 @@ export const ActivityItem = memo( onToggleCollapse?.(collapseToggleKey); } }, [collapseToggleKey, isHeaderClickable, onToggleCollapse]); + const useCompactCollapsedHeader = compactHeader && !isExpanded; + + const senderBadge = isSlashCommandResult ? ( + + result + + ) : ( + + ); + + const messageTypeBadge = systemLabel ? ( + + {systemLabel} + + ) : commentTaskRef ? ( + + Comment + + ) : isSlashCommandResult && message.commandOutput ? ( + + {message.commandOutput.stream} + + ) : isSlashCommandMessage ? ( + command + ) : messageType ? ( + + {messageType} + + ) : null; + + const leadSourceBadge = + message.source === 'lead_session' && !isSlashCommandResult ? ( + + session + + ) : message.source === 'lead_process' && !isSlashCommandResult ? ( + + live + + ) : null; + + const statusBadge = rateLimited ? ( + + + Rate Limited + + ) : isApiError ? ( + + + API Error + + ) : null; + + const recipientBadge = + commentTaskRef && commentTaskDisplayId ? ( + <> + + + + ) : message.to && message.to !== message.from ? ( + <> + + {crossTeamTarget ? ( + + ) : null} + {crossTeamSentMemberName || !crossTeamTarget ? ( + + ) : null} + + ) : null; + + const summaryContent = + isSlashCommandResult && message.commandOutput ? ( + + + + {message.commandOutput.commandLabel} + + + {message.summary || getCommandOutputSummary(message.text) || rawSummary} + + + ) : isSlashCommandMessage && slashCommandMeta ? ( + + + + {slashCommandMeta.command} + + {slashCommandMeta.args ? ( + + {slashCommandMeta.args.replace(/\n+/g, ' ')} + + ) : (slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? ( + + {slashCommandMeta.knownDescription ?? knownSlashCommand?.description} + + ) : null} + + ) : onTaskIdClick ? ( + renderInlineBoldSummary(rawSummary, onTaskIdClick) + ) : ( + renderInlineBoldSummary(rawSummary) + ); return (
- {isUnread ? ( - - ) : null} - {/* Chevron for collapsible messages */} - {showChevron ? ( - - ) : null} - - {/* Sender avatar + name badge */} - {crossTeamOrigin ? ( - - ) : null} - {isSlashCommandResult ? ( - - result - - ) : ( - - )} - - {/* Role */} - {!compactHeader && formattedRole && !isSlashCommandResult ? ( - - {formattedRole} - - ) : null} - - {/* Message type label or system label */} - {systemLabel ? ( - - {systemLabel} - - ) : isSlashCommandResult && message.commandOutput ? ( - - {message.commandOutput.stream} - - ) : isSlashCommandMessage ? ( - command - ) : messageType ? ( - - {messageType} - - ) : null} - - {/* Lead session marker */} - {message.source === 'lead_session' && !isSlashCommandResult ? ( - - session - - ) : message.source === 'lead_process' && !isSlashCommandResult ? ( - - live - - ) : null} - - {/* Rate limit warning badge */} - {rateLimited ? ( - - - Rate Limited - - ) : null} - - {/* API Error warning badge */} - {isApiError && !rateLimited ? ( - - - API Error - - ) : null} - - {/* Recipient — arrow + avatar + badge */} - {message.to && message.to !== message.from ? ( - <> - - {crossTeamTarget ? ( - - ) : null} - {crossTeamSentMemberName || !crossTeamTarget ? ( - - ) : null} - - ) : null} - - {/* Summary */} - - {isSlashCommandResult && message.commandOutput ? ( - - - - {message.commandOutput.commandLabel} - - - {message.summary || getCommandOutputSummary(message.text) || rawSummary} - - - ) : isSlashCommandMessage && slashCommandMeta ? ( - - - - {slashCommandMeta.command} - - {slashCommandMeta.args ? ( - - {slashCommandMeta.args.replace(/\n+/g, ' ')} + {useCompactCollapsedHeader ? ( +
+
+
+ {isUnread ? ( + + ) : null} + {crossTeamOrigin ? ( + + ) : null} + {senderBadge} + {messageTypeBadge} + {leadSourceBadge} + {statusBadge} + {recipientBadge} +
+
+ + {timestamp} - ) : (slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? ( - - {slashCommandMeta.knownDescription ?? knownSlashCommand?.description} - - ) : null} - - ) : onTaskIdClick ? ( - renderInlineBoldSummary(rawSummary, onTaskIdClick) - ) : ( - renderInlineBoldSummary(rawSummary) - )} - - - {/* Timestamp / expand */} -
- - {timestamp} - - {onExpand && expandItemKey && ( - + )} +
+
+
- - - )} -
+ {summaryContent} +
+
+ ) : ( + <> + {isUnread ? ( + + ) : null} + {showChevron ? ( + + ) : null} + {crossTeamOrigin ? ( + + ) : null} + {senderBadge} + {!compactHeader && formattedRole && !isSlashCommandResult ? ( + + {formattedRole} + + ) : null} + {messageTypeBadge} + {leadSourceBadge} + {statusBadge} + {recipientBadge} + + {summaryContent} + +
+ + {timestamp} + + {onExpand && expandItemKey && ( + + )} +
+ + )}
{/* Content — collapsed for system messages, expanded for others */} @@ -1380,3 +1489,5 @@ export const ActivityItem = memo( prev.onExpandContent === next.onExpandContent && areMessagesEquivalentForActivityItem(prev.message, next.message) ); + +ActivityItem.displayName = 'ActivityItem'; diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 39c0508f..6943a68b 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -751,6 +751,13 @@ const LeadThoughtsGroupRowComponent = ({ }); }, []); + const timestampLabel = + formatTime(oldest.timestamp) === formatTime(newest.timestamp) + ? formatTime(oldest.timestamp) + : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`; + const useCompactCollapsedHeader = compactHeader && !isBodyVisible; + const compactPreviewText = headerTextPreview ?? totalToolSummary; + return (
- {/* Chevron for collapse mode */} - {canToggleBodyVisibility && !compactHeader ? ( - - ) : null} - {/* Lead avatar with optional live indicator */} - {!compactHeader ? ( -
- - -
- ) : null} - - - {thoughts.length} thoughts - - {!isBodyVisible && headerTextPreview ? ( - - - +
+
+ + + {thoughts.length} thoughts + +
+
+ + {timestampLabel} + + {onExpand && expandItemKey && ( + + )} +
+
+ {compactPreviewText ? ( +
- {headerTextPreview} - - - {totalToolSummary ? ( - - - + {compactPreviewText} +
) : null} -
- ) : totalToolSummary ? ( - - - - {totalToolSummary} - - - - + ) : ( + <> + {canToggleBodyVisibility && !compactHeader ? ( + - - - ) : null} -
- - {formatTime(oldest.timestamp) === formatTime(newest.timestamp) - ? formatTime(oldest.timestamp) - : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} - - {onExpand && expandItemKey && ( - - )} -
+ ) : null} + {!compactHeader ? ( +
+ + +
+ ) : null} + + + {thoughts.length} thoughts + + {!isBodyVisible && headerTextPreview ? ( + + + + {headerTextPreview} + + + {totalToolSummary ? ( + + + + ) : null} + + ) : totalToolSummary ? ( + + + + {totalToolSummary} + + + + + + + ) : null} +
+ + {timestampLabel} + + {onExpand && expandItemKey && ( + + )} +
+ + )}
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */} diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index 89ef016a..62d5496c 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -2,7 +2,6 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, @@ -14,8 +13,10 @@ import { import { nameColorSet } from '@renderer/utils/projectColor'; import { formatDistanceToNowStrict } from 'date-fns'; import { Loader2, ShieldQuestion, Users } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { ResolvedTeamMember } from '@shared/types'; +import type { ReactNode } from 'react'; export interface PendingCrossTeamReply { teamName: string; @@ -26,6 +27,7 @@ interface PendingRepliesBlockProps { members: ResolvedTeamMember[]; pendingRepliesByMember: Record; pendingCrossTeamReplies?: PendingCrossTeamReply[]; + headerRight?: ReactNode; onMemberClick?: (member: ResolvedTeamMember) => void; } @@ -33,6 +35,7 @@ export const PendingRepliesBlock = ({ members, pendingRepliesByMember, pendingCrossTeamReplies = [], + headerRight, onMemberClick, }: PendingRepliesBlockProps): React.JSX.Element | null => { const { isLight } = useTheme(); @@ -70,9 +73,12 @@ export const PendingRepliesBlock = ({ return (
-

- Awaiting replies -

+
+

+ Awaiting replies +

+ {headerRight ?
{headerRight}
: null} +
{pending.map((entry) => { const since = formatDistanceToNowStrict(entry.sentAtMs, { addSuffix: true }); diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 57d551f6..04cb17b6 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -20,6 +20,7 @@ import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -77,7 +78,9 @@ export const CreateTaskDialog = ({ submitting = false, }: CreateTaskDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); - const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); + const projectPath = useStore( + (s) => selectTeamDataForName(s, teamName)?.config.projectPath ?? null + ); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const [subject, setSubject] = useState(defaultSubject); const descriptionDraft = useDraftPersistence({ diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 5c4ed0d7..d91680ee 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -42,22 +42,23 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { ProjectPathSelector } from './ProjectPathSelector'; import { createInitialProviderChecks, failIncompleteProviderChecks, - getProvisioningProviderBackendSummary, getProvisioningFailureHint, + getProvisioningProviderBackendSummary, + type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, - type ProvisioningProviderCheck, } from './ProvisioningProviderStatusList'; -import { ProjectPathSelector } from './ProjectPathSelector'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { computeEffectiveTeamModel } from './TeamModelSelector'; import { getNextSuggestedTeamName } from './teamNameSets'; @@ -111,15 +112,7 @@ function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): } function getProviderLabel(providerId: TeamProviderId): string { - switch (providerId) { - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'anthropic': - default: - return 'Anthropic'; - } + return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } export interface TeamCopyData { @@ -484,9 +477,7 @@ export const CreateTeamDialog = ({ }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); const runtimeBackendSummaryByProvider = useMemo(() => { - const entries: Array = ( - cliStatus?.providers ?? [] - ).map( + const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( (provider) => [ provider.providerId as TeamProviderId, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 6ad798d8..dd0075b5 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; +import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { buildMemberDraftColorMap, buildMemberDraftSuggestions, @@ -13,7 +14,6 @@ import { validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; -import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Combobox } from '@renderer/components/ui/combobox'; @@ -36,16 +36,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { isGeminiUiFrozen, normalizeCreateLaunchProviderForUi, } from '@renderer/utils/geminiUiFreeze'; -import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; -import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; -import { useShallow } from 'zustand/react/shallow'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, Check, @@ -56,24 +56,25 @@ import { RotateCcw, X, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; +import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { ProjectPathSelector } from './ProjectPathSelector'; import { createInitialProviderChecks, failIncompleteProviderChecks, - getProvisioningProviderBackendSummary, getProvisioningFailureHint, + getProvisioningProviderBackendSummary, + type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, - type ProvisioningProviderCheck, } from './ProvisioningProviderStatusList'; -import { ProjectPathSelector } from './ProjectPathSelector'; -import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { computeEffectiveTeamModel, formatTeamModelSummary, @@ -160,15 +161,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { } function getProviderLabel(providerId: TeamProviderId): string { - switch (providerId) { - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'anthropic': - default: - return 'Anthropic'; - } + return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } function resolveMemberDraftRuntime( @@ -339,9 +332,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); const runtimeBackendSummaryByProvider = useMemo(() => { - const entries: Array = ( - cliStatus?.providers ?? [] - ).map( + const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( (provider) => [ provider.providerId as TeamProviderId, @@ -643,10 +634,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const runtimeChangeNotes = useMemo(() => { if (!isLaunch) { - return [] as Array<{ key: string; memberName: string; message: string }>; + return [] as { key: string; memberName: string; message: string }[]; } - const notes: Array<{ key: string; memberName: string; message: string }> = []; + const notes: { key: string; memberName: string; message: string }[] = []; const previousLeadModel = previousLaunchParams?.model?.trim() || ''; const previousLeadEffort = previousLaunchParams?.effort; const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel; diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 195a6344..dcf79f8d 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; + import type { TeamProviderId } from '@shared/types'; import type { CliProviderStatus } from '@shared/types'; -import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; @@ -14,15 +16,7 @@ export interface ProvisioningProviderCheck { } export function getProvisioningProviderLabel(providerId: TeamProviderId): string { - switch (providerId) { - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'anthropic': - default: - return 'Anthropic'; - } + return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } export function createInitialProviderChecks( @@ -150,7 +144,7 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus function getDisplayStatusText(check: ProvisioningProviderCheck): string { const summary = check.details.find(Boolean) - ? summarizeDetail(check.details[0]!, check.status) + ? summarizeDetail(check.details[0], check.status) : null; return summary ?? getStatusLabel(check.status); } @@ -194,7 +188,7 @@ function getStatusColor(status: ProvisioningProviderCheckStatus): string { } } -function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element { +const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element => { if (status === 'checking') { return ; } @@ -205,9 +199,9 @@ function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): Re return ; } return ; -} +}; -export function ProvisioningProviderStatusList({ +export const ProvisioningProviderStatusList = ({ checks, className = '', suppressDetailsMatching, @@ -215,7 +209,7 @@ export function ProvisioningProviderStatusList({ checks: ProvisioningProviderCheck[]; className?: string; suppressDetailsMatching?: string | null; -}): React.JSX.Element | null { +}): React.JSX.Element | null => { if (checks.length === 0) { return null; } @@ -253,7 +247,7 @@ export function ProvisioningProviderStatusList({ })}
); -} +}; export function getProvisioningFailureHint( message: string | null | undefined, diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 73546360..83d2cedf 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -9,7 +9,7 @@ import { import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; +import { TaskLogsPanel } from '@renderer/components/team/taskLogs/TaskLogsPanel'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { @@ -1256,29 +1256,8 @@ export const TaskDetailDialog = ({ {variant === 'team' ? ( } - headerExtra={ - logsRefreshing || executionPreviewOnline ? ( - - {executionPreviewOnline ? ( - - - - - ) : null} - {logsRefreshing ? ( - - - Updating... - - ) : null} - - ) : null - } contentClassName="pl-2.5 overflow-visible" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" @@ -1286,19 +1265,14 @@ export const TaskDetailDialog = ({ keepMounted >
- diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 6021405b..c1aa208a 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo } from 'react'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Label } from '@renderer/components/ui/label'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { @@ -17,6 +18,7 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { doesTeamModelCarryProviderBrand, + getProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, getTeamModelUiDisabledReason, getTeamProviderLabel as getCatalogTeamProviderLabel, @@ -26,126 +28,21 @@ import { } from '@renderer/utils/teamModelCatalog'; import { Info } from 'lucide-react'; -// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) --- - -/** Anthropic — official "A" lettermark (Simple Icons) */ -const AnthropicIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -/** OpenAI — official hexagonal knot logo (Simple Icons) */ -const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -const OpenCodeIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); +export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; // --- Provider definitions --- interface ProviderDef { - id: string; + id: 'anthropic' | 'codex' | 'gemini' | 'opencode'; label: string; - icon: React.FC<{ className?: string }>; comingSoon: boolean; } const PROVIDERS: ProviderDef[] = [ - { id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false }, - { id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false }, - // { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false }, - { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false }, - { id: 'opencode', label: 'OpenCode', icon: OpenCodeIcon, comingSoon: false }, + { id: 'anthropic', label: 'Anthropic', comingSoon: false }, + { id: 'codex', label: 'Codex', comingSoon: false }, + { id: 'gemini', label: 'Gemini', comingSoon: false }, + { id: 'opencode', label: 'OpenCode', comingSoon: false }, ]; const OPENCODE_UI_DISABLED_REASON = 'OpenCode in development'; @@ -154,17 +51,6 @@ export function getTeamModelLabel(model: string): string { return getCatalogTeamModelLabel(model) ?? model; } -export function getProviderScopedTeamModelLabel( - providerId: 'anthropic' | 'codex' | 'gemini', - model: string -): string { - const baseLabel = getTeamModelLabel(model); - if (providerId !== 'codex') { - return baseLabel; - } - return baseLabel.replace(/^GPT-/i, ''); -} - export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } @@ -343,7 +229,6 @@ export const TeamModelSelector: React.FC = ({
{PROVIDERS.map((provider) => { - const Icon = provider.icon; const providerDisabledReason = getProviderDisabledReason(provider.id); const providerSelectable = isProviderSelectable(provider.id); const statusBadge = getProviderStatusBadge(provider.id); @@ -365,7 +250,7 @@ export const TeamModelSelector: React.FC = ({ !providerSelectable && 'opacity-50' )} > - + = ({ id={opt.value === normalizedValue ? id : undefined} aria-disabled={!modelSelectable} className={cn( - 'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-center text-xs font-medium transition-colors', + 'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150', normalizedValue === opt.value - ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' - : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]', + ? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' + : modelSelectable + ? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm' + : 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]', !modelSelectable && 'cursor-not-allowed opacity-45', !modelDisabledReason && !activeProviderSelectable && 'pointer-events-none' )} - style={{ - borderColor: - normalizedValue === opt.value - ? 'var(--color-border-emphasis)' - : 'var(--color-border-subtle)', - }} onClick={() => { if (!modelSelectable) return; onValueChange(opt.value); diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx index 617f55cb..38003e2f 100644 --- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx +++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx @@ -9,8 +9,8 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types'; diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 6c5e28ef..62d5759e 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; @@ -23,7 +23,11 @@ import { import { KanbanColumn } from './KanbanColumn'; import { KanbanFilterPopover } from './KanbanFilterPopover'; -import { KanbanGridLayout } from './KanbanGridLayout'; +import { + KanbanGridLayout, + SKELETON_HIDE_DELAY_MS, + SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH, +} from './KanbanGridLayout'; import { KanbanSortPopover } from './KanbanSortPopover'; import { KanbanTaskCard } from './KanbanTaskCard'; @@ -38,28 +42,28 @@ const COLUMN_ACCENTS: Record< { headerBg: string; bodyBg: string; icon: React.ReactNode } > = { todo: { - headerBg: 'rgba(59, 130, 246, 0.15)', - bodyBg: 'rgba(59, 130, 246, 0.015)', + headerBg: 'rgba(59, 130, 246, 0.22)', + bodyBg: 'rgba(59, 130, 246, 0.05)', icon: , }, in_progress: { - headerBg: 'rgba(234, 179, 8, 0.18)', - bodyBg: 'rgba(234, 179, 8, 0.018)', + headerBg: 'rgba(234, 179, 8, 0.24)', + bodyBg: 'rgba(234, 179, 8, 0.06)', icon: , }, done: { - headerBg: 'rgba(34, 197, 94, 0.15)', - bodyBg: 'rgba(34, 197, 94, 0.015)', + headerBg: 'rgba(34, 197, 94, 0.22)', + bodyBg: 'rgba(34, 197, 94, 0.05)', icon: , }, review: { - headerBg: 'rgba(139, 92, 246, 0.15)', - bodyBg: 'rgba(139, 92, 246, 0.015)', + headerBg: 'rgba(139, 92, 246, 0.22)', + bodyBg: 'rgba(139, 92, 246, 0.05)', icon: , }, approved: { - headerBg: 'rgba(34, 197, 94, 0.28)', - bodyBg: 'rgba(34, 197, 94, 0.033)', + headerBg: 'rgba(34, 197, 94, 0.34)', + bodyBg: 'rgba(34, 197, 94, 0.08)', icon: , }, }; @@ -102,6 +106,8 @@ interface KanbanBoardProps { type KanbanViewMode = 'grid' | 'columns'; +const SCROLLABLE_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay']); + const COLUMNS: { id: KanbanColumnId; title: string }[] = [ { id: 'todo', title: 'TODO' }, { id: 'in_progress', title: 'IN PROGRESS' }, @@ -131,6 +137,36 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId return null; } +function columnSupportsAddButton( + columnId: KanbanColumnId, + onAddTask?: (startImmediately: boolean) => void +): boolean { + return Boolean(onAddTask && (columnId === 'todo' || columnId === 'in_progress')); +} + +function estimateGridSkeletonCardHeight( + task: TeamTask, + columnId: KanbanColumnId, + kanbanState: KanbanState, + hasReviewers: boolean +): number { + let height = 122; + + if (task.subject.length > 54) height += 10; + if (task.subject.length > 92) height += 8; + if (task.needsClarification) height += 16; + if (task.reviewState === 'needsFix') height += 14; + if ((task.blockedBy?.length ?? 0) > 0) height += 18; + if ((task.blocks?.length ?? 0) > 0) height += 18; + + const effectiveReviewer = (kanbanState.tasks[task.id]?.reviewer ?? '').trim(); + if (columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0) { + height += 14; + } + + return Math.min(Math.max(height, 116), 196); +} + /** Сортирует задачи колонки по сохранённому порядку; задачи без порядка — в конце. */ function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): TeamTask[] { if (!order?.length) { @@ -303,7 +339,12 @@ export const KanbanBoard = ({ deletedTaskCount, onOpenTrash, }: KanbanBoardProps): React.JSX.Element => { + const boardRef = useRef(null); + const scrollRestoreTimeoutsRef = useRef([]); const [viewMode, setViewMode] = useState('grid'); + const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); + const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); + const hasReviewers = kanbanState.reviewers.length > 0; const enableTaskSorting = viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; @@ -455,7 +496,7 @@ export const KanbanBoard = ({ teamName={teamName} columnId={columnId} kanbanTaskState={kanbanState.tasks[task.id]} - hasReviewers={kanbanState.reviewers.length > 0} + hasReviewers={hasReviewers} compact={compact} taskMap={taskMap} memberColorMap={memberColorMap} @@ -481,18 +522,90 @@ export const KanbanBoard = ({ () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), [filter.columns] ); + const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); const { widths: columnWidths, getHandleProps } = useResizableColumns({ storageKey: teamName, columnIds: resizableColumnIds, }); + const columnModeSearchWidth = + primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; + const toolbarLeftWidth = + viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; + + const clearScheduledScrollRestore = useCallback(() => { + for (const timeoutId of scrollRestoreTimeoutsRef.current) { + window.clearTimeout(timeoutId); + } + scrollRestoreTimeoutsRef.current = []; + }, []); + + useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); + + const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let current = startNode?.parentElement ?? null; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { + return current; + } + current = current.parentElement; + } + return null; + }, []); + + const scheduleScrollRestore = useCallback( + (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { + const container = findScrollContainer(boardRef.current); + if (!container) { + return; + } + + const savedScrollTop = container.scrollTop; + clearScheduledScrollRestore(); + + const restore = (): void => { + container.scrollTop = savedScrollTop; + }; + + const delays = + nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; + + scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); + }, + [clearScheduledScrollRestore, findScrollContainer] + ); + + const switchViewMode = useCallback( + (nextViewMode: KanbanViewMode) => { + const nextSkeletonDelayMs = + nextViewMode === 'grid' && viewMode === 'columns' + ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH + : SKELETON_HIDE_DELAY_MS; + + setGridSkeletonDelayMs(nextSkeletonDelayMs); + scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); + setViewMode(nextViewMode); + }, + [scheduleScrollRestore, viewMode] + ); const boardContent = ( - <> -
- {toolbarLeft != null &&
{toolbarLeft}
} -
+
+
+ {toolbarLeft != null && ( +
+ {toolbarLeft} +
+ )} +
setViewMode('grid')} + onClick={() => switchViewMode('grid')} aria-label="Grid view" > @@ -551,7 +664,7 @@ export const KanbanBoard = ({ ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' : 'text-[var(--color-text-muted)]' )} - onClick={() => setViewMode('columns')} + onClick={() => switchViewMode('columns')} aria-label="Columns view" > @@ -566,6 +679,9 @@ export const KanbanBoard = ({ {viewMode === 'grid' ? ( column.id)} + primaryColumnId={primaryVisibleColumnId} + onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} + skeletonDelayMs={gridSkeletonDelayMs} columns={visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; @@ -578,45 +694,53 @@ export const KanbanBoard = ({ headerBg: accent.headerBg, bodyBg: accent.bodyBg, content: renderCards(column.id, columnTasks), + showAddButton: columnSupportsAddButton(column.id, onAddTask), + skeletonCards: columnTasks.map((task) => ({ + key: task.id, + height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), + })), }; })} /> ) : ( -
- {visibleColumns.map((column, index) => { - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - const width = columnWidths.get(column.id) ?? 256; - const handleProps = getHandleProps(column.id); - return ( -
-
- - {renderCards(column.id, columnTasks, true)} - -
- {index < visibleColumns.length - 1 ? ( -
-
+
+
+ {visibleColumns.map((column, index) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); + return ( +
+
+ + {renderCards(column.id, columnTasks, true)} +
- ) : null} -
- ); - })} + {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null} +
+ ); + })} +
)} - +
); if (enableTaskSorting) { diff --git a/src/renderer/components/team/kanban/KanbanColumn.tsx b/src/renderer/components/team/kanban/KanbanColumn.tsx index 1422c8c8..48a572bd 100644 --- a/src/renderer/components/team/kanban/KanbanColumn.tsx +++ b/src/renderer/components/team/kanban/KanbanColumn.tsx @@ -32,11 +32,7 @@ export const KanbanColumn = memo(function KanbanColumn({ }: KanbanColumnProps): React.JSX.Element { return (
{count > 0 && ( @@ -48,11 +44,7 @@ export const KanbanColumn = memo(function KanbanColumn({ )}

diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx index ff595d54..6b5f868e 100644 --- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -1,8 +1,9 @@ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; +import { cn } from '@renderer/lib/utils'; import { browserGridLayoutRepository } from '@renderer/services/layout-system/BrowserGridLayoutRepository'; import { KanbanColumn } from './KanbanColumn'; @@ -25,6 +26,7 @@ const DEFAULT_MIN_HEIGHT = 10; const DEFAULT_MIN_WIDTH = 3; const GRID_SCOPE_KEY = 'kanban-grid-layout:global:v2'; const SKELETON_HIDE_DELAY_MS = 500; +const SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH = 750; const RESIZE_HANDLES: ResizeHandleAxis[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne']; const WidthAwareGridLayout = WidthProvider(ReactGridLayout); @@ -36,22 +38,36 @@ export interface KanbanGridColumn { headerBg?: string; bodyBg?: string; content: React.ReactNode; + showAddButton?: boolean; + skeletonCards?: { + key: string; + height: number; + }[]; } interface KanbanGridLayoutProps { columns: KanbanGridColumn[]; allColumnIds: KanbanColumnId[]; + primaryColumnId?: KanbanColumnId | null; + onPrimaryColumnWidthChange?: (width: number | null) => void; + skeletonDelayMs?: number; } interface LoadedKanbanGridLayoutProps { readonly columns: KanbanGridColumn[]; readonly visibleItems: PersistedGridLayoutItem[]; readonly onPersistLayout: (layout: Layout, options?: { persist?: boolean }) => void; + readonly primaryColumnId?: KanbanColumnId | null; + readonly onPrimaryColumnWidthChange?: (width: number | null) => void; + readonly className?: string; } interface LoadingKanbanGridLayoutProps { readonly columns: KanbanGridColumn[]; readonly visibleItems: PersistedGridLayoutItem[]; + readonly primaryColumnId?: KanbanColumnId | null; + readonly onPrimaryColumnWidthChange?: (width: number | null) => void; + readonly className?: string; } const ITEMS_PER_FIRST_ROW = 3; @@ -115,18 +131,86 @@ function renderResizeHandle(axis: ResizeHandleAxis, ref: Ref): Reac ); } +const KanbanTaskCardSkeleton = ({ height }: { height: number }): ReactElement => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + const LoadingKanbanGridLayout = ({ columns, visibleItems, + primaryColumnId, + onPrimaryColumnWidthChange, + className, }: Readonly): ReactElement => { const columnMap = new Map(columns.map((column) => [column.id, column])); const loadingItems = visibleItems.length > 0 ? visibleItems : buildDefaultItems(columns.length > 0 ? columns.map((column) => column.id) : ['todo']); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + + const updateWidth = (): void => { + setContainerWidth(element.clientWidth); + }; + + updateWidth(); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + setContainerWidth(entry ? entry.contentRect.width : element.clientWidth); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const primaryColumnWidth = useMemo(() => { + if (!primaryColumnId || containerWidth <= 0) { + return null; + } + + const layoutItem = loadingItems.find((item) => item.id === primaryColumnId); + if (!layoutItem) { + return null; + } + + const columnUnitWidth = (containerWidth - GRID_MARGIN[0] * (GRID_COLS - 1)) / GRID_COLS; + return Math.round(columnUnitWidth * layoutItem.w + GRID_MARGIN[0] * (layoutItem.w - 1)); + }, [containerWidth, loadingItems, primaryColumnId]); + + useEffect(() => { + onPrimaryColumnWidthChange?.(primaryColumnWidth); + }, [onPrimaryColumnWidthChange, primaryColumnWidth]); return ( -
+
{loadingItems.map((item) => { const column = columnMap.get(item.id as KanbanColumnId); + if (!column) { + return
; + } + const skeletonCards = column.skeletonCards ?? []; + const hasTasks = skeletonCards.length > 0; + const showAddButton = column.showAddButton === true; return ( -
-
-
-
-
-
-
-
-
-
-
-
+ + {hasTasks ? ( + <> + {skeletonCards.map((card) => ( + + ))} + {showAddButton ? ( +
+ Add task +
+ ) : null} + + ) : showAddButton ? ( +
+ Add task +
+ ) : ( +
+ No tasks +
+ )} +
+
); })}
@@ -171,11 +279,38 @@ const LoadedKanbanGridLayout = ({ columns, visibleItems, onPersistLayout, + primaryColumnId, + onPrimaryColumnWidthChange, + className, }: Readonly): ReactElement => { const columnMap = useMemo(() => new Map(columns.map((column) => [column.id, column])), [columns]); - const [renderLayout, setRenderLayout] = useState(() => - visibleItems.map(toReactGridLayoutItem) - ); + const visibleLayout = useMemo(() => visibleItems.map(toReactGridLayoutItem), [visibleItems]); + const [renderLayout, setRenderLayout] = useState(() => visibleLayout); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + setRenderLayout(visibleLayout); + }, [visibleLayout]); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + + const updateWidth = (): void => { + setContainerWidth(element.clientWidth); + }; + + updateWidth(); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + setContainerWidth(entry ? entry.contentRect.width : element.clientWidth); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, []); const applyReactGridLayout = useCallback( (layout: Layout, options?: { persist?: boolean }) => { @@ -187,11 +322,28 @@ const LoadedKanbanGridLayout = ({ [onPersistLayout] ); + const primaryColumnWidth = useMemo(() => { + if (!primaryColumnId || containerWidth <= 0) { + return null; + } + + const layoutItem = renderLayout.find((item) => item.i === primaryColumnId); + if (!layoutItem) { + return null; + } + + const columnUnitWidth = (containerWidth - GRID_MARGIN[0] * (GRID_COLS - 1)) / GRID_COLS; + return Math.round(columnUnitWidth * layoutItem.w + GRID_MARGIN[0] * (layoutItem.w - 1)); + }, [containerWidth, primaryColumnId, renderLayout]); + + useEffect(() => { + onPrimaryColumnWidthChange?.(primaryColumnWidth); + }, [onPrimaryColumnWidthChange, primaryColumnWidth]); + return ( -
+
{ const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ @@ -255,12 +410,12 @@ export const KanbanGridLayout = ({ const timeoutId = window.setTimeout(() => { setShowResolvedLayout(true); - }, SKELETON_HIDE_DELAY_MS); + }, skeletonDelayMs); return () => { window.clearTimeout(timeoutId); }; - }, [showResolvedLayout]); + }, [showResolvedLayout, skeletonDelayMs]); const applyReactGridLayout = useCallback( (layout: Layout, options?: { persist?: boolean }) => { @@ -270,20 +425,36 @@ export const KanbanGridLayout = ({ }, [applyVisibleItems] ); - - if (!showResolvedLayout && !isLoaded) { - return ; - } + const showSkeletonOverlay = !showResolvedLayout || !isLoaded; const gridKey = visibleItems.map((item) => item.id).join('|'); return ( - +
+ + {showSkeletonOverlay ? ( + + ) : null} +
); }; + +export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ diff --git a/src/renderer/components/team/kanban/KanbanSearchInput.tsx b/src/renderer/components/team/kanban/KanbanSearchInput.tsx index 31672bf8..bfc0586d 100644 --- a/src/renderer/components/team/kanban/KanbanSearchInput.tsx +++ b/src/renderer/components/team/kanban/KanbanSearchInput.tsx @@ -122,7 +122,7 @@ export const KanbanSearchInput = ({ ); return ( -
+
onChange(e.target.value)} onKeyDown={handleKeyDown} - className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" + className="h-8 w-full min-w-[140px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" /> {value && ( @@ -155,7 +155,7 @@ export const KanbanSearchInput = ({ {showDropdown && suggestions.length > 0 && (
diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 0c8b8855..87933fb6 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -5,6 +5,7 @@ import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBad import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { @@ -238,11 +239,13 @@ export const KanbanTaskCard = memo( onViewChanges, onDeleteTask, }: KanbanTaskCardProps): React.JSX.Element { + const { isLight } = useTheme(); const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const hasBlockedBy = blockedByIds.length > 0; const hasBlocks = blocksIds.length > 0; + const cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]'; const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); const canDisplay = useMemo( @@ -291,8 +294,8 @@ export const KanbanTaskCard = memo( data-task-id={task.id} className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${ hasBlockedBy - ? 'border-yellow-500/30 bg-[var(--color-surface-raised)]' - : 'border-[var(--color-border)] bg-[var(--color-surface-raised)]' + ? `border-yellow-500/30 ${cardSurfaceClass}` + : `border-[var(--color-border)] ${cardSurfaceClass}` }`} role="button" tabIndex={0} diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index b981ef1d..cdf9befd 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -1,9 +1,11 @@ import React, { useState } from 'react'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; import { getProviderScopedTeamModelLabel, + getTeamProviderLabel, TeamModelSelector, } from '@renderer/components/team/dialogs/TeamModelSelector'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -52,6 +54,7 @@ export const LeadModelRow = ({ const modelButtonLabel = model.trim() ? getProviderScopedTeamModelLabel(providerId, model.trim()) : 'Default'; + const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`; return (
setModelExpanded((prev) => !prev)} > {modelExpanded ? ( @@ -104,7 +108,8 @@ export const LeadModelRow = ({ ) : ( )} - Model: {modelButtonLabel} + + {modelButtonLabel}
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index de346518..3937e09f 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -5,12 +5,8 @@ import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, + buildMemberLaunchPresentation, displayMemberName, - getLaunchAwarePresenceLabel, - getMemberRuntimeAdvisoryLabel, - getMemberRuntimeAdvisoryTitle, - getSpawnAwareDotClass, - getSpawnCardClass, } from '@renderer/utils/memberHelpers'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; @@ -81,44 +77,23 @@ export const MemberCard = ({ // const leadContext = useStore((s) => // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined // ); - const dotClass = getSpawnAwareDotClass( - member, - spawnStatus, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning, - leadActivity - ); - const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel( - member.runtimeAdvisory, - member.providerId - ); - const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle( - member.runtimeAdvisory, - member.providerId - ); - const presenceLabel = getLaunchAwarePresenceLabel( + const launchPresentation = buildMemberLaunchPresentation({ member, spawnStatus, spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, - member.runtimeAdvisory, + runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, isTeamProvisioning, - leadActivity - ); - const spawnCardClass = getSpawnCardClass( - spawnStatus, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning - ); + leadActivity, + }); + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; + const presenceLabel = launchPresentation.presenceLabel; + const spawnCardClass = launchPresentation.cardClass; const colors = getTeamColorSet(memberColor); const { isLight } = useTheme(); const pending = taskCounts?.pending ?? 0; diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 402a8236..d4cdce7c 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,13 +1,16 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; +import { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries'; import { useMemberStats } from '@renderer/hooks/useMemberStats'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; import { MemberDetailHeader } from './MemberDetailHeader'; -import { MemberDetailStats, type MemberDetailTab } from './MemberDetailStats'; +import { MemberDetailStats } from './MemberDetailStats'; +import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes'; import { MemberLogsTab } from './MemberLogsTab'; import { MemberMessagesTab } from './MemberMessagesTab'; import { MemberStatsTab } from './MemberStatsTab'; @@ -25,8 +28,11 @@ interface MemberDetailDialogProps { open: boolean; member: ResolvedTeamMember | null; teamName: string; + members: ResolvedTeamMember[]; tasks: TeamTaskWithKanban[]; messages: InboxMessage[]; + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; isTeamAlive?: boolean; isTeamProvisioning?: boolean; isLaunchSettling?: boolean; @@ -46,8 +52,11 @@ export const MemberDetailDialog = ({ open, member, teamName, + members, tasks, messages, + initialTab = 'tasks', + initialActivityFilter = 'all', isTeamAlive, isTeamProvisioning, isLaunchSettling, @@ -72,6 +81,27 @@ export const MemberDetailDialog = ({ [messages, member] ); const memberMessages = seedMemberMessages; + const memberActivityCount = useMemo(() => { + if (!member) { + return 0; + } + const leadId = `lead:${teamName}`; + const leadName = + members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; + const ownerNodeId = member.name === leadName ? leadId : `member:${teamName}:${member.name}`; + const entries = buildInlineActivityEntries({ + data: { + members, + tasks, + messages: memberMessages, + }, + teamName, + leadId, + leadName, + ownerNodeIds: new Set([leadId, ownerNodeId]), + }); + return (entries.get(ownerNodeId) ?? []).length; + }, [member, memberMessages, members, tasks, teamName]); const inProgressTasks = useMemo( () => memberTasks.filter((t) => t.status === 'in_progress').length, @@ -83,7 +113,14 @@ export const MemberDetailDialog = ({ [memberTasks] ); - const [activeTab, setActiveTab] = useState('tasks'); + const [activeTab, setActiveTab] = useState(initialTab); + + useEffect(() => { + if (!open || !member) { + return; + } + setActiveTab(initialTab); + }, [initialTab, member, open]); const { stats: memberStats, @@ -121,7 +158,7 @@ export const MemberDetailDialog = ({ totalTasks={memberTasks.length} inProgressTasks={inProgressTasks} completedTasks={completedTasks} - messageCount={memberMessages.length} + activityCount={memberActivityCount} totalTokens={totalTokens} statsLoading={statsLoading} statsComputedAt={memberStats?.computedAt} @@ -143,11 +180,11 @@ export const MemberDetailDialog = ({ )} - - Messages - {memberMessages.length > 0 && ( + + Activity + {memberActivityCount > 0 && ( - {memberMessages.length} + {memberActivityCount} )} @@ -163,11 +200,15 @@ export const MemberDetailDialog = ({ - + diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index d2b4f014..5dfaee99 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -6,10 +6,8 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, + buildMemberLaunchPresentation, displayMemberName, - getLaunchAwarePresenceLabel, - getMemberRuntimeAdvisoryTitle, - getSpawnAwareDotClass, } from '@renderer/utils/memberHelpers'; import { isLeadMember } from '@shared/utils/leadDetection'; import { Pencil } from 'lucide-react'; @@ -61,32 +59,21 @@ export const MemberDetailHeader = ({ const colors = getTeamColorSet(member.color ?? ''); const role = member.role || formatAgentRole(member.agentType); - const presenceLabel = getLaunchAwarePresenceLabel( + const launchPresentation = buildMemberLaunchPresentation({ member, spawnStatus, spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, - member.runtimeAdvisory, + runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, isTeamProvisioning, - leadActivity - ); - const dotClass = getSpawnAwareDotClass( - member, - spawnStatus, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning, - leadActivity - ); - const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle( - member.runtimeAdvisory, - member.providerId - ); + leadActivity, + }); + const presenceLabel = launchPresentation.presenceLabel; + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; const canEditRole = !isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole; diff --git a/src/renderer/components/team/members/MemberDetailStats.tsx b/src/renderer/components/team/members/MemberDetailStats.tsx index fb358847..f0723476 100644 --- a/src/renderer/components/team/members/MemberDetailStats.tsx +++ b/src/renderer/components/team/members/MemberDetailStats.tsx @@ -1,12 +1,12 @@ import { formatRelativeTime, formatTokensCompact } from '@renderer/utils/formatters'; -export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs'; +import type { MemberDetailTab } from './memberDetailTypes'; interface MemberDetailStatsProps { totalTasks: number; inProgressTasks: number; completedTasks: number; - messageCount: number; + activityCount: number; totalTokens: number | null; statsLoading?: boolean; statsComputedAt?: string; @@ -51,7 +51,7 @@ export const MemberDetailStats = ({ totalTasks, inProgressTasks, completedTasks, - messageCount, + activityCount, totalTokens, statsLoading, statsComputedAt, @@ -79,9 +79,9 @@ export const MemberDetailStats = ({ onClick={onTabChange ? () => onTabChange('tasks') : undefined} /> onTabChange('messages') : undefined} + label="Activity" + value={activityCount} + onClick={onTabChange ? () => onTabChange('activity') : undefined} /> setModelExpanded((prev) => !prev)} > @@ -254,7 +258,11 @@ export const MemberDraftRow = ({ ) : ( )} - Model: {modelButtonLabel} + + {modelButtonLabel} diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index bf911bd5..c43e7d31 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -12,17 +12,16 @@ import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/te import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, + buildMemberLaunchPresentation, displayMemberName, - getLaunchAwarePresenceLabel, - getMemberRuntimeAdvisoryTitle, - getSpawnAwareDotClass, } from '@renderer/utils/memberHelpers'; import { isLeadMember } from '@shared/utils/leadDetection'; import { ExternalLink } from 'lucide-react'; -import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps'; +import { CurrentTaskIndicator } from './CurrentTaskIndicator'; + import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types'; interface MemberHoverCardProps { @@ -103,32 +102,21 @@ export const MemberHoverCard = ({ progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining; const colors = getTeamColorSet(color ?? member.color ?? ''); const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - const presenceLabel = getLaunchAwarePresenceLabel( + const launchPresentation = buildMemberLaunchPresentation({ member, - spawnEntry?.status, - spawnEntry?.launchState, - spawnEntry?.livenessSource, - spawnEntry?.runtimeAlive, - member.runtimeAdvisory, + spawnStatus: spawnEntry?.status, + spawnLaunchState: spawnEntry?.launchState, + spawnLivenessSource: spawnEntry?.livenessSource, + spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, - false, - isLeadMember(member) ? leadActivity : undefined - ); - const dotClass = getSpawnAwareDotClass( - member, - spawnEntry?.status, - spawnEntry?.launchState, - spawnEntry?.runtimeAlive, - isLaunchSettling, - isTeamAlive, - false, - isLeadMember(member) ? leadActivity : undefined - ); - const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle( - member.runtimeAdvisory, - member.providerId - ); + isTeamProvisioning: false, + leadActivity: isLeadMember(member) ? leadActivity : undefined, + }); + const presenceLabel = launchPresentation.presenceLabel; + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; const currentTask: TeamTaskWithKanban | null = member.currentTaskId && tasks ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index f4a98b18..c19f4b10 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -6,12 +6,12 @@ import { getTeamModelLabel, getTeamProviderLabel, } from '@renderer/components/team/dialogs/TeamModelSelector'; -import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { MemberCard } from './MemberCard'; +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, @@ -86,8 +86,7 @@ function areTaskStatusCountsMapsEquivalent( for (const [key, leftCounts] of left) { const rightCounts = right.get(key); if ( - !rightCounts || - leftCounts.pending !== rightCounts.pending || + leftCounts.pending !== rightCounts?.pending || leftCounts.inProgress !== rightCounts.inProgress || leftCounts.completed !== rightCounts.completed ) { @@ -107,8 +106,7 @@ function areMemberTaskMapsEquivalent( for (const [key, leftTask] of left) { const rightTask = right.get(key); if ( - !rightTask || - leftTask.id !== rightTask.id || + leftTask.id !== rightTask?.id || leftTask.displayId !== rightTask.displayId || leftTask.subject !== rightTask.subject || leftTask.owner !== rightTask.owner || @@ -150,8 +148,7 @@ function areMemberSpawnStatusesEquivalent( for (const [key, leftEntry] of left) { const rightEntry = right.get(key); if ( - !rightEntry || - leftEntry.status !== rightEntry.status || + leftEntry.status !== rightEntry?.status || leftEntry.launchState !== rightEntry.launchState || leftEntry.error !== rightEntry.error || leftEntry.livenessSource !== rightEntry.livenessSource || diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 4943d506..316c2c17 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -1,8 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useStore } from '@renderer/store'; -import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; - import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { @@ -11,6 +8,8 @@ import { } from '@renderer/components/team/members/SubagentRecentMessagesPreview'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; +import { useStore } from '@renderer/store'; import { asEnhancedChunkArray } from '@renderer/types/data'; import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; import { formatDuration } from '@renderer/utils/formatters'; diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index b48ed530..d273268c 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -1,34 +1,73 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; +import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; +import { + buildMessageContext, + resolveMessageRenderProps, +} from '@renderer/components/team/activity/activityMessageContext'; +import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { Button } from '@renderer/components/ui/button'; +import { buildInlineActivityEntries } from '@renderer/features/agent-graph/utils/buildInlineActivityEntries'; +import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { isLeadMember } from '@shared/utils/leadDetection'; -import { ActivityItem } from '../activity/ActivityItem'; - -import type { InboxMessage } from '@shared/types'; +import type { MemberActivityFilter } from './memberDetailTypes'; +import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; +import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface MemberMessagesTabProps { messages: InboxMessage[]; teamName: string; memberName: string; + members: ResolvedTeamMember[]; + tasks: TeamTaskWithKanban[]; + initialFilter?: MemberActivityFilter; onCreateTask?: (subject: string, description: string) => void; + onTaskClick?: (task: TeamTaskWithKanban) => void; } const MAX_MESSAGES = 100; const MEMBER_MESSAGES_PAGE_SIZE = 50; +const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'messages', label: 'Messages' }, + { value: 'comments', label: 'Comments' }, +]; export const MemberMessagesTab = ({ messages, teamName, memberName, + members, + tasks, + initialFilter = 'all', onCreateTask, + onTaskClick, }: MemberMessagesTabProps): React.JSX.Element => { const [pagedMessages, setPagedMessages] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); + const [activityFilter, setActivityFilter] = useState(initialFilter); + const [expandedItem, setExpandedItem] = useState(null); + const { readSet } = useTeamMessagesRead(teamName); + const leadId = `lead:${teamName}`; + const leadName = useMemo( + () => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`, + [members, teamName] + ); + const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; + const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]); + const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); + const messageContext = useMemo(() => buildMessageContext(members), [members]); + + useEffect(() => { + setActivityFilter(initialFilter); + }, [initialFilter, memberName, teamName]); useEffect(() => { let cancelled = false; @@ -39,7 +78,9 @@ export const MemberMessagesTab = ({ void (async () => { try { - const page = await api.teams.getMessagesPage(teamName, { limit: MEMBER_MESSAGES_PAGE_SIZE }); + const page = await api.teams.getMessagesPage(teamName, { + limit: MEMBER_MESSAGES_PAGE_SIZE, + }); if (cancelled) return; const memberPageMessages = page.messages.filter( (message) => message.from === memberName || message.to === memberName @@ -89,45 +130,185 @@ export const MemberMessagesTab = ({ [messages, pagedMessages] ); - const displayMessages = useMemo( + const filteredMessages = useMemo( () => filterTeamMessages(effectiveMessages, { timeWindow: null, filter: { from: new Set(), to: new Set(), showNoise: true }, searchQuery: '', - }).slice(0, MAX_MESSAGES), + }), [effectiveMessages] ); + const activityEntries = useMemo(() => { + const entriesByOwner = buildInlineActivityEntries({ + data: { + members, + tasks, + messages: filteredMessages, + }, + teamName, + leadId, + leadName, + ownerNodeIds, + }); + return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES); + }, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]); + + const displayEntries = useMemo(() => { + switch (activityFilter) { + case 'messages': + return activityEntries.filter( + (entry) => entry.message.messageKind !== 'task_comment_notification' + ); + case 'comments': + return activityEntries.filter( + (entry) => entry.message.messageKind === 'task_comment_notification' + ); + default: + return activityEntries; + } + }, [activityEntries, activityFilter]); + + const expandedItemsByKey = useMemo(() => { + const items = new Map(); + for (const entry of displayEntries) { + items.set(toMessageKey(entry.message), { type: 'message', message: entry.message }); + } + return items; + }, [displayEntries]); + + const handleExpandItem = useCallback( + (key: string) => { + const next = expandedItemsByKey.get(key); + if (next) { + setExpandedItem(next); + } + }, + [expandedItemsByKey] + ); + + const handleTaskIdClick = useCallback( + (taskId: string) => { + const task = taskMap.get(taskId) ?? tasks.find((candidate) => candidate.displayId === taskId); + if (task) { + onTaskClick?.(task); + } + }, + [onTaskClick, taskMap, tasks] + ); + const emptyStateText = loading - ? 'Loading messages...' - : hasMore - ? 'No loaded messages for this member yet' - : 'No messages with this member'; + ? 'Loading activity...' + : activityFilter === 'comments' + ? 'No comments for this member' + : activityFilter === 'messages' + ? hasMore + ? 'No loaded messages for this member yet' + : 'No messages with this member' + : 'No activity with this member'; return ( -
- {displayMessages.length > 0 ? ( - displayMessages.map((msg, idx) => ( - - )) - ) : ( -
- {emptyStateText} -
- )} - {hasMore && ( -
- -
- )} +
+
+ {FILTER_OPTIONS.map((option) => { + const isActive = activityFilter === option.value; + return ( + + ); + })} +
+ +
+ {displayEntries.length > 0 ? ( + displayEntries.map((entry, index) => { + const messageKey = toMessageKey(entry.message); + const renderProps = resolveMessageRenderProps(entry.message, messageContext); + const timelineItem: TimelineItem = { type: 'message', message: entry.message }; + const isUnread = !entry.message.read && !readSet.has(messageKey); + + return ( +
setExpandedItem(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setExpandedItem(timelineItem); + } + }} + > + +
+ ); + }) + ) : ( +
+ {emptyStateText} +
+ )} + + {hasMore && activityFilter !== 'comments' && ( +
+ +
+ )} +
+ + { + if (!open) { + setExpandedItem(null); + } + }} + teamName={teamName} + members={members} + onTaskIdClick={handleTaskIdClick} + onCreateTaskFromMessage={onCreateTask} + />
); }; diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index 252c3e34..e02a2262 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { MembersEditorSection } from './MembersEditorSection'; import { LeadModelRow } from './LeadModelRow'; +import { MembersEditorSection } from './MembersEditorSection'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; diff --git a/src/renderer/components/team/members/memberDetailTypes.ts b/src/renderer/components/team/members/memberDetailTypes.ts new file mode 100644 index 00000000..1d81c1ba --- /dev/null +++ b/src/renderer/components/team/members/memberDetailTypes.ts @@ -0,0 +1,3 @@ +export type MemberDetailTab = 'tasks' | 'activity' | 'stats' | 'logs'; + +export type MemberActivityFilter = 'all' | 'messages' | 'comments'; diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 6ab1484d..9fafc7db 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -1,14 +1,14 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; -import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; +import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { EffortLevel, TeamProvisioningMemberInput, TeamProviderId } from '@shared/types'; +import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types'; function isValidMemberName(name: string): boolean { if (name.length < 1 || name.length > 128) return false; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2f2fa1e1..7f79114c 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -14,7 +14,6 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; @@ -28,6 +27,7 @@ import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -41,6 +41,7 @@ import type { interface MessageComposerProps { teamName: string; members: ResolvedTeamMember[]; + layout?: 'default' | 'compact'; isTeamAlive?: boolean; sending: boolean; sendError: string | null; @@ -67,6 +68,7 @@ interface MessageComposerProps { export const MessageComposer = ({ teamName, members, + layout = 'default', isTeamAlive, sending, sendError, @@ -443,10 +445,28 @@ export const MessageComposer = ({ const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError); + const shouldDockRecipientSelector = !hasAttachmentPreviewContent; + const isCompactLayout = layout === 'compact'; + const compactFooterNotice = slashCommandRestrictionReason ? ( + + + {slashCommandRestrictionReason} + + ) : sendError ? ( + + + {sendError} + + ) : lastResult?.deduplicated ? ( + + + Reused recent cross-team request + + ) : null; return (
-
+
{isLeadRecipient ? ( <> @@ -503,7 +528,10 @@ export const MessageComposer = ({ {/* Combined team + member selector */}
@@ -512,7 +540,10 @@ export const MessageComposer = ({
diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 9716b134..83daddc6 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -6,9 +6,9 @@ import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { Filter } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 71ec1294..5638726c 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Sheet, type SheetRef } from 'react-modal-sheet'; import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; @@ -9,21 +10,24 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; -import { useShallow } from 'zustand/react/shallow'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { createLogger } from '@shared/utils/logger'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; +import { createLogger } from '@shared/utils/logger'; import { CheckCheck, ChevronsDownUp, ChevronsUpDown, MessageSquare, + PanelBottom, + PanelBottomClose, + PanelBottomOpen, PanelLeft, PanelLeftClose, Search, X, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ActivityTimeline } from '../activity/ActivityTimeline'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; @@ -41,6 +45,7 @@ import { StatusBlock } from './StatusBlock'; import type { TimelineItem } from '../activity/LeadThoughtsGroup'; import type { ActionMode } from './ActionModeSelector'; import type { MessagesFilterState } from './MessagesFilterPopover'; +import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface TimeWindow { @@ -51,11 +56,16 @@ interface TimeWindow { const logger = createLogger('Component:MessagesPanel'); const MESSAGES_PANEL_FILTER_WARN_MS = 8; const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6; +const BOTTOM_SHEET_HEADER_HEIGHT = 40; +const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1; +const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2; +const BOTTOM_SHEET_FULL_SNAP_INDEX = 4; interface MessagesPanelProps { teamName: string; - position: 'sidebar' | 'inline'; - onTogglePosition: () => void; + position: TeamMessagesPanelMode; + onPositionChange: (position: TeamMessagesPanelMode) => void; + mountPoint?: Element | null; /** Active (non-removed) members. */ members: ResolvedTeamMember[]; /** All team tasks. */ @@ -95,7 +105,8 @@ interface MessagesPanelProps { export const MessagesPanel = memo(function MessagesPanel({ teamName, position, - onTogglePosition, + onPositionChange, + mountPoint, members, tasks, messages, @@ -207,6 +218,8 @@ export const MessagesPanel = memo(function MessagesPanel({ const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); + const bottomSheetRef = useRef(null); + const bottomSheetStickyTopRef = useRef(null); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); @@ -224,15 +237,20 @@ export const MessagesPanel = memo(function MessagesPanel({ const [messagesCollapsed, setMessagesCollapsed] = useState( initialSidebarStateRef.current.messagesCollapsed ); - const [sidebarSearchVisible, setSidebarSearchVisible] = useState( - initialSidebarStateRef.current.sidebarSearchVisible + const [messagesSearchBarVisible, setMessagesSearchBarVisible] = useState( + initialSidebarStateRef.current.messagesSearchBarVisible ); const [expandedItemKey, setExpandedItemKey] = useState( initialSidebarStateRef.current.expandedItemKey ); - const [sidebarScrollTop, setSidebarScrollTop] = useState( - initialSidebarStateRef.current.sidebarScrollTop + const [messagesScrollTop, setMessagesScrollTop] = useState( + initialSidebarStateRef.current.messagesScrollTop ); + const [bottomSheetSnapIndex, setBottomSheetSnapIndex] = useState( + initialSidebarStateRef.current.bottomSheetSnapIndex + ); + const [bottomSheetStickyTopHeight, setBottomSheetStickyTopHeight] = useState(196); + const [bottomSheetMountHeight, setBottomSheetMountHeight] = useState(0); useEffect(() => { initialSidebarStateRef.current = getTeamMessagesSidebarUiState(teamName); @@ -240,9 +258,10 @@ export const MessagesPanel = memo(function MessagesPanel({ setMessagesFilter(initialSidebarStateRef.current.messagesFilter); setMessagesFilterOpen(initialSidebarStateRef.current.messagesFilterOpen); setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed); - setSidebarSearchVisible(initialSidebarStateRef.current.sidebarSearchVisible); + setMessagesSearchBarVisible(initialSidebarStateRef.current.messagesSearchBarVisible); setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); - setSidebarScrollTop(initialSidebarStateRef.current.sidebarScrollTop); + setMessagesScrollTop(initialSidebarStateRef.current.messagesScrollTop); + setBottomSheetSnapIndex(initialSidebarStateRef.current.bottomSheetSnapIndex); }, [teamName]); useEffect(() => { @@ -251,9 +270,10 @@ export const MessagesPanel = memo(function MessagesPanel({ messagesFilter, messagesFilterOpen, messagesCollapsed, - sidebarSearchVisible, + messagesSearchBarVisible, expandedItemKey, - sidebarScrollTop, + messagesScrollTop, + bottomSheetSnapIndex, }); }, [ teamName, @@ -261,17 +281,52 @@ export const MessagesPanel = memo(function MessagesPanel({ messagesFilter, messagesFilterOpen, messagesCollapsed, - sidebarSearchVisible, + messagesSearchBarVisible, expandedItemKey, - sidebarScrollTop, + messagesScrollTop, + bottomSheetSnapIndex, ]); useLayoutEffect(() => { if (position !== 'sidebar') return; const el = sidebarScrollRef.current; if (!el) return; - el.scrollTop = sidebarScrollTop; - }, [position, sidebarScrollTop]); + el.scrollTop = messagesScrollTop; + }, [position, messagesScrollTop]); + + useLayoutEffect(() => { + if (position !== 'bottom-sheet' || typeof ResizeObserver === 'undefined') return; + + const mountPointElement = mountPoint instanceof HTMLElement ? mountPoint : null; + const observedEntries: [Element | null, (height: number) => void][] = [ + [bottomSheetStickyTopRef.current, setBottomSheetStickyTopHeight], + [mountPointElement, setBottomSheetMountHeight], + ]; + const observers: ResizeObserver[] = []; + + for (const [element, setHeight] of observedEntries) { + if (!element) continue; + + const updateHeight = () => { + const nextHeight = Math.ceil(element.getBoundingClientRect().height); + if (nextHeight > 0) { + setHeight(nextHeight); + } + }; + + updateHeight(); + + const observer = new ResizeObserver(() => { + updateHeight(); + }); + observer.observe(element); + observers.push(observer); + } + + return () => { + observers.forEach((observer) => observer.disconnect()); + }; + }, [position, mountPoint]); const filteredMessages = useMemo(() => { const startedAt = performance.now(); @@ -348,7 +403,7 @@ export const MessagesPanel = memo(function MessagesPanel({ ); } return result; - }, [expandedItemKey, activityTimelineMessages]); + }, [expandedItemKey, activityTimelineMessages, teamName]); // Auto-clear stale expanded key useEffect(() => { @@ -461,6 +516,60 @@ export const MessagesPanel = memo(function MessagesPanel({ [teamName, sendCrossTeamMessage] ); + const moveToInline = useCallback(() => { + onPositionChange('inline'); + }, [onPositionChange]); + + const moveToSidebar = useCallback(() => { + onPositionChange('sidebar'); + }, [onPositionChange]); + + const moveToBottomSheet = useCallback(() => { + setBottomSheetSnapIndex(BOTTOM_SHEET_COMPOSER_SNAP_INDEX); + onPositionChange('bottom-sheet'); + }, [onPositionChange]); + + const snapBottomSheetTo = useCallback((snapIndex: number) => { + setBottomSheetSnapIndex(snapIndex); + bottomSheetRef.current?.snapTo(snapIndex); + }, []); + + const toggleBottomSheetExpansion = useCallback(() => { + if (bottomSheetSnapIndex === BOTTOM_SHEET_COLLAPSED_SNAP_INDEX) { + snapBottomSheetTo(BOTTOM_SHEET_COMPOSER_SNAP_INDEX); + return; + } + snapBottomSheetTo(BOTTOM_SHEET_COLLAPSED_SNAP_INDEX); + }, [bottomSheetSnapIndex, snapBottomSheetTo]); + + const bottomSheetSnapPoints = useMemo(() => { + const maxOpenHeight = + bottomSheetMountHeight > 0 + ? Math.max(bottomSheetMountHeight - 1, 96) + : Number.POSITIVE_INFINITY; + const collapsedHeight = Math.min(BOTTOM_SHEET_HEADER_HEIGHT, maxOpenHeight); + const composerHeight = Math.min( + Math.max(collapsedHeight + bottomSheetStickyTopHeight, collapsedHeight + 120), + maxOpenHeight + ); + const centeredHeight = Math.min( + Math.max( + bottomSheetMountHeight > 0 ? Math.round(bottomSheetMountHeight * 0.58) : 520, + composerHeight + 140 + ), + maxOpenHeight + ); + + return [0, collapsedHeight, composerHeight, centeredHeight, 1]; + }, [bottomSheetMountHeight, bottomSheetStickyTopHeight]); + + const normalizedBottomSheetSnapIndex = useMemo(() => { + return Math.min( + Math.max(bottomSheetSnapIndex, BOTTOM_SHEET_COLLAPSED_SNAP_INDEX), + BOTTOM_SHEET_FULL_SNAP_INDEX + ); + }, [bottomSheetSnapIndex]); + // ---- Shared content (used in both modes) ---- const searchAndFilterControls = (
@@ -539,6 +648,7 @@ export const MessagesPanel = memo(function MessagesPanel({ tasks={tasks} messages={effectiveMessages} pendingRepliesByMember={pendingRepliesByMember} + layout="flow" position="inline" onMemberClick={onMemberClick} onTaskClick={onTaskClick} @@ -602,9 +712,9 @@ export const MessagesPanel = memo(function MessagesPanel({ // ---- Sidebar mode ---- if (position === 'sidebar') { return ( -
+
{/* Header */} -
+
Messages {filteredMessages.length > 0 && ( @@ -650,6 +760,7 @@ export const MessagesPanel = memo(function MessagesPanel({ size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" onClick={() => setMessagesCollapsed((v) => !v)} + aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} > {messagesCollapsed ? : } @@ -664,13 +775,16 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" - onClick={() => setSidebarSearchVisible((v) => !v)} + onClick={() => setMessagesSearchBarVisible((v) => !v)} + aria-label={ + messagesSearchBarVisible ? 'Hide message search' : 'Show message search' + } > - {sidebarSearchVisible ? : } + {messagesSearchBarVisible ? : } - {sidebarSearchVisible ? 'Hide search' : 'Search messages'} + {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} @@ -679,7 +793,8 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" - onClick={onTogglePosition} + onClick={moveToInline} + aria-label="Move messages to inline panel" > @@ -689,7 +804,7 @@ export const MessagesPanel = memo(function MessagesPanel({
{/* Search & filter bar (toggleable) */} - {sidebarSearchVisible && ( + {messagesSearchBarVisible && (
{searchAndFilterControls}
@@ -698,7 +813,7 @@ export const MessagesPanel = memo(function MessagesPanel({
setSidebarScrollTop(e.currentTarget.scrollTop)} + onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)} >