commit
5d564958f9
311 changed files with 33582 additions and 3696 deletions
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -23,7 +23,7 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.</sub>
|
||||
<sub>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.</sub>
|
||||
</p>
|
||||
|
||||
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/be19cfcb-93ff-403a-9a1e-8ff1a803c55e" />
|
||||
|
|
@ -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.
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
|
|
@ -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
|
||||
|
||||
<details>
|
||||
<summary><strong>Do I need to install Claude Code before using this app?</strong></summary>
|
||||
<summary><strong>Do I need to install a runtime before using this app?</strong></summary>
|
||||
<br />
|
||||
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.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Does it read or upload my code?</strong></summary>
|
||||
<br />
|
||||
No. Everything runs locally. The app reads Claude Code's session logs from <code>~/.claude/</code> — 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.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can agents communicate with each other?</strong></summary>
|
||||
<br />
|
||||
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.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Is it free?</strong></summary>
|
||||
<br />
|
||||
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.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -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.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I use it just to view past sessions without running agents?</strong></summary>
|
||||
<br />
|
||||
Yes. The app works as a session viewer — browse, search, and analyze any Claude Code session history.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Does it support multiple projects and teams?</strong></summary>
|
||||
<br />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
14
bun.lock
14
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=="],
|
||||
|
|
|
|||
|
|
@ -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**: заранее фиксируем критерии готовности и ручную проверку
|
||||
|
||||
|
|
|
|||
|
|
@ -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` — быть безопасным при частых вызовах.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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с
|
||||
|
||||
|
|
|
|||
2630
docs/iterations/iteration-07-task-logs-explicit-board-task-links.md
Normal file
2630
docs/iterations/iteration-07-task-logs-explicit-board-task-links.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
192
docs/iterations/schemas/board-task-transcript-v1.schema.json
Normal file
192
docs/iterations/schemas/board-task-transcript-v1.schema.json
Normal file
|
|
@ -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
|
||||
}
|
||||
484
docs/research/real-competitors-comparison.md
Normal file
484
docs/research/real-competitors-comparison.md
Normal file
|
|
@ -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: <https://github.com/gastownhall/gastown>
|
||||
- README: <https://github.com/gastownhall/gastown/blob/main/README.md>
|
||||
- Latest release: <https://github.com/gastownhall/gastown/releases/latest>
|
||||
|
||||
### Claude Code Agent Teams
|
||||
|
||||
- Agent Teams docs: <https://code.claude.com/docs/en/agent-teams>
|
||||
- CLI auth docs: <https://code.claude.com/docs/en/cli-usage>
|
||||
- Claude Code repo: <https://github.com/anthropics/claude-code>
|
||||
- Latest release: <https://github.com/anthropics/claude-code/releases/latest>
|
||||
|
||||
### GoClaw
|
||||
|
||||
- Official repo: <https://github.com/nextlevelbuilder/goclaw>
|
||||
- README: <https://github.com/nextlevelbuilder/goclaw/blob/dev/README.md>
|
||||
- Full docs export: <https://docs.goclaw.sh/llms-full.txt>
|
||||
- Latest release: <https://github.com/nextlevelbuilder/goclaw/releases/latest>
|
||||
|
||||
## 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-команды, чем у этих трёх систем.
|
||||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
250
packages/agent-graph/src/canvas/draw-activity-lanes.ts
Normal file
250
packages/agent-graph/src/canvas/draw-activity-lanes.ts
Normal file
|
|
@ -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<string> | 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<string> | 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -24,11 +24,14 @@ export function drawAgents(
|
|||
nodes: GraphNode[],
|
||||
time: number,
|
||||
selectedId: string | null,
|
||||
hoveredId: string | null
|
||||
hoveredId: string | null,
|
||||
focusNodeIds?: ReadonlySet<string> | 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<string> | 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<string> | null): number {
|
||||
return focusNodeIds && !focusNodeIds.has(nodeId) ? 0.25 : 1;
|
||||
}
|
||||
|
||||
function drawExceptionPip(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
tone: NonNullable<GraphNode['exceptionTone']>
|
||||
): 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)';
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
|
|||
|
||||
// ─── Edge Type → Color/Width Mapping ────────────────────────────────────────
|
||||
|
||||
const EDGE_STYLES: Record<GraphEdgeType, { color: string; startW: number; endW: number; dash?: number[] }> = {
|
||||
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<string, GraphNode>,
|
||||
_time: number,
|
||||
hasActiveParticles: Set<string>,
|
||||
focusEdgeIds?: ReadonlySet<string> | 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();
|
||||
|
|
|
|||
266
packages/agent-graph/src/canvas/draw-handoff-cards.ts
Normal file
266
packages/agent-graph/src/canvas/draw-handoff-cards.ts
Normal file
|
|
@ -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<string, GraphNode>;
|
||||
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<string, number>();
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -27,8 +27,10 @@ export function drawParticles(
|
|||
edgeMap: Map<string, GraphEdge>,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
time: number,
|
||||
focusEdgeIds?: ReadonlySet<string> | null,
|
||||
): void {
|
||||
for (const p of particles) {
|
||||
if (focusEdgeIds && !focusEdgeIds.has(p.edgeId)) continue;
|
||||
const edge = edgeMap.get(p.edgeId);
|
||||
if (!edge) continue;
|
||||
|
||||
|
|
|
|||
78
packages/agent-graph/src/canvas/draw-pill-shell.ts
Normal file
78
packages/agent-graph/src/canvas/draw-pill-shell.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -17,7 +17,10 @@ export function drawProcesses(
|
|||
time: number,
|
||||
selectedId: string | null,
|
||||
hoveredId: string | null,
|
||||
focusNodeIds?: ReadonlySet<string> | 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> | 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<string> | 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__') {
|
||||
|
|
|
|||
|
|
@ -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<GraphEdge['type'], number> = {
|
||||
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<string, GraphNode>,
|
||||
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, GraphNode>,
|
||||
): 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<string, GraphNode>
|
||||
): { 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ForceNode> {
|
||||
|
|
@ -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<string, ForceNode>();
|
||||
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<string, { x: number; y: number }>,
|
||||
activityPositions: Map<string, { x: number; y: number }>,
|
||||
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<Simulation<ForceNode, ForceLink> | null>(null);
|
||||
const launchAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const activityAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
|
||||
|
||||
// Initialize d3-force simulation
|
||||
const initSimulation = useCallback(() => {
|
||||
|
|
@ -84,9 +171,18 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
const sim = forceSimulation<ForceNode, ForceLink>([])
|
||||
.force('center', forceCenter(0, 0).strength(FORCE.centerStrength))
|
||||
.force('charge', forceManyBody<ForceNode>().strength((d) => {
|
||||
if (d.kind === 'launch-anchor' || d.kind === 'activity-anchor') {
|
||||
return 0;
|
||||
}
|
||||
return getNodeStrategy(d.kind).getChargeStrength();
|
||||
}))
|
||||
.force('collide', forceCollide<ForceNode>().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<ForceNode, 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<string, { x: number; y: number }>();
|
||||
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<ForceNode, ForceLink> | null,
|
||||
dt: number,
|
||||
launchAnchorPositions: Map<string, { x: number; y: number }>,
|
||||
activityAnchorPositions: Map<string, { x: number; y: number }>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ export type {
|
|||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphParticle,
|
||||
GraphActivityItem,
|
||||
GraphNodeKind,
|
||||
GraphNodeState,
|
||||
GraphLaunchVisualState,
|
||||
GraphEdgeType,
|
||||
GraphParticleKind,
|
||||
GraphDomainRef,
|
||||
|
|
|
|||
196
packages/agent-graph/src/layout/activityLane.ts
Normal file
196
packages/agent-graph/src/layout/activityLane.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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<string, { label: string; color: string }> = {
|
|||
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<string, GraphNode>();
|
||||
|
|
@ -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,
|
||||
}],
|
||||
});
|
||||
|
|
|
|||
117
packages/agent-graph/src/layout/launchAnchor.ts
Normal file
117
packages/agent-graph/src/layout/launchAnchor.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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<GraphParticleKind, 'spawn'>;
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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<string> | null;
|
||||
focusEdgeIds: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
export interface GraphCanvasHandle {
|
||||
|
|
@ -65,16 +88,24 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
onContextMenu,
|
||||
className,
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bloomRef = useRef<BloomRenderer>(new BloomRenderer(bloomIntensity));
|
||||
const starsRef = useRef<DepthParticle[]>([]);
|
||||
const shootingStarsRef = useRef<ShootingStarField>(createShootingStarField());
|
||||
const sizeRef = useRef({ w: 0, h: 0 });
|
||||
const lastBackgroundTimeRef = useRef<number | null>(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<GraphCanvasHandle, GraphCanvasProps>(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<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
const visibleNodesCache = useRef<GraphNode[]>([]);
|
||||
const visibleEdgesCache = useRef<GraphEdge[]>([]);
|
||||
const visibleNodeIdsCache = useRef(new Set<string>());
|
||||
const visibleEdgeIdsCache = useRef(new Set<string>());
|
||||
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||
const handoffStateRef = useRef(createTransientHandoffState());
|
||||
const lastTeamNameRef = useRef<string | null>(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<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
}, [onWheel]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
|
||||
<div ref={containerRef} className={`relative h-full w-full overflow-hidden ${className ?? ''}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import {
|
||||
Columns3,
|
||||
Expand,
|
||||
|
|
@ -14,7 +15,9 @@ import {
|
|||
Pause,
|
||||
Pin,
|
||||
Play,
|
||||
Plus,
|
||||
Server,
|
||||
Users,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
|
|
@ -36,11 +39,16 @@ export interface GraphControlsProps {
|
|||
onRequestClose?: () => 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<HTMLDivElement>(null);
|
||||
|
|
@ -91,26 +99,44 @@ export function GraphControls({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute left-20 top-3 z-10 flex items-center gap-3 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
{isAlive && (
|
||||
<div className="size-2 rounded-full animate-pulse" style={{ background: nameColor }} />
|
||||
)}
|
||||
<span className="text-xs font-mono font-semibold" style={{ color: nameColor }}>
|
||||
{teamName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
||||
{onOpenTeamPage ? (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={onOpenTeamPage}
|
||||
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Open team page"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{onCreateTask ? (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={onCreateTask}
|
||||
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Create task"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2 pointer-events-none">
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-lg px-1 py-0.5 backdrop-blur-sm"
|
||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
|
|
@ -118,13 +144,15 @@ export function GraphControls({
|
|||
>
|
||||
<ToolbarButton
|
||||
onClick={() => toggle('paused')}
|
||||
icon={filters.paused ? <Play size={14} /> : <Pause size={14} />}
|
||||
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={settingsRef} className="relative pointer-events-auto">
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
|
||||
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
|
|
@ -132,9 +160,10 @@ export function GraphControls({
|
|||
>
|
||||
<ToolbarButton
|
||||
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||
icon={<Settings2 size={14} />}
|
||||
label="View"
|
||||
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
||||
active={isSettingsOpen}
|
||||
toolbar
|
||||
title="Graph settings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -172,35 +201,50 @@ export function GraphControls({
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
|
||||
className="pointer-events-auto flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
{onRequestPinAsTab && <ToolbarButton onClick={onRequestPinAsTab} icon={<Pin size={13} />} />}
|
||||
{onRequestPinAsTab && (
|
||||
<ToolbarButton
|
||||
onClick={onRequestPinAsTab}
|
||||
icon={<Pin size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Pin as tab"
|
||||
/>
|
||||
)}
|
||||
{onRequestFullscreen && (
|
||||
<ToolbarButton
|
||||
onClick={onRequestFullscreen}
|
||||
icon={<Expand size={13} />}
|
||||
label="Fullscreen"
|
||||
icon={<Expand size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Fullscreen"
|
||||
/>
|
||||
)}
|
||||
{onRequestClose && (
|
||||
<ToolbarButton
|
||||
onClick={onRequestClose}
|
||||
icon={<X size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Close graph"
|
||||
/>
|
||||
)}
|
||||
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={13} />} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-3 right-3 z-10 pointer-events-none">
|
||||
<div className="absolute bottom-3 right-3 z-20 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
|
||||
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-0.5 py-[2px] backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.86)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={14} />} />
|
||||
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={14} />} label="Fit" />
|
||||
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={14} />} />
|
||||
<ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={11} />} compact />
|
||||
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={11} />} label="Fit" compact />
|
||||
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={11} />} compact />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -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 = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-mono transition-colors cursor-pointer ${
|
||||
aria-label={title}
|
||||
style={
|
||||
toolbar
|
||||
? {
|
||||
width: TOPBAR_BUTTON_SIZE,
|
||||
height: TOPBAR_BUTTON_SIZE,
|
||||
minWidth: TOPBAR_BUTTON_SIZE,
|
||||
minHeight: TOPBAR_BUTTON_SIZE,
|
||||
padding: 0,
|
||||
}
|
||||
: mini
|
||||
? {
|
||||
width: 16,
|
||||
height: 16,
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
padding: 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`flex items-center rounded-md font-mono transition-colors cursor-pointer ${
|
||||
toolbar
|
||||
? 'justify-center text-[0]'
|
||||
: mini
|
||||
? 'justify-center text-[0]'
|
||||
: compact
|
||||
? 'gap-0.5 px-1 py-0.5 text-[9px]'
|
||||
: 'gap-1 px-2 py-1 text-[11px]'
|
||||
} ${
|
||||
active
|
||||
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]'
|
||||
: 'text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)]'
|
||||
|
|
@ -233,6 +313,26 @@ function ToolbarButton({
|
|||
{label && <span>{label}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!title) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Root delayDuration={180}>
|
||||
<Tooltip.Trigger asChild>{button}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="z-[100] rounded-md border border-[rgba(100,200,255,0.14)] bg-[rgba(8,12,24,0.96)] px-2 py-1 text-[11px] font-mono text-[#dff6ff] shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
{title}
|
||||
<Tooltip.Arrow className="fill-[rgba(8,12,24,0.96)]" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarToggle({
|
||||
|
|
|
|||
66
packages/agent-graph/src/ui/GraphEdgeOverlay.tsx
Normal file
66
packages/agent-graph/src/ui/GraphEdgeOverlay.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="rounded-lg p-3 min-w-[180px] max-w-[240px] shadow-xl"
|
||||
style={{
|
||||
background: 'rgba(10, 15, 30, 0.92)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.14em]" style={{ color: '#66ccff90' }}>
|
||||
{getEdgeTypeLabel(edge.type)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-mono font-bold" style={{ color: edge.color ?? '#aaeeff' }}>
|
||||
{sourceNode?.label ?? edge.source} -> {targetNode?.label ?? edge.target}
|
||||
</div>
|
||||
{edge.label && (
|
||||
<div className="mt-1 text-[10px] leading-relaxed" style={{ color: '#d7f2ffcc' }}>
|
||||
{edge.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex gap-1">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[10px] px-2 py-1 rounded font-mono cursor-pointer"
|
||||
style={{
|
||||
background: 'rgba(100, 200, 255, 0.08)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.15)',
|
||||
color: '#aaeeff',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string> | 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<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<GraphFilterState>({
|
||||
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<string | null>(null);
|
||||
selectedNodeIdRef.current = selectedNodeId;
|
||||
const selectedEdgeIdRef = useRef<string | null>(null);
|
||||
selectedEdgeIdRef.current = selectedEdgeId;
|
||||
const hoveredEdgeIdRef = useRef<string | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasHandle = useRef<GraphCanvasHandle>(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<string, GraphNode>());
|
||||
const nodeMapNodesRef = useRef<GraphNode[] | null>(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<string, GraphNode> => {
|
||||
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 (
|
||||
<div ref={containerRef} className={`relative w-full h-full ${className ?? ''}`}>
|
||||
<div ref={containerRef} className={`relative h-full w-full overflow-hidden ${className ?? ''}`}>
|
||||
<GraphCanvas
|
||||
ref={canvasHandle}
|
||||
showHexGrid={config?.showHexGrid ?? true}
|
||||
|
|
@ -439,31 +687,72 @@ export function GraphView({
|
|||
onZoomToFit={() => {
|
||||
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 && (
|
||||
<div ref={overlayRef} className="fixed z-20 pointer-events-auto">
|
||||
{renderOverlay ? (
|
||||
renderOverlay({
|
||||
node: selectedNode,
|
||||
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
|
||||
onClose: () => setSelectedNodeId(null),
|
||||
})
|
||||
) : (
|
||||
<GraphOverlay
|
||||
selectedNode={selectedNode}
|
||||
events={events}
|
||||
onDeselect={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
{renderHud ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
|
||||
{renderHud({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition,
|
||||
focusNodeIds: focusState.focusNodeIds,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(selectedNode || selectedEdge) && (
|
||||
<div ref={overlayRef} className="pointer-events-auto fixed z-20">
|
||||
{selectedNode ? (
|
||||
renderOverlay ? (
|
||||
renderOverlay({
|
||||
node: selectedNode,
|
||||
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
|
||||
onClose: () => setSelectedNodeId(null),
|
||||
})
|
||||
) : (
|
||||
<GraphOverlay
|
||||
selectedNode={selectedNode}
|
||||
events={events}
|
||||
onDeselect={() => 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);
|
||||
},
|
||||
})
|
||||
) : (
|
||||
<GraphEdgeOverlay
|
||||
edge={selectedEdge}
|
||||
sourceNode={selectedEdgeNodeMap.get(selectedEdge.source)}
|
||||
targetNode={selectedEdgeNodeMap.get(selectedEdge.target)}
|
||||
onClose={() => setSelectedEdgeId(null)}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
239
packages/agent-graph/src/ui/buildFocusState.ts
Normal file
239
packages/agent-graph/src/ui/buildFocusState.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import type { GraphEdge, GraphNode } from '../ports/types';
|
||||
|
||||
export interface GraphFocusState {
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
focusEdgeIds: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
function addNode(nodeIds: Set<string>, nodeId: string | null | undefined): void {
|
||||
if (nodeId) {
|
||||
nodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
function addNodeAndIncidentEdges(
|
||||
nodeIds: Set<string>,
|
||||
edgeIds: Set<string>,
|
||||
nodeId: string | null | undefined,
|
||||
adjacency: Map<string, GraphEdge[]>
|
||||
): 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<string, GraphEdge[]>();
|
||||
|
||||
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<string>([selectedEdge.source, selectedEdge.target]);
|
||||
const edgeIds = new Set<string>([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<string>([selectedNode.id]);
|
||||
const edgeIds = new Set<string>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
109
packages/agent-graph/src/ui/selectRenderableParticles.ts
Normal file
109
packages/agent-graph/src/ui/selectRenderableParticles.ts
Normal file
|
|
@ -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<T>(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<string>;
|
||||
focusEdgeIds?: ReadonlySet<string> | 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<string>();
|
||||
const seenEdges = new Set<string>();
|
||||
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);
|
||||
}
|
||||
163
packages/agent-graph/src/ui/transientHandoffs.ts
Normal file
163
packages/agent-graph/src/ui/transientHandoffs.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { HANDOFF_CARD } from '../constants/canvas-constants';
|
||||
import type { GraphEdge, GraphNode, GraphParticle, GraphParticleKind } from '../ports/types';
|
||||
|
||||
type HandoffParticleKind = Exclude<GraphParticleKind, 'spawn'>;
|
||||
|
||||
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<string, TransientHandoffCard>;
|
||||
triggeredParticleIds: Set<string>;
|
||||
}
|
||||
|
||||
export function createTransientHandoffState(): TransientHandoffState {
|
||||
return {
|
||||
cardsByKey: new Map<string, TransientHandoffCard>(),
|
||||
triggeredParticleIds: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTransientHandoffState(
|
||||
state: TransientHandoffState,
|
||||
params: {
|
||||
particles: GraphParticle[];
|
||||
edgeMap: Map<string, GraphEdge>;
|
||||
nodeMap: Map<string, GraphNode>;
|
||||
time: number;
|
||||
}
|
||||
): void {
|
||||
const { particles, edgeMap, nodeMap, time } = params;
|
||||
|
||||
const activeParticleIds = new Set<string>();
|
||||
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<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | 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<string, TransientHandoffCard[]>();
|
||||
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<string> | null,
|
||||
focusEdgeIds: ReadonlySet<string> | 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';
|
||||
}
|
||||
138
pnpm-lock.yaml
138
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
92
scripts/diagnose-task-log-stream.ts
Normal file
92
scripts/diagnose-task-log-stream.ts
Normal file
|
|
@ -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 <team-name> <task-id-or-display-id> [--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<void> {
|
||||
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;
|
||||
});
|
||||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
teamProvisioningService,
|
||||
teamMemberLogsFinder,
|
||||
memberStatsComputer,
|
||||
boardTaskActivityService,
|
||||
boardTaskActivityDetailService,
|
||||
boardTaskLogStreamService,
|
||||
boardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService,
|
||||
teammateToolTracker ?? undefined,
|
||||
branchStatusService ?? undefined,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Array<{ name: string; role?: string }>> {
|
||||
): 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<T>(
|
||||
operation: string,
|
||||
handler: () => Promise<T>
|
||||
|
|
@ -1371,7 +1446,7 @@ async function handlePrepareProvisioning(
|
|||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||||
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<IpcResult<BoardTaskActivityEntry[]>> {
|
||||
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<IpcResult<BoardTaskActivityDetailResult>> {
|
||||
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<IpcResult<BoardTaskLogStreamResponse>> {
|
||||
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<IpcResult<BoardTaskExactLogSummariesResponse>> {
|
||||
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<IpcResult<BoardTaskExactLogDetailResult>> {
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ async function handleSpawn(
|
|||
options?: PtySpawnOptions
|
||||
): Promise<IpcResult<string>> {
|
||||
try {
|
||||
const id = service.spawn(options);
|
||||
const id = await service.spawn(options);
|
||||
return { success: true, data: id };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppendedParseResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<string, string>,
|
||||
env: env as Record<string, string>,
|
||||
});
|
||||
|
||||
pty.onData((data) => this.send(TERMINAL_DATA, id, data));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set<string
|
|||
|
||||
for (const rawLine of metadataText.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
const match = line.match(/^(?:-\s+)?(url|path):\s+(.+)$/u);
|
||||
const match = /^(?:-\s+)?(url|path):\s+(.+)$/u.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import {
|
||||
getCachedShellEnv,
|
||||
getShellPreferredHome,
|
||||
resolveInteractiveShellEnv,
|
||||
} from '@main/utils/shellEnv';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||
import { providerConnectionService } from './ProviderConnectionService';
|
||||
import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
|
|
@ -151,38 +144,29 @@ function extractModelIds(
|
|||
return [];
|
||||
}
|
||||
|
||||
return models.flatMap((model) => {
|
||||
return models.flatMap<string>((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<NodeJS.ProcessEnv> {
|
||||
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<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||
return buildProviderAwareCliEnv({ binaryPath });
|
||||
}
|
||||
|
||||
private async buildProviderCliEnv(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
return applyProviderRuntimeEnv({ ...(await this.buildCliEnv(binaryPath)) }, providerId);
|
||||
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||
return buildProviderAwareCliEnv({ binaryPath, providerId });
|
||||
}
|
||||
|
||||
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
|
||||
|
|
@ -252,12 +236,38 @@ export class ClaudeMultimodelBridgeService {
|
|||
};
|
||||
}
|
||||
|
||||
private applyConnectionIssue(
|
||||
provider: CliProviderStatus,
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>
|
||||
): 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<Record<CliProviderId, string>>
|
||||
): CliProviderStatus[] {
|
||||
return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues));
|
||||
}
|
||||
|
||||
async getProviderStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<CliProviderStatus> {
|
||||
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<UnifiedRuntimeStatusResponse>(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<CliProviderStatus> {
|
||||
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<CliProviderStatus[]> {
|
||||
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<UnifiedRuntimeStatusResponse>(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);
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,108 @@ export class ProviderConnectionService {
|
|||
return nextEnv;
|
||||
}
|
||||
|
||||
async augmentConfiguredConnectionEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: CliProviderId
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
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<NodeJS.ProcessEnv> {
|
||||
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<string | null> {
|
||||
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<Partial<Record<CliProviderId, string>>> {
|
||||
const issues: Partial<Record<CliProviderId, string>> = {};
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
const issue = await this.getConfiguredConnectionIssue(env, providerId);
|
||||
if (issue) {
|
||||
issues[providerId] = issue;
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
|
||||
return {
|
||||
...provider,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
105
src/main/services/runtime/providerAwareCliEnv.ts
Normal file
105
src/main/services/runtime/providerAwareCliEnv.ts
Normal file
|
|
@ -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<Record<CliProviderId, string>>;
|
||||
}
|
||||
|
||||
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<ProviderAwareCliEnvResult> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, unknown>): string {
|
||||
|
|
@ -102,7 +105,7 @@ export class TaskBoundaryParser {
|
|||
const b = block as Record<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown> | undefined;
|
||||
if (!input) continue;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Array<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }>> {
|
||||
): 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<string, unknown> | undefined;
|
||||
if (!input) continue;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof spawn>;
|
||||
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<typeof spawn>;
|
||||
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<string | null> {
|
||||
|
|
|
|||
43
src/main/services/team/agentTeamsToolNames.ts
Normal file
43
src/main/services/team/agentTeamsToolNames.ts
Normal file
|
|
@ -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<string>(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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<boolean> | null = null;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
if (toolName === 'task_add_comment' || toolName === 'task_get_comment') {
|
||||
const comment = payload.comment as Record<string, unknown> | 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<ContentBlock, { type: 'text' }> =>
|
||||
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<T extends ContentBlock>(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<ContentBlock, { type: 'tool_result' }> =>
|
||||
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<string, unknown> & {
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
} = { ...(rawToolUseResult as Record<string, unknown>) };
|
||||
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<BoardTaskActivityDetailResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
interface CacheEntry<T> {
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export class BoardTaskActivityParseCache<T> {
|
||||
private readonly cache = new Map<string, CacheEntry<T>>();
|
||||
private readonly inFlight = new Map<string, Promise<T>>();
|
||||
|
||||
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<T> | null {
|
||||
return this.inFlight.get(filePath) ?? null;
|
||||
}
|
||||
|
||||
setInFlight(filePath: string, promise: Promise<T>): 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<string>): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, TeamTask>;
|
||||
byDisplayId: Map<string, TeamTask[]>;
|
||||
}
|
||||
|
||||
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<string, string | number | undefined> = {}
|
||||
): 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<string, TeamTask>();
|
||||
const byDisplayId = new Map<string, TeamTask[]>();
|
||||
|
||||
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<string, ParsedBoardTaskToolAction> {
|
||||
const actionMap = new Map<string, ParsedBoardTaskToolAction>();
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BoardTaskActivityRecord[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BoardTaskActivityEntry[]> {
|
||||
if (!isBoardTaskActivityReadEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const records = await this.recordSource.getTaskRecords(teamName, taskId);
|
||||
return this.entryBuilder.buildFromRecords(records);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue