diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ecd1659e..ef24b2b9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for contributing to Claude Agent Teams UI! ## Before You Start -For big features and major changes, please discuss them in our [Discord](https://discord.gg/qtqSZSyuEc) first so we can figure out the best approach together and avoid conflicts. +For big features and major changes, please discuss them in our [Discord](https://discord.gg/qtqSZSyuEc) first: https://discord.gg/qtqSZSyuEc so we can figure out the best approach together and avoid conflicts. Small fixes, bug reports, and minor improvements are always welcome - just open a PR. diff --git a/README.md b/README.md index 0444fdef..871f52b0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- 100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents. + 100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions/logins or API keys where supported. Not just coding agents.

demo @@ -48,7 +48,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42 ## Installation -No prerequisites — Claude Code can be installed and configured directly from the app. +No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI. @@ -105,8 +105,9 @@ No prerequisites — Claude Code can be installed and configured directly from t ## What is this -A new approach to task management with AI agent teams. +A local orchestration layer for AI agent teams across Claude and Codex. +- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions/logins or API keys where supported - **Assemble your team** — create agent teams with different roles that work autonomously in parallel - **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments - **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams @@ -114,7 +115,7 @@ A new approach to task management with AI agent teams. - **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment - **Built-in review workflow** — easily see how agents review each other's tasks to make sure everything went exactly as planned - **Full tool visibility** — inspect exactly which tools an agent used to complete each task -- **Task-specific logs and messages** — clearly see all Claude logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment +- **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment - **Live process section** — see which agents are running processes and open URLs directly in the browser - **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work - **Flexible autonomy** — let agents run fully autonomous, or review and approve each action one by one (you'll get a notification) — configure the level of control that fits your security needs @@ -125,15 +126,15 @@ A new approach to task management with AI agent teams. - **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context. -- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses +- **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses -- **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks +- **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks - **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size. - **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place -- **Zero-setup onboarding** — built-in Claude Code installation and authentication +- **Zero-setup onboarding** — built-in runtime detection and provider authentication - **Built-in code editor** — edit project files with Git support without leaving the app @@ -147,7 +148,7 @@ A new approach to task management with AI agent teams. - **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box -- **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost +- **Post-compact context recovery** — when the active runtime compacts its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost - **Task context is preserved** — thanks to task descriptions, comments, and attachments, all essential information about each task remains available for ongoing work and future reference @@ -177,14 +178,14 @@ A new approach to task management with AI agent teams. | **Flexible autonomy** | ✅ Granular settings, per-action approval, notifications | ❌ | ⚠️ Plan approval only | ✅ | ✅ | | **Git worktree isolation** | ✅ Optional | ⚠️ Mandatory | ⚠️ Mandatory | ✅ | ✅ | | **Multi-agent backend** | ✅ Claude, Codex, more coming soon | ✅ 6+ agents | ✅ 11 providers | ✅ Multi-model | — | -| **Price** | **Free** | Free / $30 user/mo | Free | $0–$200/mo | Claude subscription | +| **Price** | **Free** | Free / $30 user/mo | Free | $0–$200/mo | Provider subscription | --- ## Quick start 1. **Download** the app for your platform (see [Installation](#installation)) -2. **Launch** — On first run, the setup wizard will install and authenticate Claude Code +2. **Launch** — On first run, the setup wizard will detect the runtime and guide provider authentication 3. **Create a team** — Pick a project, define roles, write a provisioning prompt 4. **Watch** — Agents spawn, create tasks, and work. You see it all on the kanban board @@ -194,27 +195,27 @@ A new approach to task management with AI agent teams. ## FAQ
-Do I need to install Claude Code before using this app? +Do I need to install a runtime before using this app?
-No. The app includes built-in installation and authentication — just launch and follow the setup wizard. +No. The app guides runtime detection/setup and provider authentication from the UI - just launch and follow the setup wizard.
Does it read or upload my code?
-No. Everything runs locally. The app reads Claude Code's session logs from ~/.claude/ — your source code is never sent anywhere. +No. Everything runs locally. The app reads local runtime/session data to power the UI - your source code is never sent anywhere.
Can agents communicate with each other?
-Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated through Claude Code's team protocol. +Yes. Agents send direct messages, create shared tasks, and leave comments - all coordinated by the app's own orchestration layer.
Is it free?
-Yes, completely free and open source. The app requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents. +Yes, completely free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex.
@@ -229,12 +230,6 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment Send a direct message to course-correct, or stop and restart from the process dashboard. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
-
-Can I use it just to view past sessions without running agents? -
-Yes. The app works as a session viewer — browse, search, and analyze any Claude Code session history. -
-
Does it support multiple projects and teams?
diff --git a/docs/research/real-competitors-comparison.md b/docs/research/real-competitors-comparison.md new file mode 100644 index 00000000..b966a535 --- /dev/null +++ b/docs/research/real-competitors-comparison.md @@ -0,0 +1,484 @@ +# Реальные конкуренты для Comparison в README + +> Дата проверки: 2026-04-13 +> Статус: внутренний comparison draft +> Цель: заменить в нашем внутреннем thinking `Vibe Kanban` и `Aperant` на реальные ориентиры - `Gastown`, `Claude Code Agent Teams`, `GoClaw` + +## Что именно сравнивается + +В этом документе "мы" = не только README-маркетинг, а текущий продуктовый стек: + +- `claude_team` как frontend/workbench +- `agent_teams_orchestrator` как локальный runtime и task/review/log pipeline + +Сравнение идёт по тем же строкам, что уже есть в `Comparison` секции README, но с реальными конкурентами. + +## Как сравнивал + +- `✅` - фича есть как явная продуктовая возможность +- `⚠️` - фича есть частично, экспериментально, только вручную, только через CLI/TUI, или без сильного UI/UX +- `❌` - фича не задокументирована как продуктовая возможность или явно отсутствует + +Правило важное: + +- если capability есть только "под капотом" или через обходной workflow, это не `✅` +- для нашей стороны я учитывал не только README, но и реальный frontend/code surface +- для конкурентов брал только первичные источники: official docs, official GitHub repo, official releases + +## Короткий snapshot + +| Система | Позиционирование | GitHub / живость | Самое важное | +|---|---|---|---| +| **Claude Agent Teams UI** | local-first coding-team cockpit | `577★`, push `2026-04-12` | сильнейший UI для task logs, review, editor, live processes | +| **Gastown** | process-model multi-agent workspace manager | `13,931★`, latest `v1.0.0` от `2026-04-03` | сильный orchestration runtime, mailboxes, handoffs, git worktrees | +| **Claude Code Agent Teams** | нативные team lead + teammates внутри Claude Code | `113,180★` у `anthropics/claude-code`, latest `v2.1.104` от `2026-04-13` | самый нативный Claude-first team runtime, но без нашего UI-слоя | +| **GoClaw** | self-hosted multi-tenant agent platform | `2,634★`, latest `v3.6.0` от `2026-04-13` | самый широкий platform surface: kanban, approvals, providers, channels | + +## Feature matrix + +| Feature | Claude Agent Teams UI | Gastown | Claude Code Agent Teams | GoClaw | +|---|---|---|---|---| +| **Cross-team communication** | ✅ Native cross-team messaging between teams | ⚠️ Cross-rig coordination exists, but not a polished team-to-team chat surface | ❌ No documented team-to-team concept | ❌ Team-local messaging, no documented cross-team agent comms | +| **Agent-to-agent messaging** | ✅ Native mailbox-style teammate and lead messaging | ✅ Built-in mailboxes, identities, handoffs | ✅ Shared mailbox + direct teammate messaging | ✅ Team messaging, member-to-member messages | +| **Linked tasks** | ✅ `#task-id` references + task dependencies | ⚠️ Beads, convoys and deps exist, but linking UX is more operational than productized | ⚠️ Shared task list + dependencies, but minimal linking UX | ✅ Task numbers, search, `blocked_by`, comments, audit trail | +| **Session analysis** | ✅ Task-specific logs, exact task log matching, deep session analysis, token tracking | ⚠️ Event stream, seance, OTLP logs, but no rich per-session analytics UI | ❌ No dedicated session analysis surface | ⚠️ Traces, audit events and task detail exist, but not our depth of per-task session analysis | +| **Task attachments** | ✅ Task and comment attachments in team workflow | ❌ Not documented as a task feature | ❌ Not documented | ✅ Task attachments + media auto-copy into team workspace | +| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ❌ | +| **Built-in code editor** | ✅ Built-in editor with Git support | ❌ | ❌ | ❌ Workspace browser exists, but not a real built-in code editor | +| **Full autonomy** | ✅ Agents can create, assign, review and progress tasks end-to-end with human override | ✅ Mayor + convoy + witness/deacon orchestration | ⚠️ Strong autonomy, but feature is still experimental | ✅ Strong autonomous team/task orchestration | +| **Task dependencies (blocked by)** | ✅ Explicit task dependencies and ordering | ✅ Beads deps / blocked work exist | ✅ Dependencies unblock automatically | ✅ `blocked_by`, blocked lifecycle, retry, stale handling | +| **Review workflow** | ✅ Agent peer review + human review UI | ⚠️ Merge/review workflows exist, but not as a productized task review cockpit | ⚠️ Plan approval + hooks, but no rich review board | ✅ `in_review`, approve/reject, reviewer-agent gates | +| **Zero setup** | ✅ Claude Code install + auth from the app | ❌ Many prerequisites and workspace bootstrap steps | ❌ Claude Code install + experimental flag required | ❌ Standard setup needs infra/provider config; Lite is easier but still not zero-setup | +| **Kanban board** | ✅ Real-time board | ❌ Dashboard overview, not Kanban | ❌ Shared task list, no Kanban board | ✅ Dashboard Kanban board | +| **Execution log viewer** | ✅ Task log panels, exact logs, stream, timeline | ⚠️ Feed/dashboard/event logs exist, but not a task log cockpit | ❌ No dedicated log viewer | ⚠️ Trace spans + task events/comments, but not strong raw per-task execution logs | +| **Live processes** | ✅ View, stop, inspect, open URLs | ⚠️ Agent/session monitoring exists, but not a developer process cockpit | ⚠️ Split panes let you watch sessions, but there is no processes dashboard | ❌ No comparable live-process UI surfaced like ours | +| **Per-task code review** | ✅ Per-task diff review with accept / reject / comment flow | ❌ | ❌ | ⚠️ Task approval exists, but not inline code diff review | +| **Flexible autonomy** | ✅ Granular approvals, notifications, autonomy controls | ✅ Strong human gates, escalation and intervention, mostly via CLI/TUI | ⚠️ Plan approval, hooks and permissions exist, but control plane is thin | ✅ Team settings, approval workflows, exec approval, task approval | +| **Git worktree isolation** | ✅ Optional per-agent worktree strategy | ✅ Core architectural primitive | ⚠️ Manual worktrees exist in Claude Code, but not as the native team model | ❌ Not a core team isolation model | +| **Multi-agent backend** | ⚠️ Claude is mature; Codex/Gemini plumbing exists in code but is still emerging as product surface | ✅ Claude Code, Codex, Gemini, Copilot and other runtimes | ❌ Claude-first only, models per teammate but no real multi-provider backend | ✅ 20+ providers including Claude CLI and ChatGPT OAuth | +| **Price** | Free OSS UI, but a Claude Code plan is still needed today | Free OSS, but you still pay for the underlying runtime plans/seats you use | Claude subscription | Free self-hosted OSS, but infra + provider/API/subscription costs remain | + +## Самые важные выводы по matrix + +### 1. Наше главное отличие - мы сильнее именно как coding workbench + +По frontend/product surface у нас очень большой отрыв в четырёх местах: + +- task-scoped logs +- hunk-level review +- built-in editor +- live processes + +Это и есть та часть, которую README сейчас продаёт лучше всего, и она реально подтверждается кодом. + +### 2. Gastown - реальный конкурент по orchestration, но не по UI + +Gastown нельзя сравнивать с нами как с "kanban app". Это скорее process-model orchestrator: + +- Mayor +- mailboxes +- handoffs +- witness/deacon monitoring +- convoys +- git worktree isolation + +Но по UX для review, editor, per-task logs и task attachments он заметно слабее. + +### 3. Claude Code Agent Teams - это ближайший конкурент именно по runtime-модели + +Если смотреть на core idea: + +- team lead +- teammates +- mailbox +- shared task list +- dependencies +- direct teammate messaging + +то это самый близкий конкурент нашему runtime foundation. Но у них почти нет того UI-слоя, который у нас уже есть как продукт: kanban, per-task review, logs, attachments, processes, editor. + +### 4. GoClaw - сильнейший platform competitor, но не лучший coding cockpit + +GoClaw выигрывает у нас по: + +- multi-provider breadth +- self-hosted platform maturity +- Kanban + approvals + task lifecycle +- OAuth/provider surface +- multi-tenant / channels / ops + +Но проигрывает в IDE-like coding surfaces: + +- hunk review +- per-task code review UX +- built-in editor +- live process control +- task-scoped raw logs as a strong developer cockpit + +## Более глубокое чтение каждого конкурента + +### Gastown + +Что после более глубокого чтения видно особенно ясно: + +- Это не просто "ещё один agent manager", а очень осознанная process-model система. +- Самые load-bearing примитивы у них - `Mayor`, `Witness`, `Deacon`, `Refinery`, `Convoy`, `Hooks`, `Beads`. +- У них сильный recovery story: + - persistent identity + - session handoff + - recovery mail protocol + - watchdog chain + - capacity-controlled dispatch +- Они явно думают не как "чат с LLM", а как "операционная система для swarm of coding agents". + +Что тянет вниз: + +- setup тяжёлый +- UI мониторинговый, не IDE-like +- per-task review/log/editor surfaces слабее +- часть силы живёт в терминах и process model, а не в простой product UX + +### Claude Code Agent Teams + +После более глубокого чтения видно: + +- Это лучший нативный Claude-first фундамент для team lead + teammates. +- Shared task list, mailbox, direct teammate messaging и automatic dependency unblocking у них реальные. +- Есть plan approval loop и hooks-based quality gates. +- Но feature всё ещё experimental, и docs сами предупреждают про limits around resumption / coordination / shutdown. + +Что это значит practically: + +- как native runtime foundation это сильная штука +- как самостоятельный продукт для управления coding team это пока тонко +- без нашего UI-слоя там очень мало operator ergonomics + +### GoClaw + +После более глубокого чтения и docs, и кода: + +- Это самый сбалансированный platform product в сравнении. +- У него сильный task engine, approvals, Kanban, workspace, provider layer, OAuth paths, traces, channels. +- Он лучше остальных выглядит как "готовая self-hosted platform", а не как набор сильных primitives. + +Что тянет вниз: + +- слабее IDE-like coding workbench +- infra/setup тяжелее нашего и Claude Code path +- non-commercial license очень сильно режет "open source leverage" + +## Scorecards + +Ниже уже не просто feature presence, а моя независимая оценка по 10-балльной шкале. + +### 1. Чисто как orchestration engine + +| Проект | Оценка | Почему | +|---|---:|---| +| **Gastown** | **9.2** | Самый сильный process-model orchestration для coding swarms: mailboxes, handoffs, convoys, witness/deacon, worktrees, merge queue, recovery | +| **GoClaw** | **8.9** | Самый зрелый durable workflow-state engine: board lifecycle, approvals, `blocked_by`, retry, stale, traces, provider-agnostic task system | +| **Claude Agent Teams UI + orchestrator** | **7.8** | Сильный local orchestrator и deterministic bootstrap, но task/state engine менее durable и менее mature | +| **Claude Code Agent Teams** | **7.7** | Хороший native runtime foundation, но lifecycle проще и feature всё ещё experimental | + +### 2. Как coding cockpit / agentic IDE + +| Проект | Оценка | Почему | +|---|---:|---| +| **Claude Agent Teams UI + orchestrator** | **9.4** | Лучший review, per-task logs, built-in editor, live processes, operator control | +| **GoClaw** | **7.2** | Хороший dashboard/workspace/product UI, но не настолько сильный coding workbench | +| **Claude Code Agent Teams** | **6.0** | Живые teammate sessions и direct messaging есть, но это всё ещё CLI-native control, не полноценный cockpit | +| **Gastown** | **5.7** | Сильный TUI/dashboard monitoring, но IDE-like surfaces почти нет | + +### 3. Setup / onboarding + +| Проект | Оценка | Почему | +|---|---:|---| +| **Claude Agent Teams UI + orchestrator** | **8.5** | Самый сильный zero-setup путь для Claude Code сценария | +| **Claude Code Agent Teams** | **7.2** | Относительно просто, если пользователь уже живёт в Claude Code, но нужен install + experimental flag | +| **GoClaw** | **6.2** | Lite заметно упрощает вход, но standard edition всё ещё тяжёлая | +| **Gastown** | **4.6** | Сильный toolchain tax: Go, Git, Dolt, beads, sqlite3, tmux, CLI runtimes, HQ bootstrap | + +### 4. Provider flexibility / subscription paths + +| Проект | Оценка | Почему | +|---|---:|---| +| **GoClaw** | **9.6** | 20+ providers, Claude CLI, ChatGPT OAuth, channels, pooling | +| **Gastown** | **8.8** | Очень хороший multi-runtime story: Claude Code, Codex, Gemini, Copilot и др. | +| **Claude Agent Teams UI + orchestrator** | **5.8** | Путь на multi-provider проступает в коде, но продукт всё ещё Claude-first | +| **Claude Code Agent Teams** | **4.2** | Claude-first by design | + +### 5. Maturity / engineering confidence + +Это уже composite signal по docs + releases + tests + architectural surface. + +| Проект | Оценка | Что учитывал | +|---|---:|---| +| **Gastown** | **8.6** | `13.9k★`, `v1.0.0`, `492` `*test.go`, глубокая design-doc surface | +| **GoClaw** | **8.5** | `v3.6.0`, `351` `*test.go`, очень широкая docs surface, частая релизная активность | +| **Claude Code Agent Teams** | **7.5** | Огромный repo и релизный cadence сильные, но сама feature experimental | +| **Claude Agent Teams UI + orchestrator** | **6.9** | UI очень силён, но stars/coverage/maturity пока заметно слабее; у frontend сейчас `0` test files | + +## Архитектурный deep-dive + +### Coordination topology + +| Проект | Топология | Сильная сторона | Ограничение | +|---|---|---|---| +| **Наш стек** | lead-centered orchestration + rich operator UI | человек очень хорошо держит команду руками | engine менее durable, много ценности живёт в operator loop | +| **Gastown** | process-model roles + externalized state via beads/hooks/mail | лучшая декомпозиция swarm как операционной системы | высокая когнитивная и инфраструктурная сложность | +| **Claude Code Agent Teams** | lead + teammates + peer messaging + shared task list | максимально нативная Claude-first team модель | experimental state machine и тонкий control plane | +| **GoClaw** | DB-backed task engine + team tools + orchestration modes | самый продуктово цельный runtime | менее выразительный IDE/workbench слой | + +### Persistence model + +| Проект | Persistence | Что это даёт | Комментарий | +|---|---|---|---| +| **Наш стек** | local app state + Claude logs + runtime stores + bootstrap state | сильный session/task visibility для local work | меньше durable workflow truth, чем у `Gastown`/`GoClaw` | +| **Gastown** | Git worktrees + Beads ledger + Dolt + mail protocol | crash-surviving coordination и сильная work history | сложнее понять и сопровождать | +| **Claude Code Agent Teams** | local files in `~/.claude/teams` and `~/.claude/tasks` | surprisingly practical lightweight persistence | проще и слабее, чем полноценный DB-backed engine | +| **GoClaw** | PostgreSQL in standard, SQLite in Lite | самый сильный durable task/store foundation | инфраструктурная цена выше | + +### Observability model + +| Проект | Лучшее в observability | Что слабее | +|---|---|---| +| **Наш стек** | лучший task-scoped log visibility и review-oriented debugging | слабее общий durable ops/trace plane | +| **Gastown** | сильные OTLP logs, activity feed, structured runtime events | слабее productized per-task log cockpit | +| **Claude Code Agent Teams** | visibility через sessions and split panes | почти нет отдельного observability product layer | +| **GoClaw** | traces, audit logs, approvals, task events, activity pages | raw per-task coding logs ощущаются слабее, чем у нас | + +### Review / merge model + +| Проект | Review model | Practical impact | +|---|---|---| +| **Наш стек** | per-task diff review + hunks + comments + approvals | лучший human review loop | +| **Gastown** | refinery / merge queue / PR-oriented review flow | сильный integration discipline, но слабый UI review cockpit | +| **Claude Code Agent Teams** | plan approval + hooks quality gates | хороший gate mechanism, но не review product | +| **GoClaw** | task `in_review` + approve/reject + reviewer agent gates | сильный workflow review, но слабее code-review UX | + +## Weighted verdicts + +Здесь самый важный момент: **"лучший проект" зависит от весов**. +Ниже три независимые линзы, каждая со своими весами. + +### Lens A - Self-hosted multi-agent product + +Веса: + +- orchestration engine - 30% +- product/UI breadth - 25% +- setup/onboarding - 10% +- provider flexibility - 15% +- maturity/confidence - 15% +- license leverage - 5% + +| Проект | Итоговый балл | +|---|---:| +| **GoClaw** | **8.1** | +| **Gastown** | **7.6** | +| **Наш стек** | **7.5** | +| **Claude Code Agent Teams** | **6.7** | + +Вывод: + +- если смотреть на проект как на **самый полноценный self-hosted продукт**, побеждает `GoClaw` + +### Lens B - Coding team workstation / agentic IDE + +Веса: + +- coding cockpit - 35% +- review/log/debug surfaces - 20% +- local operator control - 15% +- setup friction - 10% +- orchestration engine - 10% +- maturity/confidence - 10% + +| Проект | Итоговый балл | +|---|---:| +| **Наш стек** | **8.5** | +| **GoClaw** | **7.4** | +| **Claude Code Agent Teams** | **6.8** | +| **Gastown** | **6.6** | + +Вывод: + +- если смотреть на проект как на **лучший инструмент для реальной работы над кодом**, побеждаем мы + +### Lens C - Open-source orchestration leverage + +Веса: + +- orchestration engine - 30% +- engineering confidence - 20% +- license leverage - 20% +- provider/runtime flexibility - 15% +- observability/recovery - 15% + +| Проект | Итоговый балл | +|---|---:| +| **Gastown** | **8.6** | +| **GoClaw** | **7.9** | +| **Наш стек** | **7.0** | +| **Claude Code Agent Teams** | **5.9** | + +Вывод: + +- если смотреть на проект как на **наиболее ценный open-source фундамент для серьёзной orchestration-системы**, побеждает `Gastown` + +## Независимый итоговый verdict + +Если заставить меня выбрать **одного общего победителя как продукта**, то это сейчас: + +### **1 место overall - GoClaw** + +Почему: + +- самый сбалансированный проект +- сильный engine +- сильный platform UI +- сильный provider story +- сильный self-hosted story +- сильный docs/release surface + +Моя оценка: + +- overall: **8.5 / 10** +- 🎯 8.8 🛡️ 8.6 🧠 5 + +### **2 место overall - Gastown** + +Почему: + +- как orchestrator для fleets of coding agents он очень силён +- архитектурно у него самый яркий process-model характер +- по recovery / work persistence / worktree isolation он реально впечатляет + +Почему не первое место: + +- тяжёлый вход +- слабее product UX +- слабее review/log/editor cockpit + +Моя оценка: + +- overall: **8.2 / 10** +- 🎯 8.6 🛡️ 8.8 🧠 7 + +### **3 место overall - наш стек** + +Почему: + +- лучший coding cockpit +- лучший human-in-the-loop control plane +- лучший UI для лида coding-команды + +Почему не выше: + +- orchestration engine менее зрелый, чем у `Gastown` и `GoClaw` +- maturity signals слабее +- frontend test surface сейчас объективно плохой +- multi-provider story пока не настолько продуктово зрелая + +Моя оценка: + +- overall: **7.9 / 10** +- 🎯 8.4 🛡️ 7.4 🧠 5 + +### **4 место overall - Claude Code Agent Teams** + +Почему: + +- это сильная native runtime функция, но ещё не лучший самостоятельный продукт +- слишком много experimental caveats +- почти нет product/UI advantage по сравнению с остальными + +Моя оценка: + +- overall: **7.1 / 10** +- 🎯 8.2 🛡️ 6.8 🧠 3 + +## Кто лучший по конкретным сценариям + +| Сценарий | Победитель | Почему | +|---|---|---| +| **Лучший overall product** | **GoClaw** | Самый ровный баланс engine + UI + providers + self-hosted maturity | +| **Лучший pure orchestrator для coding swarms** | **Gastown** | Самый сильный process-model orchestration core | +| **Лучший native Claude runtime foundation** | **Claude Code Agent Teams** | Самая нативная реализация team lead + teammates внутри Claude Code | +| **Лучший coding cockpit / agentic IDE** | **наш стек** | Лучшие review, logs, editor, processes, human control | + +## Что особенно важно помнить для README + +Если мы когда-нибудь будем переписывать публичный `Comparison` в README, то главный честный framing такой: + +- против `Gastown` надо продавать `UI/workbench`, а не пытаться спорить, что мы сильнее как process-model orchestrator +- против `Claude Code Agent Teams` надо продавать "native runtime + настоящий product UI сверху" +- против `GoClaw` надо продавать "agentic IDE / coding cockpit", а не "более широкий platform product" + +## Где у нас реально подтверждён сильный frontend + +Это ключевые локальные опоры, на которые можно смело ссылаться внутри команды: + +- review cockpit - [ChangeReviewDialog](../../src/renderer/components/team/review/ChangeReviewDialog.tsx) +- task detail + attachments + comments - [TaskDetailDialog](../../src/renderer/components/team/dialogs/TaskDetailDialog.tsx) +- task logs - [TaskLogsPanel](../../src/renderer/components/team/taskLogs/TaskLogsPanel.tsx) +- built-in editor - [ProjectEditorOverlay](../../src/renderer/components/team/editor/ProjectEditorOverlay.tsx) +- live processes - [ProcessesSection](../../src/renderer/components/team/ProcessesSection.tsx) +- tool approvals - [ToolApprovalSheet](../../src/renderer/components/team/ToolApprovalSheet.tsx) + +Есть и важная продуктовая нюансировка: + +- cross-team communication у нас реально есть +- task attachments у нас реально есть +- multimodel/provider surface у нас уже проступает в коде +- но публично и продуктово мы всё ещё остаёмся в первую очередь Claude-first + +## Места, где надо быть особенно честными про нас + +- `Multi-agent backend` у нас пока не так зрел, как это можно прочитать из одной строки README. В коде есть мосты и статусы для `Anthropic`, `Codex`, `Gemini`, но продуктово основной путь всё ещё Claude-first. +- `Zero setup` у нас честно сильный именно для Claude Code path. +- `Cross-team communication` у нас сильнее, чем у этих конкурентов, но cross-team attachments не выглядят как полностью общий happy path. + +## Источники + +### Наша сторона + +- README: [README.md](../../README.md) +- review UI: [ChangeReviewDialog](../../src/renderer/components/team/review/ChangeReviewDialog.tsx) +- logs UI: [TaskLogsPanel](../../src/renderer/components/team/taskLogs/TaskLogsPanel.tsx) +- editor UI: [ProjectEditorOverlay](../../src/renderer/components/team/editor/ProjectEditorOverlay.tsx) +- processes UI: [ProcessesSection](../../src/renderer/components/team/ProcessesSection.tsx) +- task workflow UI: [TaskDetailDialog](../../src/renderer/components/team/dialogs/TaskDetailDialog.tsx) +- approvals UI: [ToolApprovalSheet](../../src/renderer/components/team/ToolApprovalSheet.tsx) + +### Gastown + +- Official repo: +- README: +- Latest release: + +### Claude Code Agent Teams + +- Agent Teams docs: +- CLI auth docs: +- Claude Code repo: +- Latest release: + +### GoClaw + +- Official repo: +- README: +- Full docs export: +- Latest release: + +## Bottom line + +Если брать реальные продукты, то текущая внутренняя картина такая: + +- **Gastown** - конкурент по orchestration runtime +- **Claude Code Agent Teams** - конкурент по базовой runtime-модели team lead + teammates +- **GoClaw** - конкурент по platform orchestration product +- **мы** - сильнее как agentic IDE / coding-team cockpit + +То есть наш главный moat сейчас не "самый широкий agent platform". +Он в том, что мы уже собрали более сильное рабочее место для лида coding-команды, чем у этих трёх систем. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 69c197c5..2590dca8 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -11,9 +11,18 @@ import type { Plugin } from 'vite' const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) const prodDeps = Object.keys(pkg.dependencies || {}) +// Fastify and its plugins rely on runtime module resolution that breaks when bundled. +const runtimeExternalDeps = new Set([ + 'node-pty', + 'agent-teams-controller', + 'fastify', + '@fastify/cors', + '@fastify/static', +]) + // node-pty is a native addon that cannot be bundled by Rollup. // It must remain external and be loaded at runtime via require(). -const bundledDeps = prodDeps.filter(d => d !== 'node-pty' && d !== 'agent-teams-controller') +const bundledDeps = prodDeps.filter(d => !runtimeExternalDeps.has(d)) // Rollup plugin: stub out native .node addon imports with empty modules. // ssh2 and cpu-features use optional native bindings that can't be bundled, diff --git a/landing/content/ar.json b/landing/content/ar.json index 1cb5e5ee..22b1f44d 100644 --- a/landing/content/ar.json +++ b/landing/content/ar.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "ما هو Claude Agent Teams؟", - "answer": "تطبيق سطح مكتب يتيح لك تشكيل فرق وكلاء ذكاء اصطناعي مدعومة بـ Claude Code. لكل وكيل دور، يعمل بشكل مستقل، ويتعاون مع زملائه — يُدار كله من لوحة كانبان." + "answer": "تطبيق سطح مكتب لتنظيم فرق وكلاء الذكاء الاصطناعي عبر طبقة تنسيق محلية خاصة بنا. لكل وكيل دور، يعمل بشكل مستقل، ويتعاون عبر لوحة كانبان، ويمكن تشغيله مع Anthropic أو Codex." }, { "id": "isFree", "question": "هل هو مجاني فعلاً؟", - "answer": "نعم. التطبيق نفسه مجاني 100% ومفتوح المصدر. تحتاج فقط اشتراك Claude (خطة Max أو Pro) — هذا كل شيء. يتم تثبيت وإعداد Claude Code تلقائياً من خلال تطبيقنا، بدون الحاجة للطرفية." + "answer": "نعم. التطبيق مجاني 100% ومفتوح المصدر. التطبيق نفسه لا يملك خطة مدفوعة. لتشغيل الوكلاء تحتاج فقط إلى وصول إلى مزود أو runtime مدعوم مثل Anthropic أو Codex." }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "ماذا أحتاج للبدء؟", - "answer": "فقط ثبّت التطبيق — يتضمن تثبيت ومصادقة Claude Code المدمجة. البدء بدون إعداد يجعلك تعمل في دقائق." + "answer": "فقط ثبّت التطبيق - وسيرشدك من الواجهة لاكتشاف الـ runtime وتسجيل دخول المزود. البدء بدون إعداد يجعلك تعمل في دقائق." } ], "download": { diff --git a/landing/content/de.json b/landing/content/de.json index 6acb8c05..c5bc894e 100644 --- a/landing/content/de.json +++ b/landing/content/de.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "Was ist Claude Agent Teams?", - "answer": "Eine Desktop-App, mit der Sie KI-Agenten-Teams mit Claude Code zusammenstellen können. Jeder Agent hat eine Rolle, arbeitet autonom und kollaboriert mit Teammitgliedern — alles über ein Kanban-Board verwaltet." + "answer": "Eine Desktop-App zur Orchestrierung von KI-Agententeams mit unserer eigenen lokalen Koordinationsschicht. Agenten haben Rollen, arbeiten autonom, kollaborieren über ein Kanban-Board und können mit Anthropic oder Codex laufen." }, { "id": "isFree", "question": "Ist es wirklich kostenlos?", - "answer": "Ja. Die App ist 100% kostenlos und Open Source. Sie brauchen nur ein Claude-Abo (Max oder Pro) — das war's. Claude Code wird automatisch installiert und eingerichtet." + "answer": "Ja. Die App ist 100% kostenlos und Open Source. Die App selbst hat kein Bezahlmodell. Um Agenten auszuführen, brauchen Sie nur Zugriff auf einen unterstützten Provider bzw. Runtime wie Anthropic oder Codex." }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "Was brauche ich zum Start?", - "answer": "Einfach die App installieren — Claude Code Installation und Authentifizierung sind integriert. Zero-Setup-Onboarding bringt Sie in Minuten zum Laufen." + "answer": "Einfach die App installieren - sie führt Sie in der UI durch Runtime-Erkennung und Provider-Authentifizierung. Zero-Setup-Onboarding bringt Sie in Minuten zum Laufen." } ], "download": { diff --git a/landing/content/en.json b/landing/content/en.json index 3f3c8529..f7aaa071 100644 --- a/landing/content/en.json +++ b/landing/content/en.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "What is Claude Agent Teams?", - "answer": "A desktop app that lets you assemble AI agent teams powered by Claude Code. Each agent has a role, works autonomously, and collaborates with teammates — all managed through a kanban board." + "answer": "A desktop app for orchestrating AI agent teams with our own local coordination layer. Agents have roles, work autonomously, collaborate through a kanban board, and can run on Anthropic or Codex." }, { "id": "isFree", "question": "Is it really free?", - "answer": "Yes. The app itself is 100% free and open source. You just need a Claude subscription (Max or Pro plan) — that's it. Claude Code is installed and set up automatically through our app, no terminal required." + "answer": "Yes. The app itself is 100% free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex." }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "What do I need to get started?", - "answer": "Just install the app — it includes built-in Claude Code installation and authentication. Zero-setup onboarding gets you running in minutes." + "answer": "Just install the app - it guides runtime detection and provider authentication from the UI. Zero-setup onboarding gets you running in minutes." } ], "download": { diff --git a/landing/content/es.json b/landing/content/es.json index 994a1fe8..f60a587b 100644 --- a/landing/content/es.json +++ b/landing/content/es.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "¿Qué es Claude Agent Teams?", - "answer": "Una app de escritorio que te permite montar equipos de agentes IA con Claude Code. Cada agente tiene un rol, trabaja de forma autónoma y colabora con compañeros — todo gestionado desde un tablero kanban." + "answer": "Una app de escritorio para orquestar equipos de agentes IA con nuestra propia capa local de coordinación. Cada agente tiene un rol, trabaja de forma autónoma, colabora a través de un tablero kanban y puede ejecutarse con Anthropic o Codex." }, { "id": "isFree", "question": "¿Es realmente gratis?", - "answer": "Sí. La app es 100% gratuita y de código abierto. Solo necesitas una suscripción a Claude (plan Max o Pro) — eso es todo. Claude Code se instala y configura automáticamente a través de nuestra app, sin necesidad de terminal." + "answer": "Sí. La app es 100% gratuita y de código abierto. La app no tiene un plan de pago propio. Para ejecutar agentes solo necesitas acceso a un proveedor/runtime compatible, como Anthropic o Codex." }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "¿Qué necesito para empezar?", - "answer": "Solo instala la app — incluye instalación y autenticación integrada de Claude Code. El onboarding sin configuración te pone en marcha en minutos." + "answer": "Solo instala la app - te guía en la detección del runtime y la autenticación del proveedor desde la interfaz. El onboarding sin configuración te pone en marcha en minutos." } ], "download": { diff --git a/landing/content/fr.json b/landing/content/fr.json index 77eb1ec2..2febf60c 100644 --- a/landing/content/fr.json +++ b/landing/content/fr.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "Qu'est-ce que Claude Agent Teams ?", - "answer": "Une application de bureau qui vous permet d'assembler des équipes d'agents IA propulsés par Claude Code. Chaque agent a un rôle, travaille de manière autonome et collabore avec ses coéquipiers — le tout géré via un tableau kanban." + "answer": "Une application de bureau pour orchestrer des équipes d'agents IA avec notre propre couche de coordination locale. Les agents ont des rôles, travaillent de façon autonome, collaborent sur un tableau kanban et peuvent s'exécuter avec Anthropic ou Codex." }, { "id": "isFree", "question": "C'est vraiment gratuit ?", - "answer": "Oui. L'application est 100% gratuite et open source. Vous avez juste besoin d'un abonnement Claude (Max ou Pro) — c'est tout. Claude Code est installé et configuré automatiquement, sans terminal." + "answer": "Oui. L'application est 100% gratuite et open source. L'application n'a pas d'offre payante. Pour exécuter des agents, vous avez seulement besoin d'un accès à un provider/runtime pris en charge, comme Anthropic ou Codex." }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "De quoi ai-je besoin pour commencer ?", - "answer": "Installez simplement l'application — elle inclut l'installation et l'authentification intégrées de Claude Code. L'onboarding zéro-configuration vous fait démarrer en quelques minutes." + "answer": "Installez simplement l'application - elle vous guide pour la détection du runtime et l'authentification du provider depuis l'interface. L'onboarding zéro-configuration vous fait démarrer en quelques minutes." } ], "download": { diff --git a/landing/content/hi.json b/landing/content/hi.json index 531999cc..d13fc263 100644 --- a/landing/content/hi.json +++ b/landing/content/hi.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "Claude Agent Teams क्या है?", - "answer": "एक डेस्कटॉप ऐप जो Claude Code द्वारा संचालित AI एजेंट टीमें बनाने देता है। हर एजेंट की एक भूमिका है, स्वायत्त रूप से काम करता है, और साथियों के साथ सहयोग करता है — सब कानबन बोर्ड से मैनेज होता है।" + "answer": "यह एक डेस्कटॉप ऐप है जो हमारी अपनी लोकल coordination layer के साथ AI agent teams को orchestrate करता है। हर agent की एक भूमिका होती है, वह स्वायत्त रूप से काम करता है, kanban board पर सहयोग करता है, और Anthropic या Codex पर चल सकता है।" }, { "id": "isFree", "question": "क्या यह सच में मुफ़्त है?", - "answer": "हाँ। ऐप खुद 100% मुफ़्त और ओपन सोर्स है। आपको बस Claude सब्सक्रिप्शन (Max या Pro प्लान) चाहिए — बस इतना ही। Claude Code हमारे ऐप के ज़रिए अपने आप इंस्टॉल और सेटअप हो जाता है, टर्मिनल की ज़रूरत नहीं।" + "answer": "हाँ। ऐप 100% मुफ़्त और ओपन सोर्स है। ऐप का अपना कोई paid plan नहीं है। agents चलाने के लिए आपको सिर्फ़ किसी supported provider/runtime, जैसे Anthropic या Codex, का access चाहिए।" }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "शुरू करने के लिए क्या चाहिए?", - "answer": "बस ऐप इंस्टॉल करें — इसमें Claude Code इंस्टॉलेशन और ऑथेंटिकेशन बिल्ट-इन है। शून्य-सेटअप ऑनबोर्डिंग से मिनटों में शुरू हो जाएँगे।" + "answer": "बस ऐप इंस्टॉल करें - यह UI से runtime detection और provider authentication में गाइड करता है। zero-setup onboarding आपको कुछ ही मिनटों में शुरू करा देता है।" } ], "download": { diff --git a/landing/content/ja.json b/landing/content/ja.json index 695eef57..7b5beac9 100644 --- a/landing/content/ja.json +++ b/landing/content/ja.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "Claude Agent Teamsとは?", - "answer": "Claude Codeを活用したAIエージェントチームを編成できるデスクトップアプリです。各エージェントは役割を持ち、自律的に作業し、チームメイトと連携します — すべてカンバンボードで管理。" + "answer": "独自のローカル協調レイヤーでAIエージェントチームをオーケストレーションできるデスクトップアプリです。各エージェントは役割を持ち、自律的に動き、カンバン上で連携し、Anthropic または Codex で実行できます。" }, { "id": "isFree", "question": "本当に無料ですか?", - "answer": "はい。アプリは100%無料でオープンソースです。Claudeサブスクリプション(MaxまたはProプラン)があればOK。Claude Codeは自動でインストール・セットアップされます。" + "answer": "はい。アプリは100%無料のオープンソースです。アプリ自体に有料プランはありません。エージェントを実行するには、Anthropic や Codex など対応する provider/runtime へのアクセスだけが必要です。" }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "始めるには何が必要ですか?", - "answer": "アプリをインストールするだけ — Claude Codeのインストールと認証が組み込まれています。ゼロ設定のオンボーディングで数分で開始できます。" + "answer": "アプリをインストールするだけです - UI 上で runtime の検出と provider 認証を案内します。ゼロ設定のオンボーディングで数分で開始できます。" } ], "download": { diff --git a/landing/content/pt.json b/landing/content/pt.json index 87e17e23..cee9580a 100644 --- a/landing/content/pt.json +++ b/landing/content/pt.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "O que é Claude Agent Teams?", - "answer": "Um app desktop que permite montar equipes de agentes IA alimentadas por Claude Code. Cada agente tem um papel, trabalha de forma autônoma e colabora com colegas — tudo gerenciado por um quadro kanban." + "answer": "Um app desktop para orquestrar equipes de agentes IA com nossa própria camada local de coordenação. Os agentes têm papéis, trabalham de forma autônoma, colaboram em um quadro kanban e podem rodar com Anthropic ou Codex." }, { "id": "isFree", "question": "É realmente grátis?", - "answer": "Sim. O app em si é 100% gratuito e open source. Você só precisa de uma assinatura Claude (plano Max ou Pro) — só isso. O Claude Code é instalado e configurado automaticamente pelo nosso app, sem precisar do terminal." + "answer": "Sim. O app é 100% gratuito e open source. O app não tem plano pago próprio. Para rodar agentes, você só precisa de acesso a um provider/runtime compatível, como Anthropic ou Codex." }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "O que preciso para começar?", - "answer": "Apenas instale o app — inclui instalação e autenticação integrada do Claude Code. O onboarding sem configuração te coloca em ação em minutos." + "answer": "Basta instalar o app - ele guia a detecção do runtime e a autenticação do provider pela interface. O onboarding sem configuração coloca você em ação em minutos." } ], "download": { diff --git a/landing/content/ru.json b/landing/content/ru.json index c4767a50..9f91b1ef 100644 --- a/landing/content/ru.json +++ b/landing/content/ru.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "Что такое Claude Agent Teams?", - "answer": "Десктопное приложение, которое позволяет собирать команды ИИ-агентов на базе Claude Code. Каждый агент имеет роль, работает автономно и взаимодействует с тиммейтами — всё управляется через канбан-доску." + "answer": "Десктопное приложение для оркестрации команд ИИ-агентов с нашей собственной локальной координацией. У агентов есть роли, они работают автономно, взаимодействуют через канбан-доску и могут запускаться на Anthropic или Codex." }, { "id": "isFree", "question": "Это действительно бесплатно?", - "answer": "Да. Само приложение полностью бесплатное и с открытым кодом. Вам нужна только подписка Claude (Max или Pro план) — и всё. Claude Code устанавливается и настраивается автоматически через наше приложение, без терминала." + "answer": "Да. Само приложение полностью бесплатное и с открытым кодом. У него нет собственного платного тарифа. Для запуска агентов нужен только доступ к поддерживаемому провайдеру/рантайму, например Anthropic или Codex." }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "Что нужно для начала?", - "answer": "Просто установите приложение — оно включает встроенную установку и аутентификацию Claude Code. Онбординг без настройки запустит вас за минуты." + "answer": "Просто установите приложение - оно проведёт вас через определение рантайма и аутентификацию провайдера прямо в интерфейсе. Онбординг без настройки запустит вас за минуты." } ], "download": { diff --git a/landing/content/zh.json b/landing/content/zh.json index 6a34c701..c02dc42c 100644 --- a/landing/content/zh.json +++ b/landing/content/zh.json @@ -39,12 +39,12 @@ { "id": "whatIsIt", "question": "什么是 Claude Agent Teams?", - "answer": "一个桌面应用,让你组建由 Claude Code 驱动的 AI 智能体团队。每个智能体有自己的角色,自主工作,与队友协作——一切通过看板管理。" + "answer": "一个桌面应用,通过我们自己的本地协调层来编排 AI 智能体团队。每个智能体都有角色,可自主工作,在看板上协作,并且可以运行在 Anthropic 或 Codex 上。" }, { "id": "isFree", "question": "真的免费吗?", - "answer": "是的。应用本身 100% 免费且开源。你只需要 Claude 订阅(Max 或 Pro 方案)就够了。Claude Code 通过我们的应用自动安装和配置,无需使用终端。" + "answer": "是的。应用本身 100% 免费且开源。应用没有自己的付费方案。要运行智能体,你只需要接入受支持的 provider/runtime,例如 Anthropic 或 Codex。" }, { "id": "platforms", @@ -64,7 +64,7 @@ { "id": "requirements", "question": "开始需要什么?", - "answer": "只需安装应用——内置 Claude Code 安装和认证功能。零配置启动,几分钟即可运行。" + "answer": "只需安装应用 - 它会在界面中引导你完成 runtime 检测和 provider 认证。零配置上手,几分钟即可运行。" } ], "download": { diff --git a/packages/agent-graph/src/canvas/background-layer.ts b/packages/agent-graph/src/canvas/background-layer.ts index ea181392..b560fd0f 100644 --- a/packages/agent-graph/src/canvas/background-layer.ts +++ b/packages/agent-graph/src/canvas/background-layer.ts @@ -15,38 +15,200 @@ export interface DepthParticle { brightness: number; speed: number; depth: number; + twinkleOffset: number; + twinkleSpeed: number; + twinkleAmount: number; + flickerOffset: number; + flickerSpeed: number; + flickerAmount: number; + haloStrength: number; } export function createDepthParticles(w: number, h: number): DepthParticle[] { const particles: DepthParticle[] = []; for (let i = 0; i < BACKGROUND.starCount; i++) { - particles.push({ - x: Math.random() * w, - y: Math.random() * h, - size: 0.3 + Math.random() * 1.2, - brightness: 0.15 + Math.random() * 0.4, - speed: 0.05 + Math.random() * 0.15, - depth: Math.random(), - }); + particles.push(createDepthParticle(w, h)); } return particles; } +function createDepthParticle(w: number, h: number, spawnAbove = false): DepthParticle { + const depth = Math.random(); + const sizeBias = Math.pow(Math.random(), 2.35); + const size = 0.34 + sizeBias * 1.18 + (1 - depth) * 0.16; + const brightness = 0.22 + sizeBias * 0.34 + (1 - depth) * 0.08; + + return { + x: Math.random() * w, + y: spawnAbove ? -8 - Math.random() * Math.max(12, h * 0.12) : Math.random() * h, + size, + brightness, + speed: 0.04 + Math.random() * 0.09 + (1 - depth) * 0.04, + depth, + twinkleOffset: Math.random() * Math.PI * 2, + twinkleSpeed: 0.18 + Math.random() * 0.4, + twinkleAmount: 0.05 + Math.random() * 0.08, + flickerOffset: Math.random() * Math.PI * 2, + flickerSpeed: 0.9 + Math.random() * 2.2, + flickerAmount: 0.015 + Math.random() * 0.04, + haloStrength: 0.08 + Math.random() * 0.14, + }; +} + export function updateDepthParticles( particles: DepthParticle[], w: number, h: number, - dt: number, + dt: number ): void { for (const p of particles) { p.y += p.speed * dt * 20; if (p.y > h + 5) { - p.y = -5; - p.x = Math.random() * w; + Object.assign(p, createDepthParticle(w, h, true)); } } } +// ─── Shooting Stars ──────────────────────────────────────────────────────── + +export interface ShootingStar { + x: number; + y: number; + vx: number; + vy: number; + travel: number; + maxTravel: number; + length: number; + thickness: number; + brightness: number; +} + +export interface ShootingStarField { + active: ShootingStar[]; + spawnCooldown: number; +} + +export function createShootingStarField(): ShootingStarField { + return { + active: [], + spawnCooldown: randomShootingStarCooldown(), + }; +} + +export function updateShootingStarField( + field: ShootingStarField, + w: number, + h: number, + dt: number +): void { + field.spawnCooldown -= dt; + if (field.spawnCooldown <= 0 && field.active.length < 1) { + field.active.push(createShootingStar(w, h)); + field.spawnCooldown = randomShootingStarCooldown(); + } + + for (let i = field.active.length - 1; i >= 0; i--) { + const star = field.active[i]; + star.travel += Math.hypot(star.vx, star.vy) * dt; + star.x += star.vx * dt; + star.y += star.vy * dt; + + if (star.travel >= star.maxTravel) { + field.active.splice(i, 1); + } + } +} + +function createShootingStar(w: number, h: number): ShootingStar { + const margin = 60; + const sizeBias = Math.pow(Math.random(), 1.9); + const sizeScale = 0.68 + sizeBias * 0.92; + const { x, y, angle } = createShootingStarSpawn(w, h, margin); + const speed = 58 + sizeScale * 18 + Math.random() * 10; + const vx = Math.cos(angle) * speed; + const vy = Math.sin(angle) * speed; + const maxTravel = speed * computeShootingStarExitTime(x, y, vx, vy, w, h, margin + 28); + + return { + x, + y, + vx, + vy, + travel: 0, + maxTravel, + length: 14 + sizeScale * (10 + Math.random() * 8), + thickness: 0.34 + sizeScale * 0.34, + brightness: 0.14 + sizeScale * 0.09 + Math.random() * 0.03, + }; +} + +function randomShootingStarCooldown(): number { + return 16 + Math.random() * 14; +} + +function createShootingStarSpawn( + w: number, + h: number, + margin: number +): { x: number; y: number; angle: number } { + const edgeOffset = Math.random() * Math.max(24, Math.min(w, h) * 0.06); + const variant = Math.floor(Math.random() * 4); + + switch (variant) { + case 0: + return { + x: w + margin + edgeOffset, + y: h * (0.06 + Math.random() * 0.22), + angle: Math.PI - (0.3 + Math.random() * 0.12), + }; + case 1: + return { + x: -margin - edgeOffset, + y: h * (0.06 + Math.random() * 0.22), + angle: 0.3 + Math.random() * 0.12, + }; + case 2: + return { + x: w * (0.08 + Math.random() * 0.34), + y: -margin - edgeOffset, + angle: 0.96 + Math.random() * 0.18, + }; + default: + return { + x: w * (0.58 + Math.random() * 0.34), + y: -margin - edgeOffset, + angle: Math.PI - (0.96 + Math.random() * 0.18), + }; + } +} + +function computeShootingStarExitTime( + x: number, + y: number, + vx: number, + vy: number, + w: number, + h: number, + margin: number +): number { + const exitTimes: number[] = []; + + if (vx > 0.001) { + exitTimes.push((w + margin - x) / vx); + } else if (vx < -0.001) { + exitTimes.push((-margin - x) / vx); + } + + if (vy > 0.001) { + exitTimes.push((h + margin - y) / vy); + } else if (vy < -0.001) { + exitTimes.push((-margin - y) / vy); + } + + const positiveExitTimes = exitTimes.filter((time) => time > 0); + return Math.max(positiveExitTimes.length > 0 ? Math.min(...positiveExitTimes) : 0.001, 0.001); +} + // ─── Background Drawing ───────────────────────────────────────────────────── /** @@ -57,9 +219,10 @@ export function drawBackground( w: number, h: number, particles: DepthParticle[], + shootingStars: ShootingStarField, camera: { x: number; y: number; zoom: number }, time: number, - options?: { showHexGrid?: boolean; showStarField?: boolean }, + options?: { showHexGrid?: boolean; showStarField?: boolean } ): void { const showStars = options?.showStarField ?? true; const showHex = options?.showHexGrid ?? true; @@ -70,23 +233,22 @@ export function drawBackground( // Depth star field if (showStars) { - for (const p of particles) { - const parallax = 1 - p.depth * 0.3; - const sx = p.x + camera.x * parallax * 0.02; - const sy = p.y + camera.y * parallax * 0.02; - const twinkle = 0.7 + 0.3 * Math.sin(time * 2 + p.x * 0.01); - const alpha = p.brightness * twinkle; + const centerX = w * 0.5; + const centerY = h * 0.5; - ctx.fillStyle = COLORS.holoBright + alphaHex(alpha); - ctx.beginPath(); - ctx.arc( - ((sx % w) + w) % w, - ((sy % h) + h) % h, - p.size, - 0, - Math.PI * 2, - ); - ctx.fill(); + for (const p of particles) { + const parallax = 1.06 - p.depth * 0.5; + const sx = p.x + camera.x * parallax * 0.068; + const sy = p.y + camera.y * parallax * 0.068; + const positionScale = getStarPositionScale(camera.zoom, p.depth); + const starX = projectZoomedWrappedCoord(sx, w, centerX, positionScale); + const starY = projectZoomedWrappedCoord(sy, h, centerY, positionScale); + const primaryTwinkle = Math.sin(time * p.twinkleSpeed + p.twinkleOffset) * p.twinkleAmount; + const secondaryTwinkle = Math.sin(time * p.flickerSpeed + p.flickerOffset) * p.flickerAmount; + const twinkle = clamp(1 + primaryTwinkle + secondaryTwinkle, 0.82, 1.22); + const zoomScale = getStarZoomScale(camera.zoom, p.depth); + const alpha = p.brightness * twinkle * (0.98 + (zoomScale - 1) * 0.35) * 0.52; + drawDepthParticle(ctx, starX, starY, p, alpha, zoomScale, twinkle); } } @@ -94,6 +256,148 @@ export function drawBackground( if (showHex) { drawHexGrid(ctx, w, h, camera, time); } + + if (showStars) { + for (const shootingStar of shootingStars.active) { + drawShootingStar(ctx, shootingStar); + } + } +} + +function drawDepthParticle( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + particle: DepthParticle, + alpha: number, + zoomScale: number, + twinkle: number +): void { + const size = Math.max(0.68, particle.size * zoomScale * (0.985 + (twinkle - 1) * 0.22)); + const coreRadius = Math.max(0.48, size * (0.48 + (twinkle - 1) * 0.08)); + const coreAlpha = clamp(Math.min(1, alpha * 1.08 + 0.04), 0.16, 0.72); + + if (size > 0.8) { + const glowRadius = size * (1.45 + particle.haloStrength * 0.8); + const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius); + glow.addColorStop(0, COLORS.holoHot + alphaHex(coreAlpha * particle.haloStrength * 0.72)); + glow.addColorStop(0.42, COLORS.holoBright + alphaHex(coreAlpha * particle.haloStrength * 0.34)); + glow.addColorStop(1, COLORS.holoBright + alphaHex(0)); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(x, y, glowRadius, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.fillStyle = COLORS.holoBright + alphaHex(coreAlpha * 0.12); + ctx.beginPath(); + ctx.arc(x, y, coreRadius * 1.7, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = COLORS.holoHot + alphaHex(coreAlpha); + ctx.beginPath(); + ctx.arc(x, y, coreRadius, 0, Math.PI * 2); + ctx.fill(); +} + +function drawShootingStar(ctx: CanvasRenderingContext2D, shootingStar: ShootingStar): void { + const progress = clamp01(shootingStar.travel / Math.max(shootingStar.maxTravel, 0.001)); + const fadeIn = clamp01(progress / 0.06); + const alpha = shootingStar.brightness * fadeIn; + if (alpha <= 0) return; + + const speed = Math.hypot(shootingStar.vx, shootingStar.vy) || 1; + const dirX = shootingStar.vx / speed; + const dirY = shootingStar.vy / speed; + const tailX = shootingStar.x - dirX * shootingStar.length; + const tailY = shootingStar.y - dirY * shootingStar.length; + + const trailGradient = ctx.createLinearGradient(shootingStar.x, shootingStar.y, tailX, tailY); + trailGradient.addColorStop(0, COLORS.holoHot + alphaHex(alpha)); + trailGradient.addColorStop(0.24, COLORS.holoBright + alphaHex(alpha * 0.28)); + trailGradient.addColorStop(1, COLORS.holoBright + alphaHex(0)); + + ctx.save(); + ctx.globalCompositeOperation = 'screen'; + ctx.lineCap = 'round'; + ctx.strokeStyle = COLORS.holoBright + alphaHex(alpha * 0.1); + ctx.lineWidth = shootingStar.thickness * 2.1; + ctx.beginPath(); + ctx.moveTo(tailX, tailY); + ctx.lineTo(shootingStar.x, shootingStar.y); + ctx.stroke(); + + ctx.strokeStyle = trailGradient; + ctx.lineWidth = shootingStar.thickness; + ctx.beginPath(); + ctx.moveTo(tailX, tailY); + ctx.lineTo(shootingStar.x, shootingStar.y); + ctx.stroke(); + + const glowRadius = shootingStar.thickness * 3.4; + const headGlow = ctx.createRadialGradient( + shootingStar.x, + shootingStar.y, + 0, + shootingStar.x, + shootingStar.y, + glowRadius + ); + headGlow.addColorStop(0, COLORS.holoHot + alphaHex(alpha * 0.34)); + headGlow.addColorStop(0.4, COLORS.holoBright + alphaHex(alpha * 0.12)); + headGlow.addColorStop(1, COLORS.holoBright + alphaHex(0)); + ctx.fillStyle = headGlow; + ctx.beginPath(); + ctx.arc(shootingStar.x, shootingStar.y, glowRadius, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = COLORS.holoHot + alphaHex(Math.min(1, alpha * 1.3 + 0.06)); + ctx.beginPath(); + ctx.arc( + shootingStar.x, + shootingStar.y, + Math.max(0.52, shootingStar.thickness * 0.7), + 0, + Math.PI * 2 + ); + ctx.fill(); + ctx.restore(); +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function getStarZoomScale(zoom: number, depth: number): number { + const zoomDelta = clamp(zoom, 0.45, 2.2) - 1; + const influence = 0.03 + (1 - depth) * 0.03; + return clamp(1 + zoomDelta * influence, 0.96, 1.07); +} + +function getStarPositionScale(zoom: number, depth: number): number { + const zoomDelta = clamp(zoom, 0.45, 2.2) - 1; + const influence = 0.075 + (1 - depth) * 0.075; + return clamp(1 + zoomDelta * influence, 0.86, 1.18); +} + +function projectZoomedWrappedCoord( + value: number, + size: number, + center: number, + scale: number +): number { + const repeatSize = size / Math.max(scale, 0.0001); + const repeatOffset = center - repeatSize * 0.5; + const normalized = wrapCoord(value - repeatOffset, repeatSize) + repeatOffset; + return wrapCoord(center + (normalized - center) * scale, size); +} + +function wrapCoord(value: number, size: number): number { + return ((value % size) + size) % size; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); } // ─── Hex Grid ─────────────────────────────────────────────────────────────── @@ -110,10 +414,24 @@ function drawHexGrid( w: number, h: number, camera: { x: number; y: number; zoom: number }, - time: number, + time: number ): void { - const size = BACKGROUND.hexSize; - const pulse = BACKGROUND.hexAlpha * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed)); + const lodScale = + camera.zoom < 0.24 + ? 0 + : camera.zoom < 0.34 + ? 4 + : camera.zoom < 0.46 + ? 3 + : camera.zoom < 0.62 + ? 2 + : 1; + if (lodScale === 0) return; + + const zoomFade = clamp((camera.zoom - 0.22) / 0.4, 0, 1); + const size = BACKGROUND.hexSize * lodScale; + const pulse = + BACKGROUND.hexAlpha * zoomFade * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed)); // Visible region in world space (expanded a bit for edge cells) const worldX0 = -camera.x / camera.zoom - size * 2; diff --git a/packages/agent-graph/src/canvas/draw-activity-lanes.ts b/packages/agent-graph/src/canvas/draw-activity-lanes.ts new file mode 100644 index 00000000..ab257442 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-activity-lanes.ts @@ -0,0 +1,250 @@ +import { COLORS } from '../constants/colors'; +import { MIN_VISIBLE_OPACITY, TASK_PILL } from '../constants/canvas-constants'; +import { + ACTIVITY_LANE, + getActivityAnchorTarget, + getActivityLaneBounds, + getVisibleActivityWindow, + isActivityOwner, + resolveActivityLaneSide, +} from '../layout/activityLane'; +import type { GraphActivityItem, GraphNode } from '../ports/types'; +import { truncateText } from './draw-misc'; +import { drawPillShell } from './draw-pill-shell'; +import { hexWithAlpha, measureTextCached } from './render-cache'; + +export function drawActivityLanes( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + selectedNodeId: string | null, + hoveredNodeId: string | null, + focusNodeIds?: ReadonlySet | null, + zoom = 1 +): void { + if (zoom < 0.16) { + return; + } + + const leadNode = nodes.find((node) => node.kind === 'lead' && node.x != null); + const leadX = leadNode?.x ?? null; + + for (const node of nodes) { + if (!isActivityOwner(node) || node.x == null || node.y == null) { + continue; + } + + const opacity = getLaneOpacity(node, focusNodeIds); + if (opacity < MIN_VISIBLE_OPACITY) { + continue; + } + + const window = getVisibleActivityWindow(node.activityItems); + const overflowCount = node.activityOverflowCount ?? window.overflowCount; + if (window.items.length === 0 && overflowCount <= 0) { + continue; + } + + const anchor = getActivityAnchorTarget({ + nodeX: node.x, + nodeY: node.y, + nodeKind: node.kind, + leadX, + }); + const bounds = getActivityLaneBounds(anchor.x, anchor.y); + const left = bounds.left; + const top = bounds.top; + const side = resolveActivityLaneSide({ + nodeKind: node.kind, + nodeX: node.x, + leadX, + }); + + ctx.save(); + ctx.globalAlpha = opacity; + + drawLaneHeader(ctx, side, left, top); + + const itemsTop = top + ACTIVITY_LANE.headerHeight; + for (let index = 0; index < window.items.length; index += 1) { + const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight; + drawActivityPill(ctx, { + item: window.items[index], + x: left + ACTIVITY_LANE.width / 2, + y: itemTop + ACTIVITY_LANE.itemHeight / 2, + isOwnerSelected: node.id === selectedNodeId, + isOwnerHovered: node.id === hoveredNodeId, + }); + } + + if (overflowCount > 0) { + const overflowTop = itemsTop + window.items.length * ACTIVITY_LANE.rowHeight; + drawOverflowPill( + ctx, + left + ACTIVITY_LANE.width / 2, + overflowTop + ACTIVITY_LANE.overflowHeight / 2, + overflowCount + ); + } + + ctx.restore(); + } +} + +function getLaneOpacity(node: GraphNode, focusNodeIds?: ReadonlySet | null): number { + if (focusNodeIds && !focusNodeIds.has(node.id)) { + return 0.25; + } + return 1; +} + +function drawLaneHeader( + ctx: CanvasRenderingContext2D, + side: 'left' | 'right', + left: number, + top: number +): void { + ctx.font = 'bold 8px monospace'; + ctx.textAlign = side === 'left' ? 'right' : 'left'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.46); + ctx.fillText('Activity', side === 'left' ? left + ACTIVITY_LANE.width : left, top); +} + +function drawActivityPill( + ctx: CanvasRenderingContext2D, + params: { + item: GraphActivityItem; + x: number; + y: number; + isOwnerSelected: boolean; + isOwnerHovered: boolean; + } +): void { + const { item, x, y, isOwnerSelected, isOwnerHovered } = params; + const accent = getActivityAccentColor(item.kind); + const badgeText = getActivityBadgeText(item.kind); + + ctx.save(); + ctx.translate(x, y); + + drawPillShell(ctx, { + width: ACTIVITY_LANE.width, + height: ACTIVITY_LANE.itemHeight, + radius: TASK_PILL.borderRadius, + fillStyle: isOwnerSelected + ? COLORS.cardBgSelected + : isOwnerHovered + ? 'rgba(15, 20, 40, 0.72)' + : COLORS.cardBg, + borderColor: hexWithAlpha(accent, isOwnerSelected ? 0.76 : 0.46), + borderWidth: isOwnerSelected ? 1.7 : 1, + shadowColor: hexWithAlpha(accent, 0.22), + shadowBlur: isOwnerSelected ? 10 : 6, + accentColor: hexWithAlpha(accent, 0.72), + }); + + ctx.font = 'bold 7px monospace'; + const badgeWidth = Math.max(30, Math.ceil(measureTextCached(ctx, ctx.font, badgeText) + 14)); + ctx.fillStyle = hexWithAlpha(accent, 0.16); + ctx.beginPath(); + ctx.roundRect( + ACTIVITY_LANE.width / 2 - badgeWidth - 8, + -ACTIVITY_LANE.itemHeight / 2 + 4, + badgeWidth, + 11, + 5 + ); + ctx.fill(); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(accent, 0.96); + ctx.fillText( + badgeText, + ACTIVITY_LANE.width / 2 - badgeWidth / 2 - 8, + -ACTIVITY_LANE.itemHeight / 2 + 9.5 + ); + + ctx.textAlign = 'left'; + ctx.font = 'bold 8px monospace'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = COLORS.textPrimary; + const title = truncateText(ctx, item.title, ACTIVITY_LANE.width - badgeWidth - 26, ctx.font); + ctx.fillText(title, -ACTIVITY_LANE.width / 2 + 10, -3.5); + + const preview = item.preview?.trim(); + if (preview) { + ctx.font = '7px monospace'; + ctx.fillStyle = COLORS.textDim; + const previewText = truncateText(ctx, preview, ACTIVITY_LANE.width - 18, ctx.font); + ctx.fillText(previewText, -ACTIVITY_LANE.width / 2 + 10, 6.5); + } else if (item.authorLabel) { + ctx.font = '7px monospace'; + ctx.fillStyle = COLORS.textDim; + const authorText = truncateText(ctx, item.authorLabel, ACTIVITY_LANE.width - 18, ctx.font); + ctx.fillText(authorText, -ACTIVITY_LANE.width / 2 + 10, 6.5); + } + + ctx.restore(); +} + +function drawOverflowPill( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + overflowCount: number +): void { + const width = TASK_PILL.width; + const height = ACTIVITY_LANE.overflowHeight; + ctx.save(); + ctx.translate(x, y); + + drawPillShell(ctx, { + width, + height, + radius: 6, + fillStyle: 'rgba(10, 15, 30, 0.42)', + borderColor: hexWithAlpha(COLORS.holoBright, 0.22), + borderWidth: 1, + }); + + ctx.font = 'bold 7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.66); + ctx.fillText(`+${overflowCount} more`, 0, 0.5); + + ctx.restore(); +} + +function getActivityAccentColor(kind: GraphActivityItem['kind']): string { + switch (kind) { + case 'task_comment': + return COLORS.particleTaskComment; + case 'task_assign': + return COLORS.particleTaskAssign; + case 'review_request': + return COLORS.particleReviewRequest; + case 'review_response': + return COLORS.particleReviewResponse; + case 'inbox_message': + default: + return COLORS.particleInboxMessage; + } +} + +function getActivityBadgeText(kind: GraphActivityItem['kind']): string { + switch (kind) { + case 'task_comment': + return 'COMMENT'; + case 'task_assign': + return 'TASK'; + case 'review_request': + return 'REV'; + case 'review_response': + return 'DONE'; + case 'inbox_message': + default: + return 'MSG'; + } +} diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 87fa7b5d..21a1d228 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -25,8 +25,10 @@ export function drawAgents( time: number, selectedId: string | null, hoveredId: string | null, - focusNodeIds?: ReadonlySet | null + focusNodeIds?: ReadonlySet | null, + zoom = 1 ): void { + const simplify = zoom < 0.19; for (const node of nodes) { if (node.kind !== 'member' && node.kind !== 'lead') continue; const opacity = getNodeOpacity(node) * getFocusOpacity(node.id, focusNodeIds); @@ -42,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(); @@ -80,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') ) { @@ -92,17 +111,19 @@ export function drawAgents( ctx.stroke(); } - if (node.activeTool) { + if (!simplify && node.activeTool) { drawToolCard(ctx, x, y, r, node.activeTool, time); } - if (node.exceptionTone) { + if (!simplify && node.exceptionTone) { drawExceptionPip(ctx, x, y, r, node.exceptionTone); } - // 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) { + // 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). @@ -194,10 +215,7 @@ function getNodeOpacity(node: GraphNode): number { return 1; } -function getFocusOpacity( - nodeId: string, - focusNodeIds?: ReadonlySet | null -): number { +function getFocusOpacity(nodeId: string, focusNodeIds?: ReadonlySet | null): number { return focusNodeIds && !focusNodeIds.has(nodeId) ? 0.25 : 1; } @@ -223,6 +241,85 @@ function drawExceptionPip( ctx.restore(); } +function drawLaunchStage( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + visualState: GraphNode['launchVisualState'], + time: number +): void { + if (!visualState) { + return; + } + + ctx.save(); + switch (visualState) { + case 'waiting': { + const ringR = r + 7 + Math.sin(time * 3.2) * 1.2; + const pulseAlpha = 0.16 + 0.12 * (0.5 + 0.5 * Math.sin(time * 3.2)); + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); + ctx.lineWidth = 2; + ctx.stroke(); + break; + } + case 'spawning': { + const ringR = r + 7; + const rotation = time * 2.4; + ctx.beginPath(); + ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); + ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72); + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.stroke(); + break; + } + case 'runtime_pending': { + const ringR = r + 8; + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4); + ctx.lineWidth = 1.5; + ctx.stroke(); + + const orbit = time * 1.6; + const dotR = 2.2; + const dotX = x + Math.cos(orbit) * ringR; + const dotY = y + Math.sin(orbit) * ringR; + ctx.beginPath(); + ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#67e8f9', 0.9); + ctx.fill(); + break; + } + case 'settling': { + const ringR = r + 6; + const arc = 0.65 + 0.08 * Math.sin(time * 2.2); + const rotation = time * 1.25; + ctx.beginPath(); + ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55); + ctx.lineWidth = 1.75; + ctx.lineCap = 'round'; + ctx.stroke(); + break; + } + case 'error': { + const ringR = r + 7 + Math.sin(time * 4) * 0.8; + ctx.beginPath(); + ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15); + ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6); + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.stroke(); + break; + } + } + ctx.restore(); +} + function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void { ctx.save(); ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts index d85cec27..de3c22f2 100644 --- a/packages/agent-graph/src/canvas/draw-edges.ts +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -9,7 +9,10 @@ import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; // ─── Edge Type → Color/Width Mapping ──────────────────────────────────────── -const EDGE_STYLES: Record = { +const EDGE_STYLES: Record< + GraphEdgeType, + { color: string; startW: number; endW: number; dash?: number[] } +> = { 'parent-child': { color: COLORS.edgeParentChild, ...BEAM.parentChild }, ownership: { color: COLORS.edgeOwnership, ...BEAM.ownership }, blocking: { color: COLORS.edgeBlocking, ...BEAM.blocking, dash: [8, 4] }, @@ -30,7 +33,7 @@ export function computeControlPoints( x1: number, y1: number, x2: number, - y2: number, + y2: number ): ControlPoints { const dx = x2 - x1; const dy = y2 - y1; @@ -53,7 +56,7 @@ export function bezierPoint( cp: ControlPoints, x2: number, y2: number, - t: number, + t: number ): { x: number; y: number } { const u = 1 - t; const uu = u * u; @@ -77,7 +80,9 @@ export function drawEdges( focusEdgeIds?: ReadonlySet | null, hoveredEdgeId?: string | null, selectedEdgeId?: string | null, + zoom = 1 ): void { + const simplify = zoom < 0.18; for (const edge of edges) { const source = nodeMap.get(edge.source); const target = nodeMap.get(edge.target); @@ -89,9 +94,7 @@ export function drawEdges( 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); @@ -104,27 +107,41 @@ export function drawEdges( ctx.globalAlpha = finalAlpha; // Subtle glow pass when edge has active particles - if (isActive || isSelected || isHovered) { + if (!simplify && (isActive || isSelected || isHovered)) { ctx.shadowColor = edge.color ?? style.color; 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') { + if (!simplify && edge.type === 'blocking') { drawArrowHead(ctx, cp, target.x, target.y, style.color, finalAlpha); } @@ -132,6 +149,27 @@ export function drawEdges( } } +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( @@ -144,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 @@ -204,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; @@ -219,8 +257,14 @@ function drawArrowHead( ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x2, y2); - ctx.lineTo(x2 - ux * arrowSize - uy * arrowSize * 0.5, y2 - uy * arrowSize + ux * arrowSize * 0.5); - ctx.lineTo(x2 - ux * arrowSize + uy * arrowSize * 0.5, y2 - uy * arrowSize - ux * arrowSize * 0.5); + ctx.lineTo( + x2 - ux * arrowSize - uy * arrowSize * 0.5, + y2 - uy * arrowSize + ux * arrowSize * 0.5 + ); + ctx.lineTo( + x2 - ux * arrowSize + uy * arrowSize * 0.5, + y2 - uy * arrowSize - ux * arrowSize * 0.5 + ); ctx.closePath(); ctx.fill(); ctx.restore(); diff --git a/packages/agent-graph/src/canvas/draw-handoff-cards.ts b/packages/agent-graph/src/canvas/draw-handoff-cards.ts index 2985699c..098e439f 100644 --- a/packages/agent-graph/src/canvas/draw-handoff-cards.ts +++ b/packages/agent-graph/src/canvas/draw-handoff-cards.ts @@ -1,6 +1,7 @@ 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'; @@ -75,8 +76,9 @@ function getCardPosition(params: { stackIndex: number; }): { x: number; y: number } | null { const { node, camera, viewport, height, stackIndex } = params; - const screenX = node.x! * camera.zoom + camera.x; - const screenY = node.y! * camera.zoom + camera.y; + 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 ( @@ -88,20 +90,19 @@ function getCardPosition(params: { return null; } - const anchorGap = getAnchorGap(node, camera.zoom); const stackOffset = stackIndex * (height + HANDOFF_CARD.stackGap); - let x = screenX + anchorGap.x; - let y = screenY + anchorGap.y - stackOffset; + let x = screenX - HANDOFF_CARD.width / 2; + let y = screenY - height / 2 - stackOffset; if (x + HANDOFF_CARD.width > viewport.width - HANDOFF_CARD.viewportPadding) { - x = screenX - HANDOFF_CARD.width - Math.abs(anchorGap.x); + x = viewport.width - HANDOFF_CARD.width - HANDOFF_CARD.viewportPadding; } if (x < HANDOFF_CARD.viewportPadding) { x = HANDOFF_CARD.viewportPadding; } if (y < HANDOFF_CARD.viewportPadding) { - y = screenY + Math.abs(anchorGap.y) + stackOffset; + y = HANDOFF_CARD.viewportPadding; } if (y + height > viewport.height - HANDOFF_CARD.viewportPadding) { y = Math.max(HANDOFF_CARD.viewportPadding, viewport.height - height - HANDOFF_CARD.viewportPadding); @@ -110,32 +111,29 @@ function getCardPosition(params: { return { x, y }; } -function getAnchorGap(node: GraphNode, zoom: number): { x: number; y: number } { +function getCardAnchorPosition(node: GraphNode, zoom: number): { x: number; y: number } { switch (node.kind) { case 'lead': - return { - x: NODE.radiusLead * zoom + HANDOFF_CARD.anchorGap, - y: -(NODE.radiusLead * zoom + HANDOFF_CARD.anchorGap), - }; case 'member': - return { - x: NODE.radiusMember * zoom + HANDOFF_CARD.anchorGap, - y: -(NODE.radiusMember * zoom + HANDOFF_CARD.anchorGap), - }; + return getHandoffAnchorTarget({ + nodeX: node.x ?? 0, + nodeY: node.y ?? 0, + nodeKind: node.kind, + }); case 'task': return { - x: TASK_PILL.width * zoom * 0.5 + HANDOFF_CARD.anchorGap, - y: -(TASK_PILL.height * zoom * 0.5 + HANDOFF_CARD.anchorGap), + 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.radiusProcess * zoom + HANDOFF_CARD.anchorGap, - y: -(NODE.radiusProcess * zoom + HANDOFF_CARD.anchorGap), + 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.radiusCrossTeam * zoom + HANDOFF_CARD.anchorGap, - y: -(NODE.radiusCrossTeam * zoom + HANDOFF_CARD.anchorGap), + x: (node.x ?? 0) + NODE.radiusCrossTeam + HANDOFF_CARD.anchorGap / zoom, + y: (node.y ?? 0) - (NODE.radiusCrossTeam + HANDOFF_CARD.anchorGap / zoom), }; } } diff --git a/packages/agent-graph/src/canvas/draw-pill-shell.ts b/packages/agent-graph/src/canvas/draw-pill-shell.ts new file mode 100644 index 00000000..9c786294 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-pill-shell.ts @@ -0,0 +1,78 @@ +import { hexWithAlpha } from './render-cache'; + +export interface PillShellOptions { + width: number; + height: number; + radius: number; + fillStyle: string; + borderColor: string; + borderWidth: number; + shadowColor?: string; + shadowBlur?: number; + accentColor?: string; + accentWidth?: number; +} + +export function drawPillShell( + ctx: CanvasRenderingContext2D, + options: PillShellOptions +): void { + const { + width, + height, + radius, + fillStyle, + borderColor, + borderWidth, + shadowColor, + shadowBlur = 0, + accentColor, + accentWidth = 4, + } = options; + const halfWidth = width / 2; + const halfHeight = height / 2; + + if (shadowColor && shadowBlur > 0) { + ctx.shadowColor = shadowColor; + ctx.shadowBlur = shadowBlur; + } + + ctx.beginPath(); + ctx.roundRect(-halfWidth, -halfHeight, width, height, radius); + ctx.fillStyle = fillStyle; + ctx.fill(); + + ctx.shadowBlur = 0; + ctx.strokeStyle = borderColor; + ctx.lineWidth = borderWidth; + ctx.stroke(); + + if (accentColor) { + ctx.fillStyle = accentColor; + ctx.beginPath(); + ctx.roundRect(-halfWidth, -halfHeight, accentWidth, height, [radius, 0, 0, radius]); + ctx.fill(); + } +} + +export function drawPillStackLayer( + ctx: CanvasRenderingContext2D, + options: { + width: number; + height: number; + radius: number; + offsetX: number; + offsetY: number; + fillColor: string; + fillAlpha: number; + } +): void { + const { width, height, radius, offsetX, offsetY, fillColor, fillAlpha } = options; + const halfWidth = width / 2; + const halfHeight = height / 2; + + ctx.beginPath(); + ctx.roundRect(-halfWidth + offsetX, -halfHeight + offsetY, width, height, radius); + ctx.fillStyle = hexWithAlpha(fillColor, fillAlpha); + ctx.fill(); +} diff --git a/packages/agent-graph/src/canvas/draw-processes.ts b/packages/agent-graph/src/canvas/draw-processes.ts index cfde4344..c7f8e9b9 100644 --- a/packages/agent-graph/src/canvas/draw-processes.ts +++ b/packages/agent-graph/src/canvas/draw-processes.ts @@ -18,7 +18,9 @@ export function drawProcesses( selectedId: string | null, hoveredId: string | null, focusNodeIds?: ReadonlySet | null, + zoom = 1 ): void { + const simplify = zoom < 0.2; for (const node of nodes) { if (node.kind !== 'process') continue; @@ -32,10 +34,12 @@ export function drawProcesses( ctx.save(); 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(); @@ -46,21 +50,25 @@ export function drawProcesses( ctx.lineWidth = isSelected ? 2 : 1; ctx.stroke(); - // Spinning ring for active processes - const spinAngle = time * 2; - ctx.beginPath(); - ctx.arc(x, y, r + 3, spinAngle, spinAngle + Math.PI * 0.8); - ctx.strokeStyle = hexWithAlpha(procColor, 0.38); - ctx.lineWidth = 1.5; - ctx.stroke(); + if (!simplify) { + // Spinning ring for active processes + const spinAngle = time * 2; + ctx.beginPath(); + ctx.arc(x, y, r + 3, spinAngle, spinAngle + Math.PI * 0.8); + ctx.strokeStyle = hexWithAlpha(procColor, 0.38); + ctx.lineWidth = 1.5; + ctx.stroke(); + } - // Label - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - ctx.fillStyle = COLORS.textDim; - const label = node.label.length > 12 ? node.label.slice(0, 12) + '...' : node.label; - ctx.fillText(label, x, y + r + 4); + if (!simplify) { + // Label + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = COLORS.textDim; + const label = node.label.length > 12 ? node.label.slice(0, 12) + '...' : node.label; + ctx.fillText(label, x, y + r + 4); + } ctx.restore(); } diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index 26ac1644..ccc691a8 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -7,6 +7,7 @@ import type { GraphNode } from '../ports/types'; import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors'; import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants'; import { truncateText } from './draw-misc'; +import { drawPillShell, drawPillStackLayer } from './draw-pill-shell'; import { hexWithAlpha } from './render-cache'; import type { KanbanZoneInfo } from '../layout/kanbanLayout'; @@ -19,8 +20,10 @@ export function drawTasks( time: number, selectedId: string | null, hoveredId: string | null, - focusNodeIds?: ReadonlySet | null + focusNodeIds?: ReadonlySet | null, + zoom = 1 ): void { + const simplify = zoom < 0.2; for (const node of nodes) { if (node.kind !== 'task') continue; @@ -35,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(); } @@ -43,10 +50,7 @@ export function drawTasks( // ─── Private ──────────────────────────────────────────────────────────────── -function getTaskOpacity( - node: GraphNode, - focusNodeIds?: ReadonlySet | null -): number { +function getTaskOpacity(node: GraphNode, focusNodeIds?: ReadonlySet | null): number { if (node.taskStatus === 'deleted') return 0; if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25; return 1; @@ -84,7 +88,7 @@ function drawTaskPill( (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 @@ -101,44 +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 - : 0.5 + 0.3 * Math.sin(time * 3); + 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(); @@ -239,6 +230,55 @@ 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, @@ -252,32 +292,32 @@ function drawOverflowStack( [6, 0.18], [3, 0.28], ] as const) { - ctx.beginPath(); - ctx.roundRect(-halfW + offset, -halfH - offset, TASK_PILL.width, TASK_PILL.height, r); - ctx.fillStyle = hexWithAlpha('#334155', alpha); - ctx.fill(); + drawPillStackLayer(ctx, { + width: TASK_PILL.width, + height: TASK_PILL.height, + radius: r, + offsetX: offset, + offsetY: -offset, + fillColor: '#334155', + fillAlpha: alpha, + }); } - ctx.beginPath(); - ctx.roundRect(-halfW, -halfH, TASK_PILL.width, TASK_PILL.height, 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(COLORS.taskPending, isSelected ? 0.85 : 0.55); - ctx.lineWidth = node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 2 : 1; - ctx.stroke(); - - if (node.isBlocked) { - ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6); - ctx.beginPath(); - ctx.roundRect(-halfW, -halfH, 4, TASK_PILL.height, [r, 0, 0, r]); - ctx.fill(); - } + 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'; @@ -296,7 +336,7 @@ function drawReviewChip( halfH: number, node: GraphNode ): void { - const chipText = node.reviewMode === 'manual' ? 'REV' : node.reviewerName ?? 'REV'; + 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; @@ -334,8 +374,10 @@ function drawReviewChip( */ export function drawColumnHeaders( ctx: CanvasRenderingContext2D, - zones: KanbanZoneInfo[] + zones: KanbanZoneInfo[], + zoom = 1 ): void { + if (zoom < 0.22) return; for (const zone of zones) { // Section header for unassigned tasks — larger, centered above all columns if (zone.ownerId === '__unassigned__') { diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index f41066b3..59d32dcc 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -244,7 +244,7 @@ export const HIT_DETECTION = { export const BACKGROUND = { /** Number of depth particles (stars) */ - starCount: 80, + starCount: 320, /** Hex grid cell size */ hexSize: 30, /** Hex grid max alpha */ diff --git a/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts index 75b1ae89..0a842b91 100644 --- a/packages/agent-graph/src/hooks/useGraphCamera.ts +++ b/packages/agent-graph/src/hooks/useGraphCamera.ts @@ -7,6 +7,7 @@ import { useRef, useCallback } from 'react'; import type { GraphNode } from '../ports/types'; import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; +import type { WorldBounds } from '../layout/launchAnchor'; export interface CameraTransform { x: number; @@ -22,7 +23,7 @@ export interface UseGraphCameraResult { handlePanStart: (sx: number, sy: number) => void; handlePanMove: (sx: number, sy: number) => void; handlePanEnd: () => void; - zoomToFit: (nodes: GraphNode[], canvasW: number, canvasH: number) => void; + zoomToFit: (nodes: GraphNode[], canvasW: number, canvasH: number, extraBounds?: WorldBounds[]) => void; zoomIn: () => void; zoomOut: () => void; updateInertia: () => void; @@ -117,8 +118,8 @@ export function useGraphCamera(): UseGraphCameraResult { v.vy *= ANIM.inertiaDecay; }, []); - const zoomToFit = useCallback((nodes: GraphNode[], canvasW: number, canvasH: number) => { - if (nodes.length === 0) return; + const zoomToFit = useCallback((nodes: GraphNode[], canvasW: number, canvasH: number, extraBounds: WorldBounds[] = []) => { + if (nodes.length === 0 && extraBounds.length === 0) return; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const n of nodes) { @@ -135,6 +136,13 @@ export function useGraphCamera(): UseGraphCameraResult { maxY = Math.max(maxY, y + pad); } + for (const bounds of extraBounds) { + minX = Math.min(minX, bounds.left); + minY = Math.min(minY, bounds.top); + maxX = Math.max(maxX, bounds.right); + maxY = Math.max(maxY, bounds.bottom); + } + const padding = ANIM.viewportPadding; const contentW = maxX - minX + padding * 2; const contentH = maxY - minY + padding * 2; diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index 94ecb8ba..bd6dd240 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -23,12 +23,28 @@ import { getNodeStrategy } from '../strategies'; import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects'; import { getStateColor } from '../constants/colors'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; +import { + LAUNCH_ANCHOR_LAYOUT, + getActivityAnchorId, + getHandoffAnchorBounds, + getLaunchAnchorBounds, + getLaunchAnchorId, + getLaunchAnchorTarget, + isActivityAnchorId, + isLaunchAnchorId, + type WorldBounds, +} from '../layout/launchAnchor'; +import { ACTIVITY_ANCHOR_LAYOUT, getActivityAnchorTarget } from '../layout/activityLane'; // ─── Force Node/Link types (properly typed, no loose `string`) ────────────── +type InternalNodeKind = GraphNodeKind | 'launch-anchor' | 'activity-anchor'; + interface ForceNode extends SimulationNodeDatum { id: string; - kind: GraphNodeKind; + kind: InternalNodeKind; + anchorForLeadId?: string; + anchorForNodeId?: string; } interface ForceLink extends SimulationLinkDatum { @@ -51,6 +67,10 @@ export interface UseGraphSimulationResult { updateData: (nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => void; /** Tick one simulation frame — called from parent's RAF loop */ tick: (dt: number) => void; + setNodePosition: (nodeId: string, x: number, y: number) => void; + getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; + getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null; + getExtraWorldBounds: () => WorldBounds[]; } // ─── Deterministic hash for stable initial positions ───────────────────────── @@ -64,6 +84,70 @@ function deterministicPosition(id: string, seed: number): number { return ((hash & 0x7fffffff) % 1000) / 1000 - 0.5; } +function syncLaunchAnchors(forceNodes: ForceNode[]): void { + const forceNodeMap = new Map(); + for (const node of forceNodes) { + forceNodeMap.set(node.id, node); + } + const leadNode = forceNodes.find((node) => node.kind === 'lead'); + const leadX = leadNode?.x ?? leadNode?.fx ?? null; + + for (const node of forceNodes) { + let target: { x: number; y: number } | null = null; + if (node.kind === 'launch-anchor' && node.anchorForLeadId) { + const leadNode = forceNodeMap.get(node.anchorForLeadId); + if (!leadNode) continue; + target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); + } else if (node.kind === 'activity-anchor' && node.anchorForNodeId) { + const ownerNode = forceNodeMap.get(node.anchorForNodeId); + if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue; + target = getActivityAnchorTarget({ + nodeX: ownerNode.x ?? 0, + nodeY: ownerNode.y ?? 0, + nodeKind: ownerNode.kind, + leadX, + }); + } else { + continue; + } + if (!target) { + continue; + } + + node.fx = target.x; + node.fy = target.y; + node.x = target.x; + node.y = target.y; + node.vx = 0; + node.vy = 0; + } +} + +function updateLaunchAnchorCaches( + forceNodes: ForceNode[], + launchPositions: Map, + activityPositions: Map, + bounds: WorldBounds[] +): void { + launchPositions.clear(); + activityPositions.clear(); + bounds.length = 0; + + for (const node of forceNodes) { + const x = node.x ?? node.fx ?? 0; + const y = node.y ?? node.fy ?? 0; + if (node.kind === 'launch-anchor' && node.anchorForLeadId) { + launchPositions.set(node.anchorForLeadId, { x, y }); + bounds.push(getLaunchAnchorBounds(x, y)); + continue; + } + if (node.kind === 'activity-anchor' && node.anchorForNodeId) { + activityPositions.set(node.anchorForNodeId, { x, y }); + bounds.push(getHandoffAnchorBounds(x, y)); + } + } +} + // ─── Hook ─────────────────────────────────────────────────────────────────── export function useGraphSimulation(): UseGraphSimulationResult { @@ -76,6 +160,9 @@ export function useGraphSimulation(): UseGraphSimulationResult { }); const simRef = useRef | null>(null); + const launchAnchorPositionsRef = useRef(new Map()); + const activityAnchorPositionsRef = useRef(new Map()); + const extraWorldBoundsRef = useRef([]); // Initialize d3-force simulation const initSimulation = useCallback(() => { @@ -84,9 +171,18 @@ export function useGraphSimulation(): UseGraphSimulationResult { const sim = forceSimulation([]) .force('center', forceCenter(0, 0).strength(FORCE.centerStrength)) .force('charge', forceManyBody().strength((d) => { + if (d.kind === 'launch-anchor' || d.kind === 'activity-anchor') { + return 0; + } return getNodeStrategy(d.kind).getChargeStrength(); })) .force('collide', forceCollide().radius((d) => { + if (d.kind === 'launch-anchor') { + return LAUNCH_ANCHOR_LAYOUT.collisionRadius; + } + if (d.kind === 'activity-anchor') { + return ACTIVITY_ANCHOR_LAYOUT.collisionRadius; + } return getNodeStrategy(d.kind).getCollisionRadius(); })) .force('link', forceLink([]).id((d) => d.id).distance((d) => { @@ -113,6 +209,15 @@ export function useGraphSimulation(): UseGraphSimulationResult { let sim = simRef.current; if (!sim) sim = initSimulation(); + const prevInternalPositions = new Map(); + for (const forceNode of sim.nodes()) { + if (!isLaunchAnchorId(forceNode.id) && !isActivityAnchorId(forceNode.id)) continue; + prevInternalPositions.set(forceNode.id, { + x: forceNode.x ?? forceNode.fx ?? 0, + y: forceNode.y ?? forceNode.fy ?? 0, + }); + } + // Tasks excluded from d3-force — positioned by KanbanLayoutEngine const forceNodes: ForceNode[] = nodes .filter((n) => n.kind !== 'task') @@ -128,6 +233,51 @@ export function useGraphSimulation(): UseGraphSimulationResult { fy: n.fy, })); + for (const leadNode of nodes.filter((node) => node.kind === 'lead')) { + const anchorId = getLaunchAnchorId(leadNode.id); + const cached = prevInternalPositions.get(anchorId); + const target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); + const position = cached ?? target; + forceNodes.push({ + id: anchorId, + kind: 'launch-anchor', + anchorForLeadId: leadNode.id, + x: position.x, + y: position.y, + vx: 0, + vy: 0, + fx: target.x, + fy: target.y, + }); + } + + const leadNode = nodes.find((node) => node.kind === 'lead'); + for (const ownerNode of nodes.filter( + (node): node is GraphNode & { kind: 'lead' | 'member' } => + node.kind === 'lead' || node.kind === 'member' + )) { + const anchorId = getActivityAnchorId(ownerNode.id); + const cached = prevInternalPositions.get(anchorId); + const target = getActivityAnchorTarget({ + nodeX: ownerNode.x ?? 0, + nodeY: ownerNode.y ?? 0, + nodeKind: ownerNode.kind, + leadX: leadNode?.x ?? null, + }); + const position = cached ?? target; + forceNodes.push({ + id: anchorId, + kind: 'activity-anchor', + anchorForNodeId: ownerNode.id, + x: position.x, + y: position.y, + vx: 0, + vy: 0, + fx: target.x, + fy: target.y, + }); + } + // Links only between non-task nodes (parent-child: lead↔member) const forceNodeIds = new Set(forceNodes.map((n) => n.id)); const forceLinks: ForceLink[] = edges @@ -144,7 +294,10 @@ export function useGraphSimulation(): UseGraphSimulationResult { sim.alpha(1); // Run simulation to near-completion so nodes are settled on first render - for (let i = 0; i < 120; i++) sim.tick(); + for (let i = 0; i < 120; i++) { + syncLaunchAnchors(sim.nodes()); + sim.tick(); + } sim.alpha(0); // fully settled — no more movement until new data // Copy settled positions BACK to GraphNode objects @@ -162,6 +315,12 @@ export function useGraphSimulation(): UseGraphSimulationResult { // Position tasks in kanban zones relative to their owners KanbanLayoutEngine.layout(nodes); + updateLaunchAnchorCaches( + sim.nodes(), + launchAnchorPositionsRef.current, + activityAnchorPositionsRef.current, + extraWorldBoundsRef.current + ); }, [initSimulation]); // Track previous node IDs and states for effect spawning @@ -225,7 +384,49 @@ export function useGraphSimulation(): UseGraphSimulationResult { // Tick one frame (called by parent's RAF loop) const tick = useCallback((dt: number) => { - tickFrame(stateRef.current, simRef.current, dt); + tickFrame( + stateRef.current, + simRef.current, + dt, + launchAnchorPositionsRef.current, + activityAnchorPositionsRef.current, + extraWorldBoundsRef.current + ); + }, []); + + const setNodePosition = useCallback((nodeId: string, x: number, y: number) => { + const graphNode = stateRef.current.nodes.find((node) => node.id === nodeId); + if (graphNode) { + graphNode.fx = x; + graphNode.fy = y; + graphNode.x = x; + graphNode.y = y; + graphNode.vx = 0; + graphNode.vy = 0; + } + + const sim = simRef.current; + if (!sim) { + return; + } + + const simNode = sim.nodes().find((node) => node.id === nodeId); + if (simNode) { + simNode.fx = x; + simNode.fy = y; + simNode.x = x; + simNode.y = y; + simNode.vx = 0; + simNode.vy = 0; + } + + syncLaunchAnchors(sim.nodes()); + updateLaunchAnchorCaches( + sim.nodes(), + launchAnchorPositionsRef.current, + activityAnchorPositionsRef.current, + extraWorldBoundsRef.current + ); }, []); // Cleanup @@ -235,7 +436,24 @@ export function useGraphSimulation(): UseGraphSimulationResult { }; }, []); - return { stateRef, updateData, tick }; + const getLaunchAnchorWorldPosition = useCallback((leadNodeId: string) => { + return launchAnchorPositionsRef.current.get(leadNodeId) ?? null; + }, []); + + const getExtraWorldBounds = useCallback(() => { + return extraWorldBoundsRef.current; + }, []); + + return { + stateRef, + updateData, + tick, + setNodePosition, + getLaunchAnchorWorldPosition, + getActivityAnchorWorldPosition: (nodeId: string) => + activityAnchorPositionsRef.current.get(nodeId) ?? null, + getExtraWorldBounds, + }; } function mergeParticles( @@ -261,11 +479,15 @@ function tickFrame( state: SimulationState, sim: Simulation | null, dt: number, + launchAnchorPositions: Map, + activityAnchorPositions: Map, + extraWorldBounds: WorldBounds[], ): void { state.time += dt; // Tick d3-force (only when simulation is still active) if (sim && sim.alpha() > 0.001) { + syncLaunchAnchors(sim.nodes()); sim.tick(1); const simNodes = sim.nodes(); @@ -281,6 +503,15 @@ function tickFrame( node.vy = sn.vy; } } + updateLaunchAnchorCaches(simNodes, launchAnchorPositions, activityAnchorPositions, extraWorldBounds); + } else if (sim) { + syncLaunchAnchors(sim.nodes()); + updateLaunchAnchorCaches( + sim.nodes(), + launchAnchorPositions, + activityAnchorPositions, + extraWorldBounds + ); } // Re-layout tasks in kanban zones — always run to handle new/moved tasks diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts index 26a1f01c..27a12196 100644 --- a/packages/agent-graph/src/index.ts +++ b/packages/agent-graph/src/index.ts @@ -20,8 +20,10 @@ export type { GraphNode, GraphEdge, GraphParticle, + GraphActivityItem, GraphNodeKind, GraphNodeState, + GraphLaunchVisualState, GraphEdgeType, GraphParticleKind, GraphDomainRef, diff --git a/packages/agent-graph/src/layout/activityLane.ts b/packages/agent-graph/src/layout/activityLane.ts new file mode 100644 index 00000000..a5766933 --- /dev/null +++ b/packages/agent-graph/src/layout/activityLane.ts @@ -0,0 +1,196 @@ +import { KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants'; +import type { GraphActivityItem, GraphNode } from '../ports/types'; + +export const ACTIVITY_LANE = { + width: 296, + itemHeight: 58, + rowHeight: 62, + maxVisibleItems: 3, + headerHeight: 18, + overflowHeight: 18, + horizontalGapLead: 76, + horizontalGapMember: 84, + bottomClearance: 18, + viewportPadding: 12, + visiblePadding: 80, + minScale: 0, + maxScale: 1, +} as const; + +const RESERVED_HEIGHT = + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight; + +export const ACTIVITY_ANCHOR_LAYOUT = { + reservedWidth: ACTIVITY_LANE.width, + reservedHeight: RESERVED_HEIGHT, + memberOffsetX: ACTIVITY_LANE.width / 2 + NODE.radiusMember + ACTIVITY_LANE.horizontalGapMember, + memberOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance), + leadOffsetX: -(ACTIVITY_LANE.width / 2 + NODE.radiusLead + ACTIVITY_LANE.horizontalGapLead), + leadOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance), + collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 12, +} as const; + +export interface ActivityLaneWindow { + items: GraphActivityItem[]; + overflowCount: number; +} + +export interface ActivityAnchorScreenPlacement { + x: number; + y: number; + scale: number; + visible: boolean; +} + +export interface ActivityLaneItemHit { + ownerNodeId: string; + item: GraphActivityItem; +} + +export type ActivityLaneSide = 'left' | 'right'; + +export function resolveActivityLaneSide(args: { + nodeKind: 'lead' | 'member'; + nodeX: number; + leadX?: number | null; +}): ActivityLaneSide { + const { nodeKind, nodeX, leadX } = args; + if (nodeKind === 'lead') { + return 'left'; + } + if (leadX == null) { + return 'right'; + } + return nodeX < leadX ? 'left' : 'right'; +} + +export function getActivityAnchorTarget(args: { + nodeX: number; + nodeY: number; + nodeKind: 'lead' | 'member'; + leadX?: number | null; +}): { x: number; y: number } { + const { nodeX, nodeY, nodeKind, leadX } = args; + const side = resolveActivityLaneSide({ nodeKind, nodeX, leadX }); + if (side === 'left') { + return { + x: nodeX + ACTIVITY_ANCHOR_LAYOUT.leadOffsetX, + y: nodeY + ACTIVITY_ANCHOR_LAYOUT.leadOffsetY, + }; + } + + return { + x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX, + y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY, + }; +} + +export function getActivityLaneBounds(anchorX: number, anchorY: number): { + left: number; + top: number; + right: number; + bottom: number; +} { + const halfWidth = ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2; + const halfHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2; + return { + left: anchorX - halfWidth, + top: anchorY - halfHeight, + right: anchorX + halfWidth, + bottom: anchorY + halfHeight, + }; +} + +export function getActivityLaneScale(zoom: number): number { + return Math.max(ACTIVITY_LANE.minScale, Math.min(ACTIVITY_LANE.maxScale, zoom)); +} + +export function getActivityAnchorScreenPlacement(args: { + anchorX: number; + anchorY: number; + cameraX: number; + cameraY: number; + zoom: number; + viewportWidth: number; + viewportHeight: number; +}): ActivityAnchorScreenPlacement { + const { anchorX, anchorY, cameraX, cameraY, zoom, viewportWidth, viewportHeight } = args; + const scale = getActivityLaneScale(zoom); + const scaledWidth = ACTIVITY_LANE.width * scale; + const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale; + const screenX = anchorX * zoom + cameraX; + const screenY = anchorY * zoom + cameraY; + const x = screenX - scaledWidth / 2; + const y = screenY - scaledHeight / 2; + const right = x + scaledWidth; + const bottom = y + scaledHeight; + + return { + x, + y, + scale, + visible: + right > -ACTIVITY_LANE.visiblePadding && + x < viewportWidth + ACTIVITY_LANE.visiblePadding && + bottom > -ACTIVITY_LANE.visiblePadding && + y < viewportHeight + ACTIVITY_LANE.visiblePadding, + }; +} + +export function getVisibleActivityWindow( + items: GraphActivityItem[] | undefined +): ActivityLaneWindow { + const source = items ?? []; + if (source.length <= ACTIVITY_LANE.maxVisibleItems) { + return { items: source, overflowCount: 0 }; + } + return { + items: source.slice(0, ACTIVITY_LANE.maxVisibleItems), + overflowCount: source.length - ACTIVITY_LANE.maxVisibleItems, + }; +} + +export function findActivityItemAt( + worldX: number, + worldY: number, + nodes: GraphNode[] +): ActivityLaneItemHit | null { + const leadNode = nodes.find((node) => node.kind === 'lead' && node.x != null); + const leadX = leadNode?.x ?? null; + for (const node of nodes) { + if (!isActivityOwner(node) || node.x == null || node.y == null) continue; + const { items } = getVisibleActivityWindow(node.activityItems); + if (items.length === 0) continue; + + const anchor = getActivityAnchorTarget({ + nodeX: node.x, + nodeY: node.y, + nodeKind: node.kind, + leadX, + }); + const bounds = getActivityLaneBounds(anchor.x, anchor.y); + const left = bounds.left; + const top = bounds.top; + const itemsTop = top + ACTIVITY_LANE.headerHeight; + + for (let index = 0; index < items.length; index += 1) { + const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight; + if ( + worldX >= left && + worldX <= left + ACTIVITY_LANE.width && + worldY >= itemTop && + worldY <= itemTop + ACTIVITY_LANE.itemHeight + ) { + return { ownerNodeId: node.id, item: items[index] }; + } + } + } + + return null; +} + +export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } { + return node.kind === 'lead' || node.kind === 'member'; +} diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index d11d179e..ccbb3be2 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -10,6 +10,7 @@ import type { GraphNode } from '../ports/types'; import { KANBAN_ZONE } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; +import { resolveActivityLaneSide } from './activityLane'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -40,6 +41,35 @@ const COLUMN_LABELS: Record = { approved: { label: 'Approved', color: COLORS.reviewApproved }, }; +export function getOwnerKanbanBaseX(args: { + ownerX: number; + ownerKind: GraphNode['kind']; + activeColumnCount: number; + columnWidth: number; + leadX?: number | null; +}): number { + const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args; + if (activeColumnCount <= 0) { + return ownerX; + } + + if (ownerKind !== 'lead' && ownerKind !== 'member') { + return ownerX - (activeColumnCount * columnWidth) / 2; + } + + const side = resolveActivityLaneSide({ + nodeKind: ownerKind, + nodeX: ownerX, + leadX, + }); + + if (side === 'left') { + return ownerX; + } + + return ownerX - (activeColumnCount - 1) * columnWidth; +} + export class KanbanLayoutEngine { // Reusable collections (cleared each call, never GC'd) static readonly #nodeMap = new Map(); @@ -58,6 +88,7 @@ export class KanbanLayoutEngine { const nodeMap = this.#nodeMap; nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); + const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); @@ -84,7 +115,7 @@ export class KanbanLayoutEngine { for (const [ownerId, tasks] of tasksByOwner) { const owner = nodeMap.get(ownerId); if (!owner || owner.x == null || owner.y == null) continue; - const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y, ownerId); + const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner, ownerId, leadX); if (zoneInfo) this.zones.push(zoneInfo); } @@ -93,9 +124,16 @@ export class KanbanLayoutEngine { // ─── Private ────────────────────────────────────────────────────────────── - static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null { + 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[] = []; diff --git a/packages/agent-graph/src/layout/launchAnchor.ts b/packages/agent-graph/src/layout/launchAnchor.ts new file mode 100644 index 00000000..522c818d --- /dev/null +++ b/packages/agent-graph/src/layout/launchAnchor.ts @@ -0,0 +1,117 @@ +import { NODE } from '../constants/canvas-constants'; +import { + ACTIVITY_ANCHOR_LAYOUT, + getActivityAnchorTarget, + getActivityLaneBounds, +} from './activityLane'; + +export interface WorldBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +export interface LaunchAnchorScreenPlacement { + x: number; + y: number; + scale: number; + visible: boolean; +} + +export const LAUNCH_ANCHOR_LAYOUT = { + compactWidth: 336, + compactHeight: 132, + anchorCenterOffsetX: 336 / 2 + NODE.radiusLead + 40, + anchorCenterOffsetY: -(132 / 2 + NODE.radiusLead + 36), + collisionRadius: Math.ceil(Math.hypot(336 / 2, 132 / 2)) + 14, + viewportPadding: 12, + visiblePadding: 80, + minScale: 0, + maxScale: 1, +} as const; + +const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:'; +const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:'; + +export function getLaunchAnchorId(leadNodeId: string): string { + return `${LAUNCH_ANCHOR_PREFIX}${leadNodeId}`; +} + +export function getActivityAnchorId(nodeId: string): string { + return `${ACTIVITY_ANCHOR_PREFIX}${nodeId}`; +} + +export function isLaunchAnchorId(nodeId: string): boolean { + return nodeId.startsWith(LAUNCH_ANCHOR_PREFIX); +} + +export function isActivityAnchorId(nodeId: string): boolean { + return nodeId.startsWith(ACTIVITY_ANCHOR_PREFIX); +} + +export function getLaunchAnchorTarget(leadX: number, leadY: number): { x: number; y: number } { + return { + x: leadX + LAUNCH_ANCHOR_LAYOUT.anchorCenterOffsetX, + y: leadY + LAUNCH_ANCHOR_LAYOUT.anchorCenterOffsetY, + }; +} + +export function getLaunchHudScale(zoom: number): number { + return clamp(zoom, LAUNCH_ANCHOR_LAYOUT.minScale, LAUNCH_ANCHOR_LAYOUT.maxScale); +} + +export function getLaunchAnchorBounds(anchorX: number, anchorY: number): WorldBounds { + const halfWidth = LAUNCH_ANCHOR_LAYOUT.compactWidth / 2; + const halfHeight = LAUNCH_ANCHOR_LAYOUT.compactHeight / 2; + return { + left: anchorX - halfWidth, + top: anchorY - halfHeight, + right: anchorX + halfWidth, + bottom: anchorY + halfHeight, + }; +} + +export const getLaunchHudBounds = getLaunchAnchorBounds; +export const HANDOFF_ANCHOR_LAYOUT = ACTIVITY_ANCHOR_LAYOUT; +export const getHandoffAnchorId = getActivityAnchorId; +export const isHandoffAnchorId = isActivityAnchorId; +export { getActivityAnchorTarget }; +export const getHandoffAnchorTarget = getActivityAnchorTarget; +export const getHandoffAnchorBounds = getActivityLaneBounds; + +export function getLaunchAnchorScreenPlacement(args: { + anchorX: number; + anchorY: number; + cameraX: number; + cameraY: number; + zoom: number; + viewportWidth: number; + viewportHeight: number; +}): LaunchAnchorScreenPlacement { + const { anchorX, anchorY, cameraX, cameraY, zoom, viewportWidth, viewportHeight } = args; + const scale = getLaunchHudScale(zoom); + const scaledWidth = LAUNCH_ANCHOR_LAYOUT.compactWidth * scale; + const scaledHeight = LAUNCH_ANCHOR_LAYOUT.compactHeight * scale; + const screenX = anchorX * zoom + cameraX; + const screenY = anchorY * zoom + cameraY; + const rawX = screenX - scaledWidth / 2; + const rawY = screenY - scaledHeight / 2; + const maxX = viewportWidth - scaledWidth - LAUNCH_ANCHOR_LAYOUT.viewportPadding; + const maxY = viewportHeight - scaledHeight - LAUNCH_ANCHOR_LAYOUT.viewportPadding; + + return { + x: clamp(rawX, LAUNCH_ANCHOR_LAYOUT.viewportPadding, Math.max(LAUNCH_ANCHOR_LAYOUT.viewportPadding, maxX)), + y: clamp(rawY, LAUNCH_ANCHOR_LAYOUT.viewportPadding, Math.max(LAUNCH_ANCHOR_LAYOUT.viewportPadding, maxY)), + scale, + visible: + screenX > -LAUNCH_ANCHOR_LAYOUT.visiblePadding && + screenX < viewportWidth + LAUNCH_ANCHOR_LAYOUT.visiblePadding && + screenY > -LAUNCH_ANCHOR_LAYOUT.visiblePadding && + screenY < viewportHeight + LAUNCH_ANCHOR_LAYOUT.visiblePadding, + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 3ad3ece2..931f38e2 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -17,6 +17,13 @@ export type GraphNodeState = | 'error' | 'terminated'; +export type GraphLaunchVisualState = + | 'waiting' + | 'spawning' + | 'runtime_pending' + | 'settling' + | 'error'; + // ─── Edge & Particle Types ─────────────────────────────────────────────────── export type GraphEdgeType = 'parent-child' | 'ownership' | 'blocking' | 'related' | 'message'; @@ -29,6 +36,18 @@ export type GraphParticleKind = | 'review_response' | 'spawn'; +export interface GraphActivityItem { + id: string; + kind: Exclude; + timestamp: string; + title: string; + preview?: string; + accentColor?: string; + taskId?: string; + taskDisplayId?: string; + authorLabel?: string; +} + // ─── Graph Node ────────────────────────────────────────────────────────────── export interface GraphNode { @@ -50,6 +69,8 @@ export interface GraphNode { avatarUrl?: string; /** Spawn lifecycle status */ spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; + /** Shared launch-stage visual derived by the host app */ + launchVisualState?: GraphLaunchVisualState; /** Context window usage ratio (0..1), available for lead only */ contextUsage?: number; /** Current task ID this member is working on */ @@ -82,6 +103,10 @@ export interface GraphNode { 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") */ diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 74bb5ed6..1e8c893d 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -8,7 +8,15 @@ 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'; @@ -18,7 +26,10 @@ 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 { + computeAdaptiveParticleBudget, + selectRenderableParticles, +} from './selectRenderableParticles'; import { createTransientHandoffState, selectRenderableTransientHandoffCards, @@ -77,16 +88,24 @@ export const GraphCanvas = forwardRef(funct onContextMenu, className, }, - ref, + ref ) { const canvasRef = useRef(null); const containerRef = useRef(null); const bloomRef = useRef(new BloomRenderer(bloomIntensity)); const starsRef = useRef([]); + const shootingStarsRef = useRef(createShootingStarField()); const sizeRef = useRef({ w: 0, h: 0 }); + const lastBackgroundTimeRef = useRef(null); // Performance tracking - const perfRef = useRef({ frames: 0, fps: 0, frameTimeMs: 0, lastFpsUpdate: 0, frameTimes: [] as number[] }); + const perfRef = useRef({ + frames: 0, + fps: 0, + frameTimeMs: 0, + lastFpsUpdate: 0, + frameTimes: [] as number[], + }); // Rate-limited error logging (prevent console flood at 60fps) const lastDrawErrorRef = useRef(0); @@ -115,6 +134,8 @@ export const GraphCanvas = forwardRef(funct sizeRef.current = { w: width, h: height }; bloomRef.current.resize(width * dpr, height * dpr); starsRef.current = createDepthParticles(width, height); + shootingStarsRef.current = createShootingStarField(); + lastBackgroundTimeRef.current = null; } }); @@ -134,235 +155,260 @@ export const GraphCanvas = forwardRef(funct const lastTeamNameRef = useRef(null); // Imperative draw function — called from RAF, NOT from React render - useImperativeHandle(ref, () => ({ - draw: (state: GraphDrawState) => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; + useImperativeHandle( + ref, + () => ({ + draw: (state: GraphDrawState) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; - const frameStart = performance.now(); + const frameStart = performance.now(); - const dpr = window.devicePixelRatio || 1; - const { w, h } = sizeRef.current; - if (w === 0 || h === 0) return; + const dpr = window.devicePixelRatio || 1; + const { w, h } = sizeRef.current; + if (w === 0 || h === 0) return; - try { - if (lastTeamNameRef.current !== state.teamName) { - handoffStateRef.current = createTransientHandoffState(); - lastTeamNameRef.current = state.teamName; - } + 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; - 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 - ); - - // 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, - }); - 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, - } - ); - 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 - ); - drawCrossTeamNodes( - ctx, - visibleNodes, - state.time, - state.selectedNodeId, - state.hoveredNodeId, - state.focusNodeIds - ); - drawColumnHeaders(ctx, KanbanLayoutEngine.zones); - drawTasks( - ctx, - visibleNodes, - state.time, - state.selectedNodeId, - state.hoveredNodeId, - state.focusNodeIds - ); - drawAgents( - ctx, - visibleNodes, - state.time, - state.selectedNodeId, - state.hoveredNodeId, - state.focusNodeIds - ); - - // 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); - } - } - }, - getCanvas: () => canvasRef.current, - }), [showHexGrid, showStarField, bloomIntensity]); + }, + getCanvas: () => canvasRef.current, + }), + [showHexGrid, showStarField, bloomIntensity] + ); // Wheel handler (passive: false required for preventDefault) useEffect(() => { @@ -377,7 +423,7 @@ export const GraphCanvas = forwardRef(funct }, [onWheel]); return ( -
+
-
+
-
+
-
+
{showBlockingHint && ( -
+
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; + focusNodeIds: ReadonlySet | null; + }) => React.ReactNode; } export function GraphView({ @@ -67,6 +78,7 @@ export function GraphView({ onRequestFullscreen, renderOverlay, renderEdgeOverlay, + renderHud, }: GraphViewProps): React.JSX.Element { // ─── React state (user-facing only) ───────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -108,16 +120,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 ──────────────────────────────── @@ -134,7 +142,6 @@ 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] @@ -168,6 +175,53 @@ export function GraphView({ }, [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 animate = useCallback(() => { if (!runningRef.current) return; @@ -175,7 +229,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; @@ -185,18 +239,8 @@ 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({ @@ -238,8 +282,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(() => { @@ -288,165 +337,188 @@ 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); - const nodes = simulation.stateRef.current.nodes; - const edges = simulation.stateRef.current.edges; - const nodeMap = getNodeMap(nodes); - const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); - - // Check if we hit a node - interaction.handleMouseDown(world.x, world.y, nodes); - - // 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); - } - } - }, [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; - } + 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); - const nodes = simulation.stateRef.current.nodes; - const edges = simulation.stateRef.current.edges; - const hoveredNodeId = findNodeAt(world.x, world.y, nodes); - interaction.hoveredNodeId.current = hoveredNodeId; + // Check if we hit a node + interaction.handleMouseDown(world.x, world.y, nodes); - if (hoveredNodeId) { - hoveredEdgeIdRef.current = null; - canvas.style.cursor = 'pointer'; - return; - } + // 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); + } + } + }, + [camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef] + ); - 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 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); - const dx = world.x - edgeMouseDownRef.current.x; - const dy = world.y - edgeMouseDownRef.current.y; - if (dx * dx + dy * dy <= 25) { - clickedEdgeId = edgeMouseDownRef.current.id; - } + interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes); + return; } - 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 + // 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; } - if (!interaction.isDragging.current && !clickedEdgeId) { - 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) { - 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; + 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?.(); } - events?.onNodeDoubleClick?.(node.domainRef); } - } - }, [camera, interaction, simulation.stateRef, events]); + }, + [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 || selectedEdgeId) { @@ -458,7 +530,13 @@ export function GraphView({ } 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(); @@ -470,14 +548,12 @@ export function GraphView({ }, [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 selectedEdge: GraphEdge | null = - selectedEdgeId - ? simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? 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] @@ -559,7 +635,7 @@ export function GraphView({ // ─── Render ───────────────────────────────────────────────────────────── return ( -
+
{ markUserInteracted(); const el = containerRef.current; - if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + if (el) + camera.zoomToFit( + simulation.stateRef.current.nodes, + el.clientWidth, + el.clientHeight, + simulation.getExtraWorldBounds() + ); }} onRequestClose={onRequestClose} onRequestPinAsTab={onRequestPinAsTab} @@ -597,8 +679,18 @@ export function GraphView({ showBlockingHint={filters.showEdges && hasBlockingEdges && !selectedNode && !selectedEdge} /> + {renderHud ? ( +
+ {renderHud({ + getLaunchAnchorScreenPlacement, + getActivityAnchorScreenPlacement, + focusNodeIds: focusState.focusNodeIds, + })} +
+ ) : null} + {(selectedNode || selectedEdge) && ( -
+
{selectedNode ? ( renderOverlay ? ( renderOverlay({ diff --git a/packages/agent-graph/src/ui/selectRenderableParticles.ts b/packages/agent-graph/src/ui/selectRenderableParticles.ts index 1247bf74..d642464a 100644 --- a/packages/agent-graph/src/ui/selectRenderableParticles.ts +++ b/packages/agent-graph/src/ui/selectRenderableParticles.ts @@ -9,6 +9,7 @@ export function computeAdaptiveParticleBudget(params: { visibleEdgeCount: number; frameTimeMs: number; hasFocusedEdges: boolean; + zoom?: number; }): number { const baseBudget = Math.max( MIN_PARTICLE_BUDGET, @@ -16,12 +17,18 @@ export function computeAdaptiveParticleBudget(params: { ); 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(baseBudget * 0.55); + adjustedBudget = Math.floor(adjustedBudget * 0.55); } else if (params.frameTimeMs >= 18) { - adjustedBudget = Math.floor(baseBudget * 0.72); + adjustedBudget = Math.floor(adjustedBudget * 0.72); } else if (params.frameTimeMs >= 14) { - adjustedBudget = Math.floor(baseBudget * 0.88); + adjustedBudget = Math.floor(adjustedBudget * 0.88); } if (params.hasFocusedEdges) { @@ -66,9 +73,10 @@ export function selectRenderableParticles(params: { 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 nonFocused = + focused.length === indexed.length + ? [] + : indexed.filter(({ particle }) => !(params.focusEdgeIds?.has(particle.edgeId) ?? false)); const selectedById = new Set(); const seenEdges = new Set(); diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index 2150756c..b0baa0d1 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -19,10 +19,21 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim() ? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim()) : defaultRuntimeCacheRoot; const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path'); +const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); + +function shouldUseWindowsShell(cmd) { + if (process.platform !== 'win32') { + return false; + } + + const commandName = path.basename(cmd).toLowerCase(); + return WINDOWS_SHELL_COMMANDS.has(commandName); +} function runOrExit(cmd, args, options = {}) { const result = spawnSync(cmd, args, { stdio: 'inherit', + shell: shouldUseWindowsShell(cmd), ...options, }); @@ -39,6 +50,7 @@ function runOrExit(cmd, args, options = {}) { function runAndCapture(cmd, args, options = {}) { const result = spawnSync(cmd, args, { encoding: 'utf8', + shell: shouldUseWindowsShell(cmd), ...options, }); @@ -506,8 +518,9 @@ async function main() { CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: resolvedRuntime.binaryPath, }; delete uiEnv.CLAUDE_CLI_PATH; + const uiPackageManager = readPackageManagerCommand(uiRepoRoot); - runOrExit('pnpm', ['exec', 'electron-vite', 'dev'], { + runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev'], { cwd: uiRepoRoot, env: uiEnv, }); diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index ccc70c57..2e0910df 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -1,7 +1,7 @@ /** * MoreMenu - Dropdown menu behind a "..." icon for less-frequent toolbar actions. * - * Groups: Search, Export (session-only), Analyze (session-only). + * Groups: Teams, Settings, Extensions, Search, Schedules, Export (session-only), Analyze (session-only). * Closes on outside click or Escape. */ @@ -10,7 +10,18 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { triggerDownload } from '@renderer/utils/sessionExporter'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Activity, Braces, Calendar, FileText, MoreHorizontal, Search, Type } from 'lucide-react'; +import { + Activity, + Braces, + Calendar, + FileText, + MoreHorizontal, + Puzzle, + Search, + Settings, + Type, + Users, +} from 'lucide-react'; import type { SessionDetail } from '@renderer/types/data'; import type { Tab } from '@renderer/types/tabs'; @@ -41,8 +52,11 @@ export const MoreMenu = ({ const containerRef = useRef(null); const openCommandPalette = useStore((s) => s.openCommandPalette); + const openExtensionsTab = useStore((s) => s.openExtensionsTab); const openSessionReport = useStore((s) => s.openSessionReport); const openSchedulesTab = useStore((s) => s.openSchedulesTab); + const openSettingsTab = useStore((s) => s.openSettingsTab); + const openTeamsTab = useStore((s) => s.openTeamsTab); // Close on outside click useEffect(() => { @@ -86,6 +100,33 @@ export const MoreMenu = ({ // Build menu sections const topItems: MenuItem[] = [ + { + id: 'teams', + label: 'Teams', + icon: Users, + onClick: () => { + openTeamsTab(); + setIsOpen(false); + }, + }, + { + id: 'settings', + label: 'Settings', + icon: Settings, + onClick: () => { + openSettingsTab(); + setIsOpen(false); + }, + }, + { + id: 'extensions', + label: 'Extensions', + icon: Puzzle, + onClick: () => { + openExtensionsTab(); + setIsOpen(false); + }, + }, { id: 'search', label: 'Search', diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index e6674307..56f99fe9 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -7,7 +7,12 @@ import { useCallback, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { + getTeamColorSet, + getThemedBadge, + getThemedBorder, + getThemedText, +} from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { nameColorSet } from '@renderer/utils/projectColor'; @@ -78,7 +83,7 @@ export const SortableTab = ({ const teamColorSet = useStore( useShallow((s) => { - if (tab.type !== 'team' || !tab.teamName) return null; + if ((tab.type !== 'team' && tab.type !== 'graph') || !tab.teamName) return null; const team = s.teamByName[tab.teamName]; const explicitColor = team?.color ?? @@ -86,10 +91,13 @@ export const SortableTab = ({ if (explicitColor) return getTeamColorSet(explicitColor); // Fallback: deterministic color derived from display name const displayName = team?.displayName ?? tab.label; - return nameColorSet(displayName); + return nameColorSet(displayName, isLight); }) ); - const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)'; + const activeBorderColor = teamColorSet + ? getThemedBorder(teamColorSet, isLight) + : 'var(--color-accent, #6366f1)'; + const inactiveTeamTextColor = teamColorSet ? getThemedText(teamColorSet, isLight) : null; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id, @@ -116,11 +124,12 @@ export const SortableTab = ({ : teamColorSet ? getThemedBadge(teamColorSet, isLight) : 'transparent', - color: - isActive || isHovered - ? 'var(--color-text)' - : teamColorSet - ? teamColorSet.text + color: isActive + ? 'var(--color-text)' + : inactiveTeamTextColor + ? inactiveTeamTextColor + : isHovered + ? 'var(--color-text)' : 'var(--color-text-muted)', outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none', outlineOffset: '-1px', diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx index 82924d9d..b60ab422 100644 --- a/src/renderer/components/layout/TabBarActions.tsx +++ b/src/renderer/components/layout/TabBarActions.tsx @@ -9,7 +9,7 @@ import { useMemo, useState } from 'react'; import { isElectronMode } from '@renderer/api'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { Bell, PanelRight, Puzzle, Settings, Users } from 'lucide-react'; +import { Bell, PanelRight } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { MoreMenu } from './MoreMenu'; @@ -18,9 +18,6 @@ export const TabBarActions = (): React.JSX.Element => { const { unreadCount, openNotificationsTab, - openTeamsTab, - openExtensionsTab, - openSettingsTab, activeTabId, openTabs, tabSessionData, @@ -32,9 +29,6 @@ export const TabBarActions = (): React.JSX.Element => { useShallow((s) => ({ unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, - openTeamsTab: s.openTeamsTab, - openExtensionsTab: s.openExtensionsTab, - openSettingsTab: s.openSettingsTab, activeTabId: s.activeTabId, openTabs: s.openTabs, tabSessionData: s.tabSessionData, @@ -47,10 +41,8 @@ export const TabBarActions = (): React.JSX.Element => { // Hover states for buttons const [notificationsHover, setNotificationsHover] = useState(false); - const [teamsHover, setTeamsHover] = useState(false); - const [extensionsHover, setExtensionsHover] = useState(false); const [githubHover, setGithubHover] = useState(false); - const [settingsHover, setSettingsHover] = useState(false); + const [discordHover, setDiscordHover] = useState(false); const [expandHover, setExpandHover] = useState(false); const [updateHover, setUpdateHover] = useState(false); @@ -113,36 +105,6 @@ export const TabBarActions = (): React.JSX.Element => { )} - {/* Teams icon */} - - - {/* Extensions icon */} - - {/* GitHub link */} - {/* Settings gear icon */} + {/* Discord link */} - {/* More menu (Search, Export, Analyze, Schedules) */} + {/* More menu (Teams, Settings, Extensions, Search, Export, Analyze, Schedules) */} 0; @@ -505,19 +515,14 @@ export const ProviderRuntimeSettingsDialog = ({ selectedProvider && isApiKeyProviderId(selectedProvider.providerId) && activeApiKeyFormProviderId === selectedProvider.providerId; - const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; - const configuredAuthMode: CliProviderAuthMode | undefined = - selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined; const codexApiKeyBetaEnabled = selectedProvider?.connection?.apiKeyBetaEnabled === true; - const connectionMethodCardOptions = selectedProvider - ? getConnectionMethodCardOptions(selectedProvider) - : null; + const showApiKeySection = Boolean( + apiKeyConfig && (selectedProvider?.providerId !== 'codex' || codexApiKeyBetaEnabled) + ); const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider) : null; const connectionLoading = selectedProviderLoading || connectionSaving; const connectionBusy = disabled || connectionLoading; const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving; - const showConnectionMethodCards = - connectionMethodCardOptions !== null && typeof configuredAuthMode !== 'undefined'; const connectionMethodCardsHint = selectedProvider ? getConnectionMethodCardsHint(selectedProvider) : null; @@ -531,6 +536,18 @@ export const ProviderRuntimeSettingsDialog = ({ Boolean(selectedProvider?.connection?.supportsOAuth && onRequestLogin) && configuredAuthMode !== 'api_key' && (!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth'); + let connectionStatusLabel: string | null = null; + if (selectedProvider) { + if (!hideConnectionMethodMeta && selectedProvider.authenticated) { + connectionStatusLabel = `Using ${formatProviderAuthMethodLabelForProvider( + selectedProvider.providerId, + selectedProvider.authMethod + )}`; + } else if (!hideConnectionMethodMeta) { + connectionStatusLabel = 'Not connected'; + } + } + const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime; const connectionProgressMessage = useMemo(() => { if (!connectionLoading || !selectedProvider) { @@ -783,7 +800,13 @@ export const ProviderRuntimeSettingsDialog = ({ value={provider.providerId} className="relative rounded-b-none data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:bg-[var(--color-surface)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:-bottom-px data-[state=active]:after:h-1 data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']" > - {provider.displayName} + + + {provider.displayName} + ))} @@ -791,7 +814,7 @@ export const ProviderRuntimeSettingsDialog = ({
- {selectedProvider ? ( + {showSelectedProviderSummary && selectedProvider ? (
- {managedRuntimeSummary ? ( + {managedRuntimeSummary && !hideConnectionMethodMeta ? ( {managedRuntimeSummary} @@ -987,7 +1010,7 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null}
- {configuredAuthMode ? ( + {configuredAuthMode && !hideConnectionMethodMeta ? ( ) : null} - - {selectedProvider.authenticated - ? `Using ${formatProviderAuthMethodLabelForProvider( - selectedProvider.providerId, - selectedProvider.authMethod - )}` - : 'Not connected'} - - {selectedProvider.connection?.apiKeyConfigured ? ( + {connectionStatusLabel ? ( + + {connectionStatusLabel} + + ) : null} + {selectedProvider.connection?.apiKeyConfigured && !showApiKeySection ? ( {selectedProvider.connection.apiKeySourceLabel} @@ -1039,8 +1059,7 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null}
- {apiKeyConfig && - (selectedProvider.providerId !== 'codex' || codexApiKeyBetaEnabled) ? ( + {showApiKeySection ? (
{ const elapsed = useElapsedTimer(startedAt, loading); - const [logsOpen, setLogsOpen] = useState(() => Boolean(cliLogsTail) && loading); + const [logsOpen, setLogsOpen] = useState( + () => defaultLogsOpen ?? (Boolean(cliLogsTail) && loading) + ); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const outputScrollRef = useRef(null); const isError = tone === 'error'; @@ -172,6 +177,13 @@ export const ProvisioningProgressBlock = ({ setLiveOutputOpen(defaultLiveOutputOpen); }, [defaultLiveOutputOpen]); + useEffect(() => { + if (defaultLogsOpen === undefined) { + return; + } + setLogsOpen(defaultLogsOpen); + }, [defaultLogsOpen]); + // On error with logs available, prioritize logs view over noisy live stream payload. useEffect(() => { if (isError && cliLogsTail) { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index db6646b1..0916ec7a 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1936,8 +1936,12 @@ export const TeamDetailView = ({ -
-
+
+
{headerColorSet ? (
0} - contentClassName="overflow-x-visible" action={ -
- = 0 ? lastActiveStepRef.current : 0} - startedAt={progress.startedAt} - pid={progress.pid} - cliLogsTail={progress.cliLogsTail} - assistantOutput={progress.assistantOutput} - defaultLiveOutputOpen - onCancel={null} - /> -
- ); - } - - if (isReady) { - const joiningPhrase = - remainingJoinCount === 1 - ? '1 teammate still joining' - : `${remainingJoinCount} teammates still joining`; - const readyDetailMessage = - failedSpawnCount > 0 - ? progress.message - : fallbackTeammateCount === 0 - ? 'Team provisioned - lead online' - : allTeammatesConfirmedAlive - ? `Team provisioned - all ${fallbackTeammateCount} teammates joined` - : hasMembersStillJoining - ? joiningPhrase - : 'Team provisioned - teammates are still joining'; - const readyDetailSeverity = - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined; - const readyMessage = - failedSpawnCount > 0 - ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start` - : fallbackTeammateCount === 0 - ? 'Team launched - lead online' - : allTeammatesConfirmedAlive - ? `Team launched - all ${fallbackTeammateCount} teammates joined` - : hasMembersStillJoining - ? 'Finishing launch' - : 'Finishing launch'; - const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX; - - return ( -
- 0 || hasMembersStillJoining ? readyDetailMessage : null} - messageSeverity={readyDetailSeverity} - surface="flat" - currentStepIndex={readyStepIndex} - startedAt={progress.startedAt} - pid={progress.pid} - cliLogsTail={progress.cliLogsTail} - assistantOutput={progress.assistantOutput} - defaultLiveOutputOpen={false} - onCancel={null} - successMessage={readyMessage} - successMessageSeverity={ - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success' - } - onDismiss={() => setDismissed(true)} - /> -
- ); - } - - if (isActive) { - return ( -
- = 0 ? progressStepIndex : -1} - loading - startedAt={progress.startedAt} - pid={progress.pid} - cliLogsTail={progress.cliLogsTail} - assistantOutput={progress.assistantOutput} - defaultLiveOutputOpen - onCancel={ - canCancel - ? () => { - void cancelProvisioning(progress.runId); - } - : null - } - /> -
- ); - } - - return null; + const panel = ( + + ); + return panel; }); diff --git a/src/renderer/components/team/TeamProvisioningPanel.tsx b/src/renderer/components/team/TeamProvisioningPanel.tsx new file mode 100644 index 00000000..1e9cddb0 --- /dev/null +++ b/src/renderer/components/team/TeamProvisioningPanel.tsx @@ -0,0 +1,111 @@ +import { memo, useEffect, useRef, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { cn } from '@renderer/lib/utils'; +import { X } from 'lucide-react'; + +import { ProvisioningProgressBlock } from './ProvisioningProgressBlock'; +import { useTeamProvisioningPresentation } from './useTeamProvisioningPresentation'; + +export interface TeamProvisioningPanelProps { + teamName: string; + surface?: 'raised' | 'flat'; + dismissible?: boolean; + className?: string; + defaultLogsOpen?: boolean; +} + +export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({ + teamName, + surface = 'flat', + dismissible = false, + className, + defaultLogsOpen, +}: TeamProvisioningPanelProps): React.JSX.Element | null { + const { presentation, cancelProvisioning, runInstanceKey } = + useTeamProvisioningPresentation(teamName); + const [dismissed, setDismissed] = useState(false); + const lastActiveStepRef = useRef(-1); + + useEffect(() => { + setDismissed(false); + }, [runInstanceKey]); + + if (!presentation || dismissed) { + return null; + } + + if (presentation.currentStepIndex >= 0 && !presentation.isFailed) { + lastActiveStepRef.current = presentation.currentStepIndex; + } + + const showRunningState = presentation.isActive || presentation.hasMembersStillJoining; + + const block = ( + = 0 + ? lastActiveStepRef.current + : 0 + : undefined + } + loading={showRunningState} + startedAt={presentation.progress.startedAt} + pid={presentation.progress.pid} + cliLogsTail={presentation.progress.cliLogsTail} + assistantOutput={presentation.progress.assistantOutput} + defaultLiveOutputOpen={presentation.defaultLiveOutputOpen} + defaultLogsOpen={defaultLogsOpen} + onCancel={ + presentation.canCancel && cancelProvisioning + ? () => { + void cancelProvisioning(presentation.progress.runId); + } + : null + } + successMessage={presentation.successMessage} + successMessageSeverity={presentation.successMessageSeverity} + onDismiss={ + dismissible && presentation.isReady + ? () => { + setDismissed(true); + } + : null + } + className={!presentation.isFailed ? className : undefined} + /> + ); + + if (!presentation.isFailed) { + return block; + } + + return ( +
+
+

+ {presentation.progress.message} +

+ {dismissible ? ( + + ) : null} +
+ {block} +
+ ); +}); diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 6021405b..1f2e7db0 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo } from 'react'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Label } from '@renderer/components/ui/label'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { @@ -26,126 +27,19 @@ import { } from '@renderer/utils/teamModelCatalog'; import { Info } from 'lucide-react'; -// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) --- - -/** Anthropic — official "A" lettermark (Simple Icons) */ -const AnthropicIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -/** OpenAI — official hexagonal knot logo (Simple Icons) */ -const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -const OpenCodeIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - // --- Provider definitions --- interface ProviderDef { - id: string; + id: 'anthropic' | 'codex' | 'gemini' | 'opencode'; label: string; - icon: React.FC<{ className?: string }>; comingSoon: boolean; } const PROVIDERS: ProviderDef[] = [ - { id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false }, - { id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false }, - // { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false }, - { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false }, - { id: 'opencode', label: 'OpenCode', icon: OpenCodeIcon, comingSoon: false }, + { id: 'anthropic', label: 'Anthropic', comingSoon: false }, + { id: 'codex', label: 'Codex', comingSoon: false }, + { id: 'gemini', label: 'Gemini', comingSoon: false }, + { id: 'opencode', label: 'OpenCode', comingSoon: false }, ]; const OPENCODE_UI_DISABLED_REASON = 'OpenCode in development'; @@ -343,7 +237,6 @@ export const TeamModelSelector: React.FC = ({
{PROVIDERS.map((provider) => { - const Icon = provider.icon; const providerDisabledReason = getProviderDisabledReason(provider.id); const providerSelectable = isProviderSelectable(provider.id); const statusBadge = getProviderStatusBadge(provider.id); @@ -365,7 +258,7 @@ export const TeamModelSelector: React.FC = ({ !providerSelectable && 'opacity-50' )} > - + = ({ id={opt.value === normalizedValue ? id : undefined} aria-disabled={!modelSelectable} className={cn( - 'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-center text-xs font-medium transition-colors', + 'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150', normalizedValue === opt.value - ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' - : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]', + ? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' + : modelSelectable + ? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm' + : 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]', !modelSelectable && 'cursor-not-allowed opacity-45', !modelDisabledReason && !activeProviderSelectable && 'pointer-events-none' )} - style={{ - borderColor: - normalizedValue === opt.value - ? 'var(--color-border-emphasis)' - : 'var(--color-border-subtle)', - }} onClick={() => { if (!modelSelectable) return; onValueChange(opt.value); diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 6c5e28ef..54d86a85 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; @@ -23,7 +23,11 @@ import { import { KanbanColumn } from './KanbanColumn'; import { KanbanFilterPopover } from './KanbanFilterPopover'; -import { KanbanGridLayout } from './KanbanGridLayout'; +import { + KanbanGridLayout, + SKELETON_HIDE_DELAY_MS, + SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH, +} from './KanbanGridLayout'; import { KanbanSortPopover } from './KanbanSortPopover'; import { KanbanTaskCard } from './KanbanTaskCard'; @@ -38,28 +42,28 @@ const COLUMN_ACCENTS: Record< { headerBg: string; bodyBg: string; icon: React.ReactNode } > = { todo: { - headerBg: 'rgba(59, 130, 246, 0.15)', - bodyBg: 'rgba(59, 130, 246, 0.015)', + headerBg: 'rgba(59, 130, 246, 0.22)', + bodyBg: 'rgba(59, 130, 246, 0.05)', icon: , }, in_progress: { - headerBg: 'rgba(234, 179, 8, 0.18)', - bodyBg: 'rgba(234, 179, 8, 0.018)', + headerBg: 'rgba(234, 179, 8, 0.24)', + bodyBg: 'rgba(234, 179, 8, 0.06)', icon: , }, done: { - headerBg: 'rgba(34, 197, 94, 0.15)', - bodyBg: 'rgba(34, 197, 94, 0.015)', + headerBg: 'rgba(34, 197, 94, 0.22)', + bodyBg: 'rgba(34, 197, 94, 0.05)', icon: , }, review: { - headerBg: 'rgba(139, 92, 246, 0.15)', - bodyBg: 'rgba(139, 92, 246, 0.015)', + headerBg: 'rgba(139, 92, 246, 0.22)', + bodyBg: 'rgba(139, 92, 246, 0.05)', icon: , }, approved: { - headerBg: 'rgba(34, 197, 94, 0.28)', - bodyBg: 'rgba(34, 197, 94, 0.033)', + headerBg: 'rgba(34, 197, 94, 0.34)', + bodyBg: 'rgba(34, 197, 94, 0.08)', icon: , }, }; @@ -102,6 +106,8 @@ interface KanbanBoardProps { type KanbanViewMode = 'grid' | 'columns'; +const SCROLLABLE_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay']); + const COLUMNS: { id: KanbanColumnId; title: string }[] = [ { id: 'todo', title: 'TODO' }, { id: 'in_progress', title: 'IN PROGRESS' }, @@ -131,6 +137,36 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId return null; } +function columnSupportsAddButton( + columnId: KanbanColumnId, + onAddTask?: (startImmediately: boolean) => void +): boolean { + return Boolean(onAddTask && (columnId === 'todo' || columnId === 'in_progress')); +} + +function estimateGridSkeletonCardHeight( + task: TeamTask, + columnId: KanbanColumnId, + kanbanState: KanbanState, + hasReviewers: boolean +): number { + let height = 122; + + if (task.subject.length > 54) height += 10; + if (task.subject.length > 92) height += 8; + if (task.needsClarification) height += 16; + if (task.reviewState === 'needsFix') height += 14; + if ((task.blockedBy?.length ?? 0) > 0) height += 18; + if ((task.blocks?.length ?? 0) > 0) height += 18; + + const effectiveReviewer = (kanbanState.tasks[task.id]?.reviewer ?? task.reviewer ?? '').trim(); + if (columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0) { + height += 14; + } + + return Math.min(Math.max(height, 116), 196); +} + /** Сортирует задачи колонки по сохранённому порядку; задачи без порядка — в конце. */ function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): TeamTask[] { if (!order?.length) { @@ -303,7 +339,12 @@ export const KanbanBoard = ({ deletedTaskCount, onOpenTrash, }: KanbanBoardProps): React.JSX.Element => { + const boardRef = useRef(null); + const scrollRestoreTimeoutsRef = useRef([]); const [viewMode, setViewMode] = useState('grid'); + const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); + const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); + const hasReviewers = kanbanState.reviewers.length > 0; const enableTaskSorting = viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; @@ -455,7 +496,7 @@ export const KanbanBoard = ({ teamName={teamName} columnId={columnId} kanbanTaskState={kanbanState.tasks[task.id]} - hasReviewers={kanbanState.reviewers.length > 0} + hasReviewers={hasReviewers} compact={compact} taskMap={taskMap} memberColorMap={memberColorMap} @@ -481,18 +522,90 @@ export const KanbanBoard = ({ () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), [filter.columns] ); + const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); const { widths: columnWidths, getHandleProps } = useResizableColumns({ storageKey: teamName, columnIds: resizableColumnIds, }); + const columnModeSearchWidth = + primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; + const toolbarLeftWidth = + viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; + + const clearScheduledScrollRestore = useCallback(() => { + for (const timeoutId of scrollRestoreTimeoutsRef.current) { + window.clearTimeout(timeoutId); + } + scrollRestoreTimeoutsRef.current = []; + }, []); + + useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); + + const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let current = startNode?.parentElement ?? null; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { + return current; + } + current = current.parentElement; + } + return null; + }, []); + + const scheduleScrollRestore = useCallback( + (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { + const container = findScrollContainer(boardRef.current); + if (!container) { + return; + } + + const savedScrollTop = container.scrollTop; + clearScheduledScrollRestore(); + + const restore = (): void => { + container.scrollTop = savedScrollTop; + }; + + const delays = + nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; + + scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); + }, + [clearScheduledScrollRestore, findScrollContainer] + ); + + const switchViewMode = useCallback( + (nextViewMode: KanbanViewMode) => { + const nextSkeletonDelayMs = + nextViewMode === 'grid' && viewMode === 'columns' + ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH + : SKELETON_HIDE_DELAY_MS; + + setGridSkeletonDelayMs(nextSkeletonDelayMs); + scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); + setViewMode(nextViewMode); + }, + [scheduleScrollRestore, viewMode] + ); const boardContent = ( - <> -
- {toolbarLeft != null &&
{toolbarLeft}
} -
+
+
+ {toolbarLeft != null && ( +
+ {toolbarLeft} +
+ )} +
setViewMode('grid')} + onClick={() => switchViewMode('grid')} aria-label="Grid view" > @@ -551,7 +664,7 @@ export const KanbanBoard = ({ ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' : 'text-[var(--color-text-muted)]' )} - onClick={() => setViewMode('columns')} + onClick={() => switchViewMode('columns')} aria-label="Columns view" > @@ -566,6 +679,9 @@ export const KanbanBoard = ({ {viewMode === 'grid' ? ( column.id)} + primaryColumnId={primaryVisibleColumnId} + onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} + skeletonDelayMs={gridSkeletonDelayMs} columns={visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; @@ -578,45 +694,53 @@ export const KanbanBoard = ({ headerBg: accent.headerBg, bodyBg: accent.bodyBg, content: renderCards(column.id, columnTasks), + showAddButton: columnSupportsAddButton(column.id, onAddTask), + skeletonCards: columnTasks.map((task) => ({ + key: task.id, + height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), + })), }; })} /> ) : ( -
- {visibleColumns.map((column, index) => { - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - const width = columnWidths.get(column.id) ?? 256; - const handleProps = getHandleProps(column.id); - return ( -
-
- - {renderCards(column.id, columnTasks, true)} - -
- {index < visibleColumns.length - 1 ? ( -
-
+
+
+ {visibleColumns.map((column, index) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); + return ( +
+
+ + {renderCards(column.id, columnTasks, true)} +
- ) : null} -
- ); - })} + {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null} +
+ ); + })} +
)} - +
); if (enableTaskSorting) { diff --git a/src/renderer/components/team/kanban/KanbanColumn.tsx b/src/renderer/components/team/kanban/KanbanColumn.tsx index 1422c8c8..48a572bd 100644 --- a/src/renderer/components/team/kanban/KanbanColumn.tsx +++ b/src/renderer/components/team/kanban/KanbanColumn.tsx @@ -32,11 +32,7 @@ export const KanbanColumn = memo(function KanbanColumn({ }: KanbanColumnProps): React.JSX.Element { return (
{count > 0 && ( @@ -48,11 +44,7 @@ export const KanbanColumn = memo(function KanbanColumn({ )}

diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx index ff595d54..cb4da436 100644 --- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -1,8 +1,9 @@ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; +import { cn } from '@renderer/lib/utils'; import { browserGridLayoutRepository } from '@renderer/services/layout-system/BrowserGridLayoutRepository'; import { KanbanColumn } from './KanbanColumn'; @@ -25,6 +26,7 @@ const DEFAULT_MIN_HEIGHT = 10; const DEFAULT_MIN_WIDTH = 3; const GRID_SCOPE_KEY = 'kanban-grid-layout:global:v2'; const SKELETON_HIDE_DELAY_MS = 500; +const SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH = 750; const RESIZE_HANDLES: ResizeHandleAxis[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne']; const WidthAwareGridLayout = WidthProvider(ReactGridLayout); @@ -36,22 +38,36 @@ export interface KanbanGridColumn { headerBg?: string; bodyBg?: string; content: React.ReactNode; + showAddButton?: boolean; + skeletonCards?: Array<{ + key: string; + height: number; + }>; } interface KanbanGridLayoutProps { columns: KanbanGridColumn[]; allColumnIds: KanbanColumnId[]; + primaryColumnId?: KanbanColumnId | null; + onPrimaryColumnWidthChange?: (width: number | null) => void; + skeletonDelayMs?: number; } interface LoadedKanbanGridLayoutProps { readonly columns: KanbanGridColumn[]; readonly visibleItems: PersistedGridLayoutItem[]; readonly onPersistLayout: (layout: Layout, options?: { persist?: boolean }) => void; + readonly primaryColumnId?: KanbanColumnId | null; + readonly onPrimaryColumnWidthChange?: (width: number | null) => void; + readonly className?: string; } interface LoadingKanbanGridLayoutProps { readonly columns: KanbanGridColumn[]; readonly visibleItems: PersistedGridLayoutItem[]; + readonly primaryColumnId?: KanbanColumnId | null; + readonly onPrimaryColumnWidthChange?: (width: number | null) => void; + readonly className?: string; } const ITEMS_PER_FIRST_ROW = 3; @@ -115,18 +131,86 @@ function renderResizeHandle(axis: ResizeHandleAxis, ref: Ref): Reac ); } +const KanbanTaskCardSkeleton = ({ height }: { height: number }): ReactElement => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + const LoadingKanbanGridLayout = ({ columns, visibleItems, + primaryColumnId, + onPrimaryColumnWidthChange, + className, }: Readonly): ReactElement => { const columnMap = new Map(columns.map((column) => [column.id, column])); const loadingItems = visibleItems.length > 0 ? visibleItems : buildDefaultItems(columns.length > 0 ? columns.map((column) => column.id) : ['todo']); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + + const updateWidth = (): void => { + setContainerWidth(element.clientWidth); + }; + + updateWidth(); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + setContainerWidth(entry ? entry.contentRect.width : element.clientWidth); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const primaryColumnWidth = useMemo(() => { + if (!primaryColumnId || containerWidth <= 0) { + return null; + } + + const layoutItem = loadingItems.find((item) => item.id === primaryColumnId); + if (!layoutItem) { + return null; + } + + const columnUnitWidth = (containerWidth - GRID_MARGIN[0] * (GRID_COLS - 1)) / GRID_COLS; + return Math.round(columnUnitWidth * layoutItem.w + GRID_MARGIN[0] * (layoutItem.w - 1)); + }, [containerWidth, loadingItems, primaryColumnId]); + + useEffect(() => { + onPrimaryColumnWidthChange?.(primaryColumnWidth); + }, [onPrimaryColumnWidthChange, primaryColumnWidth]); return ( -
+
{loadingItems.map((item) => { const column = columnMap.get(item.id as KanbanColumnId); + if (!column) { + return
; + } + const skeletonCards = column.skeletonCards ?? []; + const hasTasks = skeletonCards.length > 0; + const showAddButton = column.showAddButton === true; return ( -
-
-
-
-
-
-
-
-
-
-
-
+ + {hasTasks ? ( + <> + {skeletonCards.map((card) => ( + + ))} + {showAddButton ? ( +
+ Add task +
+ ) : null} + + ) : showAddButton ? ( +
+ Add task +
+ ) : ( +
+ No tasks +
+ )} +
+
); })}
@@ -171,11 +279,38 @@ const LoadedKanbanGridLayout = ({ columns, visibleItems, onPersistLayout, + primaryColumnId, + onPrimaryColumnWidthChange, + className, }: Readonly): ReactElement => { const columnMap = useMemo(() => new Map(columns.map((column) => [column.id, column])), [columns]); - const [renderLayout, setRenderLayout] = useState(() => - visibleItems.map(toReactGridLayoutItem) - ); + const visibleLayout = useMemo(() => visibleItems.map(toReactGridLayoutItem), [visibleItems]); + const [renderLayout, setRenderLayout] = useState(() => visibleLayout); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + setRenderLayout(visibleLayout); + }, [visibleLayout]); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + + const updateWidth = (): void => { + setContainerWidth(element.clientWidth); + }; + + updateWidth(); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + setContainerWidth(entry ? entry.contentRect.width : element.clientWidth); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, []); const applyReactGridLayout = useCallback( (layout: Layout, options?: { persist?: boolean }) => { @@ -187,11 +322,28 @@ const LoadedKanbanGridLayout = ({ [onPersistLayout] ); + const primaryColumnWidth = useMemo(() => { + if (!primaryColumnId || containerWidth <= 0) { + return null; + } + + const layoutItem = renderLayout.find((item) => item.i === primaryColumnId); + if (!layoutItem) { + return null; + } + + const columnUnitWidth = (containerWidth - GRID_MARGIN[0] * (GRID_COLS - 1)) / GRID_COLS; + return Math.round(columnUnitWidth * layoutItem.w + GRID_MARGIN[0] * (layoutItem.w - 1)); + }, [containerWidth, primaryColumnId, renderLayout]); + + useEffect(() => { + onPrimaryColumnWidthChange?.(primaryColumnWidth); + }, [onPrimaryColumnWidthChange, primaryColumnWidth]); + return ( -
+
{ const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ @@ -255,12 +410,12 @@ export const KanbanGridLayout = ({ const timeoutId = window.setTimeout(() => { setShowResolvedLayout(true); - }, SKELETON_HIDE_DELAY_MS); + }, skeletonDelayMs); return () => { window.clearTimeout(timeoutId); }; - }, [showResolvedLayout]); + }, [showResolvedLayout, skeletonDelayMs]); const applyReactGridLayout = useCallback( (layout: Layout, options?: { persist?: boolean }) => { @@ -270,20 +425,36 @@ export const KanbanGridLayout = ({ }, [applyVisibleItems] ); - - if (!showResolvedLayout && !isLoaded) { - return ; - } + const showSkeletonOverlay = !showResolvedLayout || !isLoaded; const gridKey = visibleItems.map((item) => item.id).join('|'); return ( - +
+ + {showSkeletonOverlay ? ( + + ) : null} +
); }; + +export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ diff --git a/src/renderer/components/team/kanban/KanbanSearchInput.tsx b/src/renderer/components/team/kanban/KanbanSearchInput.tsx index 31672bf8..bfc0586d 100644 --- a/src/renderer/components/team/kanban/KanbanSearchInput.tsx +++ b/src/renderer/components/team/kanban/KanbanSearchInput.tsx @@ -122,7 +122,7 @@ export const KanbanSearchInput = ({ ); return ( -
+
onChange(e.target.value)} onKeyDown={handleKeyDown} - className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" + className="h-8 w-full min-w-[140px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" /> {value && ( @@ -155,7 +155,7 @@ export const KanbanSearchInput = ({ {showDropdown && suggestions.length > 0 && (
diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 0c8b8855..87933fb6 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -5,6 +5,7 @@ import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBad import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { @@ -238,11 +239,13 @@ export const KanbanTaskCard = memo( onViewChanges, onDeleteTask, }: KanbanTaskCardProps): React.JSX.Element { + const { isLight } = useTheme(); const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const hasBlockedBy = blockedByIds.length > 0; const hasBlocks = blocksIds.length > 0; + const cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]'; const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); const canDisplay = useMemo( @@ -291,8 +294,8 @@ export const KanbanTaskCard = memo( data-task-id={task.id} className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${ hasBlockedBy - ? 'border-yellow-500/30 bg-[var(--color-surface-raised)]' - : 'border-[var(--color-border)] bg-[var(--color-surface-raised)]' + ? `border-yellow-500/30 ${cardSurfaceClass}` + : `border-[var(--color-border)] ${cardSurfaceClass}` }`} role="button" tabIndex={0} diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index b981ef1d..cdf9befd 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -1,9 +1,11 @@ import React, { useState } from 'react'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; import { getProviderScopedTeamModelLabel, + getTeamProviderLabel, TeamModelSelector, } from '@renderer/components/team/dialogs/TeamModelSelector'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -52,6 +54,7 @@ export const LeadModelRow = ({ const modelButtonLabel = model.trim() ? getProviderScopedTeamModelLabel(providerId, model.trim()) : 'Default'; + const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`; return (
setModelExpanded((prev) => !prev)} > {modelExpanded ? ( @@ -104,7 +108,8 @@ export const LeadModelRow = ({ ) : ( )} - Model: {modelButtonLabel} + + {modelButtonLabel}
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index de346518..3937e09f 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -5,12 +5,8 @@ import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, + buildMemberLaunchPresentation, displayMemberName, - getLaunchAwarePresenceLabel, - getMemberRuntimeAdvisoryLabel, - getMemberRuntimeAdvisoryTitle, - getSpawnAwareDotClass, - getSpawnCardClass, } from '@renderer/utils/memberHelpers'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; @@ -81,44 +77,23 @@ export const MemberCard = ({ // const leadContext = useStore((s) => // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined // ); - const dotClass = getSpawnAwareDotClass( - member, - spawnStatus, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning, - leadActivity - ); - const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel( - member.runtimeAdvisory, - member.providerId - ); - const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle( - member.runtimeAdvisory, - member.providerId - ); - const presenceLabel = getLaunchAwarePresenceLabel( + const launchPresentation = buildMemberLaunchPresentation({ member, spawnStatus, spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, - member.runtimeAdvisory, + runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, isTeamProvisioning, - leadActivity - ); - const spawnCardClass = getSpawnCardClass( - spawnStatus, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning - ); + leadActivity, + }); + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; + const presenceLabel = launchPresentation.presenceLabel; + const spawnCardClass = launchPresentation.cardClass; const colors = getTeamColorSet(memberColor); const { isLight } = useTheme(); const pending = taskCounts?.pending ?? 0; diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index d2b4f014..5dfaee99 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -6,10 +6,8 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, + buildMemberLaunchPresentation, displayMemberName, - getLaunchAwarePresenceLabel, - getMemberRuntimeAdvisoryTitle, - getSpawnAwareDotClass, } from '@renderer/utils/memberHelpers'; import { isLeadMember } from '@shared/utils/leadDetection'; import { Pencil } from 'lucide-react'; @@ -61,32 +59,21 @@ export const MemberDetailHeader = ({ const colors = getTeamColorSet(member.color ?? ''); const role = member.role || formatAgentRole(member.agentType); - const presenceLabel = getLaunchAwarePresenceLabel( + const launchPresentation = buildMemberLaunchPresentation({ member, spawnStatus, spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, - member.runtimeAdvisory, + runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, isTeamProvisioning, - leadActivity - ); - const dotClass = getSpawnAwareDotClass( - member, - spawnStatus, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning, - leadActivity - ); - const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle( - member.runtimeAdvisory, - member.providerId - ); + leadActivity, + }); + const presenceLabel = launchPresentation.presenceLabel; + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; const canEditRole = !isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole; diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index f9e30076..704e0076 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -1,8 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { getProviderScopedTeamModelLabel, + getTeamProviderLabel, TeamModelSelector, } from '@renderer/components/team/dialogs/TeamModelSelector'; import { RoleSelect } from '@renderer/components/team/RoleSelect'; @@ -169,6 +171,7 @@ export const MemberDraftRow = ({ const modelButtonLabel = forceInheritedModelSettings ? `${modelButtonLabelBase} (lead)` : modelButtonLabelBase; + const modelButtonAriaLabel = `${getTeamProviderLabel(effectiveProviderId)} provider, ${modelButtonLabel}`; const modelTooltipText = forceInheritedModelSettings ? 'Provider, model, and effort are inherited from the lead while sync is enabled.' : modelLockReason; @@ -246,6 +249,7 @@ export const MemberDraftRow = ({ variant="outline" size="sm" className="h-8 w-full justify-start gap-1 overflow-hidden text-left" + aria-label={modelButtonAriaLabel} disabled={lockProviderModel || isRemoved} onClick={() => setModelExpanded((prev) => !prev)} > @@ -254,7 +258,11 @@ export const MemberDraftRow = ({ ) : ( )} - Model: {modelButtonLabel} + + {modelButtonLabel} diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index 1834a571..c43e7d31 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -12,10 +12,8 @@ import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/te import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, + buildMemberLaunchPresentation, displayMemberName, - getLaunchAwarePresenceLabel, - getMemberRuntimeAdvisoryTitle, - getSpawnAwareDotClass, } from '@renderer/utils/memberHelpers'; import { isLeadMember } from '@shared/utils/leadDetection'; import { ExternalLink } from 'lucide-react'; @@ -104,32 +102,21 @@ export const MemberHoverCard = ({ progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining; const colors = getTeamColorSet(color ?? member.color ?? ''); const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - const presenceLabel = getLaunchAwarePresenceLabel( + const launchPresentation = buildMemberLaunchPresentation({ member, - spawnEntry?.status, - spawnEntry?.launchState, - spawnEntry?.livenessSource, - spawnEntry?.runtimeAlive, - member.runtimeAdvisory, + spawnStatus: spawnEntry?.status, + spawnLaunchState: spawnEntry?.launchState, + spawnLivenessSource: spawnEntry?.livenessSource, + spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, - false, - isLeadMember(member) ? leadActivity : undefined - ); - const dotClass = getSpawnAwareDotClass( - member, - spawnEntry?.status, - spawnEntry?.launchState, - spawnEntry?.runtimeAlive, - isLaunchSettling, - isTeamAlive, - false, - isLeadMember(member) ? leadActivity : undefined - ); - const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle( - member.runtimeAdvisory, - member.providerId - ); + isTeamProvisioning: false, + leadActivity: isLeadMember(member) ? leadActivity : undefined, + }); + const presenceLabel = launchPresentation.presenceLabel; + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; const currentTask: TeamTaskWithKanban | null = member.currentTaskId && tasks ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts new file mode 100644 index 00000000..f7b3570b --- /dev/null +++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { + selectTeamDataForName, + getCurrentProvisioningProgressForTeam, +} from '@renderer/store/slices/teamSlice'; +import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; +import { useShallow } from 'zustand/react/shallow'; + +import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; + +export function useTeamProvisioningPresentation(teamName: string): { + presentation: TeamProvisioningPresentation | null; + cancelProvisioning: ((runId: string) => Promise) | null; + runInstanceKey: string | null; +} { + const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses, memberSpawnSnapshot } = + useStore( + useShallow((s) => ({ + progress: getCurrentProvisioningProgressForTeam(s, teamName), + cancelProvisioning: s.cancelProvisioning, + teamMembers: selectTeamDataForName(s, teamName)?.members ?? [], + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + })) + ); + + const presentation = useMemo( + () => + buildTeamProvisioningPresentation({ + progress, + members: teamMembers, + memberSpawnStatuses, + memberSpawnSnapshot, + }), + [memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers] + ); + + return { + presentation, + cancelProvisioning, + runInstanceKey: progress ? `${teamName}:${progress.runId}:${progress.startedAt}` : null, + }; +} diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index b6f53552..50a56ee3 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -134,6 +134,70 @@ function hsla(hue: number, saturation: number, lightness: number, alpha = 1): st return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; } +function normalizeHexColor(color: string): string | null { + const trimmed = color.trim(); + const shortHexMatch = /^#([\da-f]{3,4})$/i.exec(trimmed); + if (shortHexMatch) { + const expanded = shortHexMatch[1] + .slice(0, 3) + .split('') + .map((char) => char + char) + .join(''); + return `#${expanded.toLowerCase()}`; + } + + const longHexMatch = /^#([\da-f]{6})([\da-f]{2})?$/i.exec(trimmed); + if (longHexMatch) { + return `#${longHexMatch[1].toLowerCase()}`; + } + + return null; +} + +function hexToRgb(color: string): { r: number; g: number; b: number } | null { + const normalized = normalizeHexColor(color); + if (!normalized) return null; + + return { + r: parseInt(normalized.slice(1, 3), 16), + g: parseInt(normalized.slice(3, 5), 16), + b: parseInt(normalized.slice(5, 7), 16), + }; +} + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const delta = max - min; + const lightness = (max + min) / 2; + + if (delta === 0) { + return { h: 0, s: 0, l: lightness * 100 }; + } + + let hue = 0; + if (max === rn) { + hue = ((gn - bn) / delta) % 6; + } else if (max === gn) { + hue = (bn - rn) / delta + 2; + } else { + hue = (rn - gn) / delta + 4; + } + + hue = Math.round(hue * 60); + if (hue < 0) hue += 360; + + const saturation = delta / (1 - Math.abs(2 * lightness - 1)); + return { + h: hue, + s: saturation * 100, + l: lightness * 100, + }; +} + function buildGeneratedMemberColorSet(colorName: string): TeamColorSet | null { const hue = MEMBER_COLOR_HUE[colorName]; if (hue === undefined) { @@ -162,6 +226,28 @@ function buildColorSetFromHue(hue: number): TeamColorSet { }; } +function buildColorSetFromHex(color: string): TeamColorSet { + const normalized = normalizeHexColor(color); + const rgb = normalized ? hexToRgb(normalized) : null; + if (!normalized || !rgb) { + return { + border: color, + badge: `${color}26`, + text: color, + }; + } + + const { h } = rgbToHsl(rgb.r, rgb.g, rgb.b); + const derived = buildColorSetFromHue(h); + + return { + ...derived, + border: normalized, + badge: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.15)`, + badgeLight: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.12)`, + }; +} + export function getTeamColorSet(colorName: string): TeamColorSet { if (!colorName) return DEFAULT_COLOR; @@ -174,11 +260,7 @@ export function getTeamColorSet(colorName: string): TeamColorSet { // If it's a hex color, generate a set from it if (colorName.startsWith('#')) { - return { - border: colorName, - badge: `${colorName}26`, - text: colorName, - }; + return buildColorSetFromHex(colorName); } // Hash unknown palette names (e.g. "coral", "sapphire") to one of the diff --git a/src/renderer/index.css b/src/renderer/index.css index 492b89d1..60a5262a 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -259,7 +259,7 @@ } .kanban-grid-layout { - min-height: 640px; + min-height: 0; overflow: visible; transition: none; } @@ -357,11 +357,11 @@ } .kanban-grid-resize-handle-n { - top: -3px; + top: 0; } .kanban-grid-resize-handle-s { - bottom: -3px; + bottom: 0; } .kanban-grid-resize-handle-e, @@ -374,11 +374,11 @@ } .kanban-grid-resize-handle-e { - right: -3px; + right: 0; } .kanban-grid-resize-handle-w { - left: -3px; + left: 0; } .kanban-grid-resize-handle-ne, @@ -390,26 +390,26 @@ } .kanban-grid-resize-handle-ne { - top: -4px; - right: -4px; + top: 0; + right: 0; cursor: nesw-resize; } .kanban-grid-resize-handle-nw { - top: -4px; - left: -4px; + top: 0; + left: 0; cursor: nwse-resize; } .kanban-grid-resize-handle-se { - right: -4px; - bottom: -4px; + right: 0; + bottom: 0; cursor: nwse-resize; } .kanban-grid-resize-handle-sw { - left: -4px; - bottom: -4px; + left: 0; + bottom: 0; cursor: nesw-resize; } diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 2d348feb..9066c898 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -222,7 +222,7 @@ export function getSpawnCardClass( ): string { const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; if (isTeamAlive === false && !isTeamProvisioning) { - return 'opacity-40'; + return ''; } if ( isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals) @@ -408,6 +408,127 @@ export function getLaunchAwarePresenceLabel( return advisoryLabel ?? basePresenceLabel; } +export type MemberLaunchVisualState = + | 'waiting' + | 'spawning' + | 'runtime_pending' + | 'settling' + | 'error' + | null; + +export interface MemberLaunchPresentation { + presenceLabel: string; + dotClass: string; + cardClass: string; + runtimeAdvisoryLabel: string | null; + runtimeAdvisoryTitle?: string; + launchVisualState: MemberLaunchVisualState; + spawnBadgeLabel: string | null; +} + +export function buildMemberLaunchPresentation({ + member, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, + runtimeAdvisory, + isLaunchSettling = false, + isTeamAlive, + isTeamProvisioning, + leadActivity, +}: { + member: ResolvedTeamMember; + spawnStatus: MemberSpawnStatus | undefined; + spawnLaunchState: MemberLaunchState | undefined; + spawnLivenessSource: MemberSpawnLivenessSource | undefined; + spawnRuntimeAlive: boolean | undefined; + runtimeAdvisory: MemberRuntimeAdvisory | undefined; + isLaunchSettling?: boolean; + isTeamAlive?: boolean; + isTeamProvisioning?: boolean; + leadActivity?: LeadActivityState; +}): MemberLaunchPresentation { + const presenceLabel = getLaunchAwarePresenceLabel( + member, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, + runtimeAdvisory, + isLaunchSettling, + isTeamAlive, + isTeamProvisioning, + leadActivity + ); + const dotClass = getSpawnAwareDotClass( + member, + spawnStatus, + spawnLaunchState, + spawnRuntimeAlive, + isLaunchSettling, + isTeamAlive, + isTeamProvisioning, + leadActivity + ); + const cardClass = getSpawnCardClass( + spawnStatus, + spawnLaunchState, + spawnRuntimeAlive, + isLaunchSettling, + isTeamAlive, + isTeamProvisioning + ); + const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId); + const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId); + const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; + + let launchVisualState: MemberLaunchVisualState = null; + if (isTeamAlive === false && !isTeamProvisioning) { + launchVisualState = null; + } else if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { + launchVisualState = 'error'; + } else if ( + spawnLaunchState === 'runtime_pending_bootstrap' && + spawnStatus === 'online' && + spawnRuntimeAlive === true + ) { + launchVisualState = 'runtime_pending'; + } else if ( + isLaunchStillStarting( + spawnStatus, + spawnLaunchState, + spawnRuntimeAlive, + keepLaunchSettlingVisuals + ) + ) { + launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting'; + } else if ( + isLaunchSettling && + spawnStatus === 'online' && + spawnLaunchState === 'confirmed_alive' + ) { + launchVisualState = 'settling'; + } + + const spawnBadgeLabel = + spawnStatus && spawnStatus !== 'online' + ? spawnStatus === 'waiting' || spawnStatus === 'spawning' + ? 'starting' + : spawnStatus + : null; + + return { + presenceLabel, + dotClass, + cardClass, + runtimeAdvisoryLabel, + runtimeAdvisoryTitle, + launchVisualState, + spawnBadgeLabel, + }; +} + export const TASK_STATUS_STYLES: Record = { pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' }, in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' }, diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts new file mode 100644 index 00000000..2253c177 --- /dev/null +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -0,0 +1,245 @@ +import { + DISPLAY_COMPLETE_STEP_INDEX, + getDisplayStepIndex, + getLaunchJoinMilestonesFromMembers, + getLaunchJoinState, +} from '@renderer/components/team/provisioningSteps'; + +import type { + MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + ResolvedTeamMember, + TeamProvisioningProgress, +} from '@shared/types'; + +type MemberSpawnStatusCollection = + | Record + | Map + | undefined; + +const ACTIVE_PROVISIONING_STATES = new Set([ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]); + +export interface TeamProvisioningPresentation { + progress: TeamProvisioningProgress; + isActive: boolean; + isReady: boolean; + isFailed: boolean; + canCancel: boolean; + currentStepIndex: number; + expectedTeammateCount: number; + heartbeatConfirmedCount: number; + processOnlyAliveCount: number; + pendingSpawnCount: number; + failedSpawnCount: number; + allTeammatesConfirmedAlive: boolean; + hasMembersStillJoining: boolean; + remainingJoinCount: number; + panelTitle: string; + panelMessage?: string | null; + panelMessageSeverity?: 'error' | 'warning' | 'info'; + panelTone?: 'default' | 'error'; + successMessage?: string | null; + successMessageSeverity?: 'success' | 'warning' | 'info'; + defaultLiveOutputOpen: boolean; + compactTitle: string; + compactDetail?: string | null; + compactTone: 'default' | 'warning' | 'error' | 'success'; +} + +export function isProvisioningProgressActive( + progress: Pick | null | undefined +): boolean { + return progress != null && ACTIVE_PROVISIONING_STATES.has(progress.state); +} + +export function buildTeamProvisioningPresentation({ + progress, + members, + memberSpawnStatuses, + memberSpawnSnapshot, +}: { + progress: TeamProvisioningProgress | null | undefined; + members: readonly ResolvedTeamMember[]; + memberSpawnStatuses?: MemberSpawnStatusCollection; + memberSpawnSnapshot?: Pick; +}): TeamProvisioningPresentation | null { + if (!progress) { + return null; + } + + if (progress.state === 'cancelled' || progress.state === 'disconnected') { + return null; + } + + const isReady = progress.state === 'ready'; + const isFailed = progress.state === 'failed'; + const isActive = isProvisioningProgressActive(progress); + const canCancel = + progress.state === 'spawning' || + progress.state === 'configuring' || + progress.state === 'assembling' || + progress.state === 'finalizing' || + progress.state === 'verifying'; + + const { + expectedTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + } = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses, + memberSpawnSnapshot, + }); + + const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = + getLaunchJoinState({ + expectedTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + }); + + const progressStepIndex = getDisplayStepIndex({ + progress, + expectedTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + }); + + if (isFailed) { + return { + progress, + isActive: false, + isReady: false, + isFailed: true, + canCancel: false, + currentStepIndex: progressStepIndex, + expectedTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + allTeammatesConfirmedAlive, + hasMembersStillJoining, + remainingJoinCount, + panelTitle: 'Launch failed', + panelMessage: progress.error ?? null, + panelTone: 'error', + defaultLiveOutputOpen: true, + compactTitle: 'Launch failed', + compactDetail: progress.message ?? null, + compactTone: 'error', + }; + } + + if (isReady) { + const joiningPhrase = + remainingJoinCount === 1 + ? '1 teammate still joining' + : `${remainingJoinCount} teammates still joining`; + const readyCompactDetail = + failedSpawnCount > 0 + ? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start` + : hasMembersStillJoining + ? joiningPhrase + : expectedTeammateCount === 0 + ? 'Lead online' + : `All ${expectedTeammateCount} teammates joined`; + const readyDetailMessage = + failedSpawnCount > 0 + ? progress.message + : expectedTeammateCount === 0 + ? 'Team provisioned - lead online' + : allTeammatesConfirmedAlive + ? `Team provisioned - all ${expectedTeammateCount} teammates joined` + : hasMembersStillJoining + ? joiningPhrase + : 'Team provisioned - teammates are still joining'; + const readyDetailSeverity = + failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined; + const readyMessage = + failedSpawnCount > 0 + ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start` + : expectedTeammateCount === 0 + ? 'Team launched - lead online' + : allTeammatesConfirmedAlive + ? `Team launched - all ${expectedTeammateCount} teammates joined` + : 'Finishing launch'; + + return { + progress, + isActive: false, + isReady: true, + isFailed: false, + canCancel: false, + currentStepIndex: hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX, + expectedTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + allTeammatesConfirmedAlive, + hasMembersStillJoining, + remainingJoinCount, + panelTitle: 'Launch details', + panelMessage: failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null, + panelMessageSeverity: readyDetailSeverity, + successMessage: readyMessage, + successMessageSeverity: + failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success', + defaultLiveOutputOpen: false, + compactTitle: + failedSpawnCount > 0 + ? 'Launch finished with errors' + : hasMembersStillJoining + ? 'Finishing launch' + : 'Team launched', + compactDetail: readyCompactDetail, + compactTone: + failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success', + }; + } + + if (isActive) { + return { + progress, + isActive: true, + isReady: false, + isFailed: false, + canCancel, + currentStepIndex: progressStepIndex >= 0 ? progressStepIndex : -1, + expectedTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + allTeammatesConfirmedAlive, + hasMembersStillJoining, + remainingJoinCount, + panelTitle: 'Launching team', + panelMessage: progress.message, + panelMessageSeverity: progress.messageSeverity, + defaultLiveOutputOpen: true, + compactTitle: 'Launching team', + compactDetail: + expectedTeammateCount > 0 && progressStepIndex >= 2 + ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` + : progress.message, + compactTone: 'default', + }; + } + + return null; +} diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index b4b305e5..7c68f7d0 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -140,6 +140,14 @@ vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', () => ({ getProviderRuntimeBackendSummary: () => null, })); +vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({ + ProviderBrandLogo: ({ providerId }: { providerId: string }) => + React.createElement('span', { + 'data-testid': `provider-logo-${providerId}`, + 'data-provider-id': providerId, + }), +})); + import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; function createCodexProvider( @@ -280,6 +288,10 @@ function findButtonByText(container: HTMLElement, text: string): HTMLButtonEleme return button; } +function countOccurrences(text: string, fragment: string): number { + return text.split(fragment).length - 1; +} + describe('ProviderRuntimeSettingsDialog Codex connection flows', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -424,6 +436,68 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => { }); }); + it('removes duplicate Codex summary and API key source text when connection cards are visible', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + storeState.appConfig.providerConnections.codex = { + apiKeyBetaEnabled: true, + authMode: 'oauth', + }; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + apiKeyBetaEnabled: true, + configuredAuthMode: 'oauth', + apiKeyConfigured: true, + apiKeySource: 'environment', + apiKeySourceLabel: 'Detected from OPENAI_API_KEY', + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Current runtime: Codex subscription'); + expect(host.textContent).not.toContain('Mode: Codex subscription'); + expect(host.textContent).not.toContain('Runtime: Default adapter'); + expect(countOccurrences(host.textContent ?? '', 'Using Codex subscription')).toBe(0); + expect(countOccurrences(host.textContent ?? '', 'Detected from OPENAI_API_KEY')).toBe(1); + expect(host.textContent).not.toContain('Connected'); + }); + + it('renders provider logos inside the provider tabs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [createAnthropicProvider(), createCodexProvider()], + initialProviderId: 'anthropic', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="provider-logo-anthropic"]')).not.toBeNull(); + expect(host.querySelector('[data-testid="provider-logo-codex"]')).not.toBeNull(); + }); + it('renders Anthropics connection methods as cards and hides the empty runtime section', async () => { const host = document.createElement('div'); document.body.appendChild(host); @@ -456,6 +530,35 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => { expect(host.textContent).toContain('API key'); expect(host.textContent).not.toContain('Authentication method'); expect(host.textContent).not.toContain('Runtime backend is not configurable'); + expect(host.textContent).not.toContain('Mode: Auto'); + expect(countOccurrences(host.textContent ?? '', 'Using Anthropic subscription')).toBe(1); + expect(countOccurrences(host.textContent ?? '', 'Detected from ANTHROPIC_API_KEY')).toBe(1); + }); + + it('keeps the API key icon container square', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [createAnthropicProvider()], + initialProviderId: 'anthropic', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + const icon = host.querySelector('[data-testid="provider-api-key-icon"]'); + expect(icon).not.toBeNull(); + expect(icon?.className).toContain('h-8'); + expect(icon?.className).toContain('w-8'); + expect(icon?.className).toContain('shrink-0'); }); it('switches Anthropic to API key mode from the connection cards', async () => { @@ -772,7 +875,8 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => { expect(storeState.updateConfig).toHaveBeenCalled(); expect(onRefreshProvider).toHaveBeenCalledWith('anthropic'); - expect(host.textContent).toContain('Mode: API key'); + expect(host.textContent).not.toContain('Mode: API key'); + expect(host.textContent).toContain('API keySelected'); expect(host.textContent).toContain('Connection updated, but failed to refresh provider status.'); expect(host.textContent).not.toContain('Failed to update connection'); }); @@ -812,7 +916,8 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('Mode: Anthropic subscription'); + expect(host.textContent).not.toContain('Mode: Anthropic subscription'); + expect(host.textContent).toContain('Anthropic subscriptionSelected'); expect(host.textContent).toContain('Connect Anthropic'); expect(host.textContent).toContain( 'Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.' @@ -859,7 +964,8 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => { authMode: 'api_key', }, }); - expect(host.textContent).toContain('Mode: API key'); + expect(host.textContent).not.toContain('Mode: API key'); + expect(host.textContent).toContain('Selected'); expect(host.textContent).toContain('Disable API key mode'); expect(host.textContent).toContain('Connection updated, but failed to refresh provider status.'); }); diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index 47313756..24e1ca81 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -14,6 +14,16 @@ const storeState = { { name: 'jack', agentType: 'developer' }, ] as Array>, }, + teamDataCacheByName: { + 'northstar-core': { + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', agentType: 'reviewer', runtimeAdvisory: undefined }, + { name: 'bob', agentType: 'developer' }, + { name: 'jack', agentType: 'developer' }, + ], + }, + } as Record> }>, memberSpawnStatusesByTeam: { 'northstar-core': {}, }, @@ -26,6 +36,9 @@ vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store/slices/teamSlice', () => ({ getCurrentProvisioningProgressForTeam: () => storeState.progress, + selectTeamDataForName: (_state: typeof storeState, teamName: string) => + storeState.teamDataCacheByName[teamName] ?? + (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null), })); vi.mock('zustand/react/shallow', () => ({ @@ -40,11 +53,13 @@ vi.mock('@renderer/components/ui/button', () => ({ vi.mock('@renderer/components/team/ProvisioningProgressBlock', () => ({ ProvisioningProgressBlock: ({ currentStepIndex, + loading, message, successMessage, successMessageSeverity, }: { currentStepIndex: number; + loading?: boolean; message?: string | null; successMessage?: string | null; successMessageSeverity?: string; @@ -54,6 +69,7 @@ vi.mock('@renderer/components/team/ProvisioningProgressBlock', () => ({ { 'data-testid': 'progress-block', 'data-current-step-index': String(currentStepIndex), + 'data-loading': loading ? 'true' : 'false', 'data-success-severity': successMessageSeverity ?? '', }, [successMessage, message].filter(Boolean).join(' ') @@ -87,6 +103,9 @@ describe('TeamProvisioningBanner launch-step alignment', () => { { name: 'bob', agentType: 'developer', runtimeAdvisory: undefined }, { name: 'jack', agentType: 'developer', runtimeAdvisory: undefined }, ]; + storeState.teamDataCacheByName['northstar-core'] = { + members: [...storeState.selectedTeamData.members], + }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: ['alice', 'bob', 'jack'], @@ -114,6 +133,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => { const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('2'); + expect(block?.getAttribute('data-loading')).toBe('true'); expect(block?.textContent).toContain('Finishing launch'); expect(block?.textContent).toContain('3 teammates still joining'); @@ -123,6 +143,46 @@ describe('TeamProvisioningBanner launch-step alignment', () => { }); }); + it('derives teammate counts from team cache even when the team is not selected', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.selectedTeamName = 'other-team'; + storeState.progress = { + runId: 'run-2', + teamName: 'northstar-core', + state: 'ready', + startedAt: '2026-04-08T16:00:00.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 1234, + cliLogsTail: '', + assistantOutput: '', + }; + storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record; + storeState.memberSpawnStatusesByTeam['northstar-core'] = { + alice: { status: 'waiting', launchState: 'starting' }, + bob: { status: 'waiting', launchState: 'starting' }, + jack: { status: 'waiting', launchState: 'starting' }, + } as Record; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); + await Promise.resolve(); + }); + + const block = host.querySelector('[data-testid="progress-block"]'); + expect(block?.getAttribute('data-current-step-index')).toBe('2'); + expect(block?.textContent).toContain('3 teammates still joining'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('keeps Starting active until a real provisioning pid exists', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.progress = { diff --git a/test/renderer/constants/teamColors.test.ts b/test/renderer/constants/teamColors.test.ts index 2ae91058..c4f7aa0f 100644 --- a/test/renderer/constants/teamColors.test.ts +++ b/test/renderer/constants/teamColors.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getThemedText, getSubagentTypeColorSet, getTeamColorSet, scaleColorAlpha, @@ -37,8 +38,11 @@ describe('getTeamColorSet', () => { it('generates a color set from hex strings', () => { const result = getTeamColorSet('#ff5500'); expect(result.border).toBe('#ff5500'); - expect(result.badge).toBe('#ff550026'); - expect(result.text).toBe('#ff5500'); + expect(result.badge).toBe('rgba(255, 85, 0, 0.15)'); + expect(result.badgeLight).toBe('rgba(255, 85, 0, 0.12)'); + expect(result.text).toBeTruthy(); + expect(result.textLight).toBeTruthy(); + expect(getThemedText(result, true)).not.toBe('#ff5500'); }); it('hashes unknown non-hex strings to a valid named color (not always blue)', () => { diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index dd50312b..56a8dfe9 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -1,4 +1,5 @@ import { + buildMemberLaunchPresentation, getLaunchAwarePresenceLabel, getSpawnAwareDotClass, getSpawnAwarePresenceLabel, @@ -123,9 +124,7 @@ describe('memberHelpers spawn-aware presence', () => { ) ).toContain('bg-red-400'); - expect(getSpawnCardClass('spawning', 'starting', false, false, false, false)).toBe( - 'opacity-40' - ); + expect(getSpawnCardClass('spawning', 'starting', false, false, false, false)).toBe(''); }); it('keeps runtime-pending teammates in starting state while launch is still settling', () => { @@ -177,6 +176,36 @@ describe('memberHelpers spawn-aware presence', () => { ).toBe('ready'); }); + it('derives runtime-pending and settling visual states from the same launch inputs', () => { + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'runtime_pending_bootstrap', + spawnLivenessSource: 'process', + spawnRuntimeAlive: true, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }).launchVisualState + ).toBe('runtime_pending'); + + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnLivenessSource: 'heartbeat', + spawnRuntimeAlive: true, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }).launchVisualState + ).toBe('settling'); + }); + it('renders unified retry advisory labels for provider retries', () => { expect( getMemberRuntimeAdvisoryLabel( diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts new file mode 100644 index 00000000..f24f8896 --- /dev/null +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; + +describe('buildTeamProvisioningPresentation', () => { + it('uses a lead-online compact detail for ready teams without teammates', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-1', + teamName: 'solo-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:05.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: {}, + memberSpawnSnapshot: undefined, + }); + + expect(presentation?.compactTitle).toBe('Team launched'); + expect(presentation?.compactDetail).toBe('Lead online'); + }); +});