improvement: alot small improvements and fixes

This commit is contained in:
777genius 2026-04-13 16:20:57 +03:00
parent 7d47cbaded
commit 5b328a0f8a
64 changed files with 4229 additions and 1298 deletions

View file

@ -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.

View file

@ -23,7 +23,7 @@
</p>
<p align="center">
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.</sub>
<sub>100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions/logins or API keys where supported. Not just coding agents.</sub>
</p>
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/be19cfcb-93ff-403a-9a1e-8ff1a803c55e" />
@ -48,7 +48,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42
## Installation
No prerequisites — Claude Code can be installed and configured directly from the app.
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
<table align="center">
<tr>
@ -105,8 +105,9 @@ No prerequisites — Claude Code can be installed and configured directly from t
## What is this
A new approach to task management with AI agent teams.
A local orchestration layer for AI agent teams across Claude and Codex.
- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions/logins or API keys where supported
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
- **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
@ -114,7 +115,7 @@ A new approach to task management with AI agent teams.
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
- **Built-in review workflow** — easily see how agents review each other's tasks to make sure everything went exactly as planned
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
- **Task-specific logs and messages** — clearly see all Claude logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment
- **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment
- **Live process section** — see which agents are running processes and open URLs directly in the browser
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
- **Flexible autonomy** — let agents run fully autonomous, or review and approve each action one by one (you'll get a notification) — configure the level of control that fits your security needs
@ -125,15 +126,15 @@ A new approach to task management with AI agent teams.
- **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context.
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
- **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses
- **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks
- **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
- **Zero-setup onboarding** — built-in Claude Code installation and authentication
- **Zero-setup onboarding** — built-in runtime detection and provider authentication
- **Built-in code editor** — edit project files with Git support without leaving the app
@ -147,7 +148,7 @@ A new approach to task management with AI agent teams.
- **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box
- **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost
- **Post-compact context recovery** — when the active runtime compacts its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost
- **Task context is preserved** — thanks to task descriptions, comments, and attachments, all essential information about each task remains available for ongoing work and future reference
@ -177,14 +178,14 @@ A new approach to task management with AI agent teams.
| **Flexible autonomy** | ✅ Granular settings, per-action approval, notifications | ❌ | ⚠️ Plan approval only | ✅ | ✅ |
| **Git worktree isolation** | ✅ Optional | ⚠️ Mandatory | ⚠️ Mandatory | ✅ | ✅ |
| **Multi-agent backend** | ✅ Claude, Codex, more coming soon | ✅ 6+ agents | ✅ 11 providers | ✅ Multi-model | — |
| **Price** | **Free** | Free / $30 user/mo | Free | $0$200/mo | Claude subscription |
| **Price** | **Free** | Free / $30 user/mo | Free | $0$200/mo | Provider subscription |
---
## Quick start
1. **Download** the app for your platform (see [Installation](#installation))
2. **Launch** — On first run, the setup wizard will install and authenticate Claude Code
2. **Launch** — On first run, the setup wizard will detect the runtime and guide provider authentication
3. **Create a team** — Pick a project, define roles, write a provisioning prompt
4. **Watch** — Agents spawn, create tasks, and work. You see it all on the kanban board
@ -194,27 +195,27 @@ A new approach to task management with AI agent teams.
## FAQ
<details>
<summary><strong>Do I need to install Claude Code before using this app?</strong></summary>
<summary><strong>Do I need to install a runtime before using this app?</strong></summary>
<br />
No. The app includes built-in installation and authentication — just launch and follow the setup wizard.
No. The app guides runtime detection/setup and provider authentication from the UI - just launch and follow the setup wizard.
</details>
<details>
<summary><strong>Does it read or upload my code?</strong></summary>
<br />
No. Everything runs locally. The app reads Claude Code's session logs from <code>~/.claude/</code> your source code is never sent anywhere.
No. Everything runs locally. The app reads local runtime/session data to power the UI - your source code is never sent anywhere.
</details>
<details>
<summary><strong>Can agents communicate with each other?</strong></summary>
<br />
Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated through Claude Code's team protocol.
Yes. Agents send direct messages, create shared tasks, and leave comments - all coordinated by the app's own orchestration layer.
</details>
<details>
<summary><strong>Is it free?</strong></summary>
<br />
Yes, completely free and open source. The app requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
Yes, completely free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex.
</details>
<details>
@ -229,12 +230,6 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment
Send a direct message to course-correct, or stop and restart from the process dashboard. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
</details>
<details>
<summary><strong>Can I use it just to view past sessions without running agents?</strong></summary>
<br />
Yes. The app works as a session viewer — browse, search, and analyze any Claude Code session history.
</details>
<details>
<summary><strong>Does it support multiple projects and teams?</strong></summary>
<br />

View file

@ -0,0 +1,484 @@
# Реальные конкуренты для Comparison в README
> Дата проверки: 2026-04-13
> Статус: внутренний comparison draft
> Цель: заменить в нашем внутреннем thinking `Vibe Kanban` и `Aperant` на реальные ориентиры - `Gastown`, `Claude Code Agent Teams`, `GoClaw`
## Что именно сравнивается
В этом документе "мы" = не только README-маркетинг, а текущий продуктовый стек:
- `claude_team` как frontend/workbench
- `agent_teams_orchestrator` как локальный runtime и task/review/log pipeline
Сравнение идёт по тем же строкам, что уже есть в `Comparison` секции README, но с реальными конкурентами.
## Как сравнивал
- `✅` - фича есть как явная продуктовая возможность
- `⚠️` - фича есть частично, экспериментально, только вручную, только через CLI/TUI, или без сильного UI/UX
- `❌` - фича не задокументирована как продуктовая возможность или явно отсутствует
Правило важное:
- если capability есть только "под капотом" или через обходной workflow, это не `✅`
- для нашей стороны я учитывал не только README, но и реальный frontend/code surface
- для конкурентов брал только первичные источники: official docs, official GitHub repo, official releases
## Короткий snapshot
| Система | Позиционирование | GitHub / живость | Самое важное |
|---|---|---|---|
| **Claude Agent Teams UI** | local-first coding-team cockpit | `577★`, push `2026-04-12` | сильнейший UI для task logs, review, editor, live processes |
| **Gastown** | process-model multi-agent workspace manager | `13,931★`, latest `v1.0.0` от `2026-04-03` | сильный orchestration runtime, mailboxes, handoffs, git worktrees |
| **Claude Code Agent Teams** | нативные team lead + teammates внутри Claude Code | `113,180★` у `anthropics/claude-code`, latest `v2.1.104` от `2026-04-13` | самый нативный Claude-first team runtime, но без нашего UI-слоя |
| **GoClaw** | self-hosted multi-tenant agent platform | `2,634★`, latest `v3.6.0` от `2026-04-13` | самый широкий platform surface: kanban, approvals, providers, channels |
## Feature matrix
| Feature | Claude Agent Teams UI | Gastown | Claude Code Agent Teams | GoClaw |
|---|---|---|---|---|
| **Cross-team communication** | ✅ Native cross-team messaging between teams | ⚠️ Cross-rig coordination exists, but not a polished team-to-team chat surface | ❌ No documented team-to-team concept | ❌ Team-local messaging, no documented cross-team agent comms |
| **Agent-to-agent messaging** | ✅ Native mailbox-style teammate and lead messaging | ✅ Built-in mailboxes, identities, handoffs | ✅ Shared mailbox + direct teammate messaging | ✅ Team messaging, member-to-member messages |
| **Linked tasks** | ✅ `#task-id` references + task dependencies | ⚠️ Beads, convoys and deps exist, but linking UX is more operational than productized | ⚠️ Shared task list + dependencies, but minimal linking UX | ✅ Task numbers, search, `blocked_by`, comments, audit trail |
| **Session analysis** | ✅ Task-specific logs, exact task log matching, deep session analysis, token tracking | ⚠️ Event stream, seance, OTLP logs, but no rich per-session analytics UI | ❌ No dedicated session analysis surface | ⚠️ Traces, audit events and task detail exist, but not our depth of per-task session analysis |
| **Task attachments** | ✅ Task and comment attachments in team workflow | ❌ Not documented as a task feature | ❌ Not documented | ✅ Task attachments + media auto-copy into team workspace |
| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ❌ |
| **Built-in code editor** | ✅ Built-in editor with Git support | ❌ | ❌ | ❌ Workspace browser exists, but not a real built-in code editor |
| **Full autonomy** | ✅ Agents can create, assign, review and progress tasks end-to-end with human override | ✅ Mayor + convoy + witness/deacon orchestration | ⚠️ Strong autonomy, but feature is still experimental | ✅ Strong autonomous team/task orchestration |
| **Task dependencies (blocked by)** | ✅ Explicit task dependencies and ordering | ✅ Beads deps / blocked work exist | ✅ Dependencies unblock automatically | ✅ `blocked_by`, blocked lifecycle, retry, stale handling |
| **Review workflow** | ✅ Agent peer review + human review UI | ⚠️ Merge/review workflows exist, but not as a productized task review cockpit | ⚠️ Plan approval + hooks, but no rich review board | ✅ `in_review`, approve/reject, reviewer-agent gates |
| **Zero setup** | ✅ Claude Code install + auth from the app | ❌ Many prerequisites and workspace bootstrap steps | ❌ Claude Code install + experimental flag required | ❌ Standard setup needs infra/provider config; Lite is easier but still not zero-setup |
| **Kanban board** | ✅ Real-time board | ❌ Dashboard overview, not Kanban | ❌ Shared task list, no Kanban board | ✅ Dashboard Kanban board |
| **Execution log viewer** | ✅ Task log panels, exact logs, stream, timeline | ⚠️ Feed/dashboard/event logs exist, but not a task log cockpit | ❌ No dedicated log viewer | ⚠️ Trace spans + task events/comments, but not strong raw per-task execution logs |
| **Live processes** | ✅ View, stop, inspect, open URLs | ⚠️ Agent/session monitoring exists, but not a developer process cockpit | ⚠️ Split panes let you watch sessions, but there is no processes dashboard | ❌ No comparable live-process UI surfaced like ours |
| **Per-task code review** | ✅ Per-task diff review with accept / reject / comment flow | ❌ | ❌ | ⚠️ Task approval exists, but not inline code diff review |
| **Flexible autonomy** | ✅ Granular approvals, notifications, autonomy controls | ✅ Strong human gates, escalation and intervention, mostly via CLI/TUI | ⚠️ Plan approval, hooks and permissions exist, but control plane is thin | ✅ Team settings, approval workflows, exec approval, task approval |
| **Git worktree isolation** | ✅ Optional per-agent worktree strategy | ✅ Core architectural primitive | ⚠️ Manual worktrees exist in Claude Code, but not as the native team model | ❌ Not a core team isolation model |
| **Multi-agent backend** | ⚠️ Claude is mature; Codex/Gemini plumbing exists in code but is still emerging as product surface | ✅ Claude Code, Codex, Gemini, Copilot and other runtimes | ❌ Claude-first only, models per teammate but no real multi-provider backend | ✅ 20+ providers including Claude CLI and ChatGPT OAuth |
| **Price** | Free OSS UI, but a Claude Code plan is still needed today | Free OSS, but you still pay for the underlying runtime plans/seats you use | Claude subscription | Free self-hosted OSS, but infra + provider/API/subscription costs remain |
## Самые важные выводы по matrix
### 1. Наше главное отличие - мы сильнее именно как coding workbench
По frontend/product surface у нас очень большой отрыв в четырёх местах:
- task-scoped logs
- hunk-level review
- built-in editor
- live processes
Это и есть та часть, которую README сейчас продаёт лучше всего, и она реально подтверждается кодом.
### 2. Gastown - реальный конкурент по orchestration, но не по UI
Gastown нельзя сравнивать с нами как с "kanban app". Это скорее process-model orchestrator:
- Mayor
- mailboxes
- handoffs
- witness/deacon monitoring
- convoys
- git worktree isolation
Но по UX для review, editor, per-task logs и task attachments он заметно слабее.
### 3. Claude Code Agent Teams - это ближайший конкурент именно по runtime-модели
Если смотреть на core idea:
- team lead
- teammates
- mailbox
- shared task list
- dependencies
- direct teammate messaging
то это самый близкий конкурент нашему runtime foundation. Но у них почти нет того UI-слоя, который у нас уже есть как продукт: kanban, per-task review, logs, attachments, processes, editor.
### 4. GoClaw - сильнейший platform competitor, но не лучший coding cockpit
GoClaw выигрывает у нас по:
- multi-provider breadth
- self-hosted platform maturity
- Kanban + approvals + task lifecycle
- OAuth/provider surface
- multi-tenant / channels / ops
Но проигрывает в IDE-like coding surfaces:
- hunk review
- per-task code review UX
- built-in editor
- live process control
- task-scoped raw logs as a strong developer cockpit
## Более глубокое чтение каждого конкурента
### Gastown
Что после более глубокого чтения видно особенно ясно:
- Это не просто "ещё один agent manager", а очень осознанная process-model система.
- Самые load-bearing примитивы у них - `Mayor`, `Witness`, `Deacon`, `Refinery`, `Convoy`, `Hooks`, `Beads`.
- У них сильный recovery story:
- persistent identity
- session handoff
- recovery mail protocol
- watchdog chain
- capacity-controlled dispatch
- Они явно думают не как "чат с LLM", а как "операционная система для swarm of coding agents".
Что тянет вниз:
- setup тяжёлый
- UI мониторинговый, не IDE-like
- per-task review/log/editor surfaces слабее
- часть силы живёт в терминах и process model, а не в простой product UX
### Claude Code Agent Teams
После более глубокого чтения видно:
- Это лучший нативный Claude-first фундамент для team lead + teammates.
- Shared task list, mailbox, direct teammate messaging и automatic dependency unblocking у них реальные.
- Есть plan approval loop и hooks-based quality gates.
- Но feature всё ещё experimental, и docs сами предупреждают про limits around resumption / coordination / shutdown.
Что это значит practically:
- как native runtime foundation это сильная штука
- как самостоятельный продукт для управления coding team это пока тонко
- без нашего UI-слоя там очень мало operator ergonomics
### GoClaw
После более глубокого чтения и docs, и кода:
- Это самый сбалансированный platform product в сравнении.
- У него сильный task engine, approvals, Kanban, workspace, provider layer, OAuth paths, traces, channels.
- Он лучше остальных выглядит как "готовая self-hosted platform", а не как набор сильных primitives.
Что тянет вниз:
- слабее IDE-like coding workbench
- infra/setup тяжелее нашего и Claude Code path
- non-commercial license очень сильно режет "open source leverage"
## Scorecards
Ниже уже не просто feature presence, а моя независимая оценка по 10-балльной шкале.
### 1. Чисто как orchestration engine
| Проект | Оценка | Почему |
|---|---:|---|
| **Gastown** | **9.2** | Самый сильный process-model orchestration для coding swarms: mailboxes, handoffs, convoys, witness/deacon, worktrees, merge queue, recovery |
| **GoClaw** | **8.9** | Самый зрелый durable workflow-state engine: board lifecycle, approvals, `blocked_by`, retry, stale, traces, provider-agnostic task system |
| **Claude Agent Teams UI + orchestrator** | **7.8** | Сильный local orchestrator и deterministic bootstrap, но task/state engine менее durable и менее mature |
| **Claude Code Agent Teams** | **7.7** | Хороший native runtime foundation, но lifecycle проще и feature всё ещё experimental |
### 2. Как coding cockpit / agentic IDE
| Проект | Оценка | Почему |
|---|---:|---|
| **Claude Agent Teams UI + orchestrator** | **9.4** | Лучший review, per-task logs, built-in editor, live processes, operator control |
| **GoClaw** | **7.2** | Хороший dashboard/workspace/product UI, но не настолько сильный coding workbench |
| **Claude Code Agent Teams** | **6.0** | Живые teammate sessions и direct messaging есть, но это всё ещё CLI-native control, не полноценный cockpit |
| **Gastown** | **5.7** | Сильный TUI/dashboard monitoring, но IDE-like surfaces почти нет |
### 3. Setup / onboarding
| Проект | Оценка | Почему |
|---|---:|---|
| **Claude Agent Teams UI + orchestrator** | **8.5** | Самый сильный zero-setup путь для Claude Code сценария |
| **Claude Code Agent Teams** | **7.2** | Относительно просто, если пользователь уже живёт в Claude Code, но нужен install + experimental flag |
| **GoClaw** | **6.2** | Lite заметно упрощает вход, но standard edition всё ещё тяжёлая |
| **Gastown** | **4.6** | Сильный toolchain tax: Go, Git, Dolt, beads, sqlite3, tmux, CLI runtimes, HQ bootstrap |
### 4. Provider flexibility / subscription paths
| Проект | Оценка | Почему |
|---|---:|---|
| **GoClaw** | **9.6** | 20+ providers, Claude CLI, ChatGPT OAuth, channels, pooling |
| **Gastown** | **8.8** | Очень хороший multi-runtime story: Claude Code, Codex, Gemini, Copilot и др. |
| **Claude Agent Teams UI + orchestrator** | **5.8** | Путь на multi-provider проступает в коде, но продукт всё ещё Claude-first |
| **Claude Code Agent Teams** | **4.2** | Claude-first by design |
### 5. Maturity / engineering confidence
Это уже composite signal по docs + releases + tests + architectural surface.
| Проект | Оценка | Что учитывал |
|---|---:|---|
| **Gastown** | **8.6** | `13.9k★`, `v1.0.0`, `492` `*test.go`, глубокая design-doc surface |
| **GoClaw** | **8.5** | `v3.6.0`, `351` `*test.go`, очень широкая docs surface, частая релизная активность |
| **Claude Code Agent Teams** | **7.5** | Огромный repo и релизный cadence сильные, но сама feature experimental |
| **Claude Agent Teams UI + orchestrator** | **6.9** | UI очень силён, но stars/coverage/maturity пока заметно слабее; у frontend сейчас `0` test files |
## Архитектурный deep-dive
### Coordination topology
| Проект | Топология | Сильная сторона | Ограничение |
|---|---|---|---|
| **Наш стек** | lead-centered orchestration + rich operator UI | человек очень хорошо держит команду руками | engine менее durable, много ценности живёт в operator loop |
| **Gastown** | process-model roles + externalized state via beads/hooks/mail | лучшая декомпозиция swarm как операционной системы | высокая когнитивная и инфраструктурная сложность |
| **Claude Code Agent Teams** | lead + teammates + peer messaging + shared task list | максимально нативная Claude-first team модель | experimental state machine и тонкий control plane |
| **GoClaw** | DB-backed task engine + team tools + orchestration modes | самый продуктово цельный runtime | менее выразительный IDE/workbench слой |
### Persistence model
| Проект | Persistence | Что это даёт | Комментарий |
|---|---|---|---|
| **Наш стек** | local app state + Claude logs + runtime stores + bootstrap state | сильный session/task visibility для local work | меньше durable workflow truth, чем у `Gastown`/`GoClaw` |
| **Gastown** | Git worktrees + Beads ledger + Dolt + mail protocol | crash-surviving coordination и сильная work history | сложнее понять и сопровождать |
| **Claude Code Agent Teams** | local files in `~/.claude/teams` and `~/.claude/tasks` | surprisingly practical lightweight persistence | проще и слабее, чем полноценный DB-backed engine |
| **GoClaw** | PostgreSQL in standard, SQLite in Lite | самый сильный durable task/store foundation | инфраструктурная цена выше |
### Observability model
| Проект | Лучшее в observability | Что слабее |
|---|---|---|
| **Наш стек** | лучший task-scoped log visibility и review-oriented debugging | слабее общий durable ops/trace plane |
| **Gastown** | сильные OTLP logs, activity feed, structured runtime events | слабее productized per-task log cockpit |
| **Claude Code Agent Teams** | visibility через sessions and split panes | почти нет отдельного observability product layer |
| **GoClaw** | traces, audit logs, approvals, task events, activity pages | raw per-task coding logs ощущаются слабее, чем у нас |
### Review / merge model
| Проект | Review model | Practical impact |
|---|---|---|
| **Наш стек** | per-task diff review + hunks + comments + approvals | лучший human review loop |
| **Gastown** | refinery / merge queue / PR-oriented review flow | сильный integration discipline, но слабый UI review cockpit |
| **Claude Code Agent Teams** | plan approval + hooks quality gates | хороший gate mechanism, но не review product |
| **GoClaw** | task `in_review` + approve/reject + reviewer agent gates | сильный workflow review, но слабее code-review UX |
## Weighted verdicts
Здесь самый важный момент: **"лучший проект" зависит от весов**.
Ниже три независимые линзы, каждая со своими весами.
### Lens A - Self-hosted multi-agent product
Веса:
- orchestration engine - 30%
- product/UI breadth - 25%
- setup/onboarding - 10%
- provider flexibility - 15%
- maturity/confidence - 15%
- license leverage - 5%
| Проект | Итоговый балл |
|---|---:|
| **GoClaw** | **8.1** |
| **Gastown** | **7.6** |
| **Наш стек** | **7.5** |
| **Claude Code Agent Teams** | **6.7** |
Вывод:
- если смотреть на проект как на **самый полноценный self-hosted продукт**, побеждает `GoClaw`
### Lens B - Coding team workstation / agentic IDE
Веса:
- coding cockpit - 35%
- review/log/debug surfaces - 20%
- local operator control - 15%
- setup friction - 10%
- orchestration engine - 10%
- maturity/confidence - 10%
| Проект | Итоговый балл |
|---|---:|
| **Наш стек** | **8.5** |
| **GoClaw** | **7.4** |
| **Claude Code Agent Teams** | **6.8** |
| **Gastown** | **6.6** |
Вывод:
- если смотреть на проект как на **лучший инструмент для реальной работы над кодом**, побеждаем мы
### Lens C - Open-source orchestration leverage
Веса:
- orchestration engine - 30%
- engineering confidence - 20%
- license leverage - 20%
- provider/runtime flexibility - 15%
- observability/recovery - 15%
| Проект | Итоговый балл |
|---|---:|
| **Gastown** | **8.6** |
| **GoClaw** | **7.9** |
| **Наш стек** | **7.0** |
| **Claude Code Agent Teams** | **5.9** |
Вывод:
- если смотреть на проект как на **наиболее ценный open-source фундамент для серьёзной orchestration-системы**, побеждает `Gastown`
## Независимый итоговый verdict
Если заставить меня выбрать **одного общего победителя как продукта**, то это сейчас:
### **1 место overall - GoClaw**
Почему:
- самый сбалансированный проект
- сильный engine
- сильный platform UI
- сильный provider story
- сильный self-hosted story
- сильный docs/release surface
Моя оценка:
- overall: **8.5 / 10**
- 🎯 8.8 🛡️ 8.6 🧠 5
### **2 место overall - Gastown**
Почему:
- как orchestrator для fleets of coding agents он очень силён
- архитектурно у него самый яркий process-model характер
- по recovery / work persistence / worktree isolation он реально впечатляет
Почему не первое место:
- тяжёлый вход
- слабее product UX
- слабее review/log/editor cockpit
Моя оценка:
- overall: **8.2 / 10**
- 🎯 8.6 🛡️ 8.8 🧠 7
### **3 место overall - наш стек**
Почему:
- лучший coding cockpit
- лучший human-in-the-loop control plane
- лучший UI для лида coding-команды
Почему не выше:
- orchestration engine менее зрелый, чем у `Gastown` и `GoClaw`
- maturity signals слабее
- frontend test surface сейчас объективно плохой
- multi-provider story пока не настолько продуктово зрелая
Моя оценка:
- overall: **7.9 / 10**
- 🎯 8.4 🛡️ 7.4 🧠 5
### **4 место overall - Claude Code Agent Teams**
Почему:
- это сильная native runtime функция, но ещё не лучший самостоятельный продукт
- слишком много experimental caveats
- почти нет product/UI advantage по сравнению с остальными
Моя оценка:
- overall: **7.1 / 10**
- 🎯 8.2 🛡️ 6.8 🧠 3
## Кто лучший по конкретным сценариям
| Сценарий | Победитель | Почему |
|---|---|---|
| **Лучший overall product** | **GoClaw** | Самый ровный баланс engine + UI + providers + self-hosted maturity |
| **Лучший pure orchestrator для coding swarms** | **Gastown** | Самый сильный process-model orchestration core |
| **Лучший native Claude runtime foundation** | **Claude Code Agent Teams** | Самая нативная реализация team lead + teammates внутри Claude Code |
| **Лучший coding cockpit / agentic IDE** | **наш стек** | Лучшие review, logs, editor, processes, human control |
## Что особенно важно помнить для README
Если мы когда-нибудь будем переписывать публичный `Comparison` в README, то главный честный framing такой:
- против `Gastown` надо продавать `UI/workbench`, а не пытаться спорить, что мы сильнее как process-model orchestrator
- против `Claude Code Agent Teams` надо продавать "native runtime + настоящий product UI сверху"
- против `GoClaw` надо продавать "agentic IDE / coding cockpit", а не "более широкий platform product"
## Где у нас реально подтверждён сильный frontend
Это ключевые локальные опоры, на которые можно смело ссылаться внутри команды:
- review cockpit - [ChangeReviewDialog](../../src/renderer/components/team/review/ChangeReviewDialog.tsx)
- task detail + attachments + comments - [TaskDetailDialog](../../src/renderer/components/team/dialogs/TaskDetailDialog.tsx)
- task logs - [TaskLogsPanel](../../src/renderer/components/team/taskLogs/TaskLogsPanel.tsx)
- built-in editor - [ProjectEditorOverlay](../../src/renderer/components/team/editor/ProjectEditorOverlay.tsx)
- live processes - [ProcessesSection](../../src/renderer/components/team/ProcessesSection.tsx)
- tool approvals - [ToolApprovalSheet](../../src/renderer/components/team/ToolApprovalSheet.tsx)
Есть и важная продуктовая нюансировка:
- cross-team communication у нас реально есть
- task attachments у нас реально есть
- multimodel/provider surface у нас уже проступает в коде
- но публично и продуктово мы всё ещё остаёмся в первую очередь Claude-first
## Места, где надо быть особенно честными про нас
- `Multi-agent backend` у нас пока не так зрел, как это можно прочитать из одной строки README. В коде есть мосты и статусы для `Anthropic`, `Codex`, `Gemini`, но продуктово основной путь всё ещё Claude-first.
- `Zero setup` у нас честно сильный именно для Claude Code path.
- `Cross-team communication` у нас сильнее, чем у этих конкурентов, но cross-team attachments не выглядят как полностью общий happy path.
## Источники
### Наша сторона
- README: [README.md](../../README.md)
- review UI: [ChangeReviewDialog](../../src/renderer/components/team/review/ChangeReviewDialog.tsx)
- logs UI: [TaskLogsPanel](../../src/renderer/components/team/taskLogs/TaskLogsPanel.tsx)
- editor UI: [ProjectEditorOverlay](../../src/renderer/components/team/editor/ProjectEditorOverlay.tsx)
- processes UI: [ProcessesSection](../../src/renderer/components/team/ProcessesSection.tsx)
- task workflow UI: [TaskDetailDialog](../../src/renderer/components/team/dialogs/TaskDetailDialog.tsx)
- approvals UI: [ToolApprovalSheet](../../src/renderer/components/team/ToolApprovalSheet.tsx)
### Gastown
- Official repo: <https://github.com/gastownhall/gastown>
- README: <https://github.com/gastownhall/gastown/blob/main/README.md>
- Latest release: <https://github.com/gastownhall/gastown/releases/latest>
### Claude Code Agent Teams
- Agent Teams docs: <https://code.claude.com/docs/en/agent-teams>
- CLI auth docs: <https://code.claude.com/docs/en/cli-usage>
- Claude Code repo: <https://github.com/anthropics/claude-code>
- Latest release: <https://github.com/anthropics/claude-code/releases/latest>
### GoClaw
- Official repo: <https://github.com/nextlevelbuilder/goclaw>
- README: <https://github.com/nextlevelbuilder/goclaw/blob/dev/README.md>
- Full docs export: <https://docs.goclaw.sh/llms-full.txt>
- Latest release: <https://github.com/nextlevelbuilder/goclaw/releases/latest>
## Bottom line
Если брать реальные продукты, то текущая внутренняя картина такая:
- **Gastown** - конкурент по orchestration runtime
- **Claude Code Agent Teams** - конкурент по базовой runtime-модели team lead + teammates
- **GoClaw** - конкурент по platform orchestration product
- **мы** - сильнее как agentic IDE / coding-team cockpit
То есть наш главный moat сейчас не "самый широкий agent platform".
Он в том, что мы уже собрали более сильное рабочее место для лида coding-команды, чем у этих трёх систем.

View file

@ -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,

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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;

View file

@ -0,0 +1,250 @@
import { COLORS } from '../constants/colors';
import { MIN_VISIBLE_OPACITY, TASK_PILL } from '../constants/canvas-constants';
import {
ACTIVITY_LANE,
getActivityAnchorTarget,
getActivityLaneBounds,
getVisibleActivityWindow,
isActivityOwner,
resolveActivityLaneSide,
} from '../layout/activityLane';
import type { GraphActivityItem, GraphNode } from '../ports/types';
import { truncateText } from './draw-misc';
import { drawPillShell } from './draw-pill-shell';
import { hexWithAlpha, measureTextCached } from './render-cache';
export function drawActivityLanes(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
selectedNodeId: string | null,
hoveredNodeId: string | null,
focusNodeIds?: ReadonlySet<string> | null,
zoom = 1
): void {
if (zoom < 0.16) {
return;
}
const leadNode = nodes.find((node) => node.kind === 'lead' && node.x != null);
const leadX = leadNode?.x ?? null;
for (const node of nodes) {
if (!isActivityOwner(node) || node.x == null || node.y == null) {
continue;
}
const opacity = getLaneOpacity(node, focusNodeIds);
if (opacity < MIN_VISIBLE_OPACITY) {
continue;
}
const window = getVisibleActivityWindow(node.activityItems);
const overflowCount = node.activityOverflowCount ?? window.overflowCount;
if (window.items.length === 0 && overflowCount <= 0) {
continue;
}
const anchor = getActivityAnchorTarget({
nodeX: node.x,
nodeY: node.y,
nodeKind: node.kind,
leadX,
});
const bounds = getActivityLaneBounds(anchor.x, anchor.y);
const left = bounds.left;
const top = bounds.top;
const side = resolveActivityLaneSide({
nodeKind: node.kind,
nodeX: node.x,
leadX,
});
ctx.save();
ctx.globalAlpha = opacity;
drawLaneHeader(ctx, side, left, top);
const itemsTop = top + ACTIVITY_LANE.headerHeight;
for (let index = 0; index < window.items.length; index += 1) {
const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
drawActivityPill(ctx, {
item: window.items[index],
x: left + ACTIVITY_LANE.width / 2,
y: itemTop + ACTIVITY_LANE.itemHeight / 2,
isOwnerSelected: node.id === selectedNodeId,
isOwnerHovered: node.id === hoveredNodeId,
});
}
if (overflowCount > 0) {
const overflowTop = itemsTop + window.items.length * ACTIVITY_LANE.rowHeight;
drawOverflowPill(
ctx,
left + ACTIVITY_LANE.width / 2,
overflowTop + ACTIVITY_LANE.overflowHeight / 2,
overflowCount
);
}
ctx.restore();
}
}
function getLaneOpacity(node: GraphNode, focusNodeIds?: ReadonlySet<string> | null): number {
if (focusNodeIds && !focusNodeIds.has(node.id)) {
return 0.25;
}
return 1;
}
function drawLaneHeader(
ctx: CanvasRenderingContext2D,
side: 'left' | 'right',
left: number,
top: number
): void {
ctx.font = 'bold 8px monospace';
ctx.textAlign = side === 'left' ? 'right' : 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.46);
ctx.fillText('Activity', side === 'left' ? left + ACTIVITY_LANE.width : left, top);
}
function drawActivityPill(
ctx: CanvasRenderingContext2D,
params: {
item: GraphActivityItem;
x: number;
y: number;
isOwnerSelected: boolean;
isOwnerHovered: boolean;
}
): void {
const { item, x, y, isOwnerSelected, isOwnerHovered } = params;
const accent = getActivityAccentColor(item.kind);
const badgeText = getActivityBadgeText(item.kind);
ctx.save();
ctx.translate(x, y);
drawPillShell(ctx, {
width: ACTIVITY_LANE.width,
height: ACTIVITY_LANE.itemHeight,
radius: TASK_PILL.borderRadius,
fillStyle: isOwnerSelected
? COLORS.cardBgSelected
: isOwnerHovered
? 'rgba(15, 20, 40, 0.72)'
: COLORS.cardBg,
borderColor: hexWithAlpha(accent, isOwnerSelected ? 0.76 : 0.46),
borderWidth: isOwnerSelected ? 1.7 : 1,
shadowColor: hexWithAlpha(accent, 0.22),
shadowBlur: isOwnerSelected ? 10 : 6,
accentColor: hexWithAlpha(accent, 0.72),
});
ctx.font = 'bold 7px monospace';
const badgeWidth = Math.max(30, Math.ceil(measureTextCached(ctx, ctx.font, badgeText) + 14));
ctx.fillStyle = hexWithAlpha(accent, 0.16);
ctx.beginPath();
ctx.roundRect(
ACTIVITY_LANE.width / 2 - badgeWidth - 8,
-ACTIVITY_LANE.itemHeight / 2 + 4,
badgeWidth,
11,
5
);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(accent, 0.96);
ctx.fillText(
badgeText,
ACTIVITY_LANE.width / 2 - badgeWidth / 2 - 8,
-ACTIVITY_LANE.itemHeight / 2 + 9.5
);
ctx.textAlign = 'left';
ctx.font = 'bold 8px monospace';
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary;
const title = truncateText(ctx, item.title, ACTIVITY_LANE.width - badgeWidth - 26, ctx.font);
ctx.fillText(title, -ACTIVITY_LANE.width / 2 + 10, -3.5);
const preview = item.preview?.trim();
if (preview) {
ctx.font = '7px monospace';
ctx.fillStyle = COLORS.textDim;
const previewText = truncateText(ctx, preview, ACTIVITY_LANE.width - 18, ctx.font);
ctx.fillText(previewText, -ACTIVITY_LANE.width / 2 + 10, 6.5);
} else if (item.authorLabel) {
ctx.font = '7px monospace';
ctx.fillStyle = COLORS.textDim;
const authorText = truncateText(ctx, item.authorLabel, ACTIVITY_LANE.width - 18, ctx.font);
ctx.fillText(authorText, -ACTIVITY_LANE.width / 2 + 10, 6.5);
}
ctx.restore();
}
function drawOverflowPill(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
overflowCount: number
): void {
const width = TASK_PILL.width;
const height = ACTIVITY_LANE.overflowHeight;
ctx.save();
ctx.translate(x, y);
drawPillShell(ctx, {
width,
height,
radius: 6,
fillStyle: 'rgba(10, 15, 30, 0.42)',
borderColor: hexWithAlpha(COLORS.holoBright, 0.22),
borderWidth: 1,
});
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.66);
ctx.fillText(`+${overflowCount} more`, 0, 0.5);
ctx.restore();
}
function getActivityAccentColor(kind: GraphActivityItem['kind']): string {
switch (kind) {
case 'task_comment':
return COLORS.particleTaskComment;
case 'task_assign':
return COLORS.particleTaskAssign;
case 'review_request':
return COLORS.particleReviewRequest;
case 'review_response':
return COLORS.particleReviewResponse;
case 'inbox_message':
default:
return COLORS.particleInboxMessage;
}
}
function getActivityBadgeText(kind: GraphActivityItem['kind']): string {
switch (kind) {
case 'task_comment':
return 'COMMENT';
case 'task_assign':
return 'TASK';
case 'review_request':
return 'REV';
case 'review_response':
return 'DONE';
case 'inbox_message':
default:
return 'MSG';
}
}

View file

@ -25,8 +25,10 @@ export function drawAgents(
time: number,
selectedId: string | null,
hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | null
focusNodeIds?: ReadonlySet<string> | null,
zoom = 1
): void {
const simplify = zoom < 0.19;
for (const node of nodes) {
if (node.kind !== 'member' && node.kind !== 'lead') continue;
const opacity = getNodeOpacity(node) * 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<string> | null
): number {
function getFocusOpacity(nodeId: string, focusNodeIds?: ReadonlySet<string> | 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)';

View file

@ -9,7 +9,10 @@ import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
// ─── Edge Type → Color/Width Mapping ────────────────────────────────────────
const EDGE_STYLES: Record<GraphEdgeType, { color: string; startW: number; endW: number; dash?: number[] }> = {
const EDGE_STYLES: Record<
GraphEdgeType,
{ color: string; startW: number; endW: number; dash?: number[] }
> = {
'parent-child': { color: COLORS.edgeParentChild, ...BEAM.parentChild },
ownership: { color: COLORS.edgeOwnership, ...BEAM.ownership },
blocking: { color: COLORS.edgeBlocking, ...BEAM.blocking, dash: [8, 4] },
@ -30,7 +33,7 @@ export function computeControlPoints(
x1: number,
y1: number,
x2: number,
y2: number,
y2: number
): ControlPoints {
const dx = x2 - x1;
const dy = y2 - y1;
@ -53,7 +56,7 @@ export function bezierPoint(
cp: ControlPoints,
x2: number,
y2: number,
t: number,
t: number
): { x: number; y: number } {
const u = 1 - t;
const uu = u * u;
@ -77,7 +80,9 @@ export function drawEdges(
focusEdgeIds?: ReadonlySet<string> | null,
hoveredEdgeId?: string | null,
selectedEdgeId?: string | null,
zoom = 1
): void {
const simplify = zoom < 0.18;
for (const edge of edges) {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
@ -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();

View file

@ -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),
};
}
}

View file

@ -0,0 +1,78 @@
import { hexWithAlpha } from './render-cache';
export interface PillShellOptions {
width: number;
height: number;
radius: number;
fillStyle: string;
borderColor: string;
borderWidth: number;
shadowColor?: string;
shadowBlur?: number;
accentColor?: string;
accentWidth?: number;
}
export function drawPillShell(
ctx: CanvasRenderingContext2D,
options: PillShellOptions
): void {
const {
width,
height,
radius,
fillStyle,
borderColor,
borderWidth,
shadowColor,
shadowBlur = 0,
accentColor,
accentWidth = 4,
} = options;
const halfWidth = width / 2;
const halfHeight = height / 2;
if (shadowColor && shadowBlur > 0) {
ctx.shadowColor = shadowColor;
ctx.shadowBlur = shadowBlur;
}
ctx.beginPath();
ctx.roundRect(-halfWidth, -halfHeight, width, height, radius);
ctx.fillStyle = fillStyle;
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderWidth;
ctx.stroke();
if (accentColor) {
ctx.fillStyle = accentColor;
ctx.beginPath();
ctx.roundRect(-halfWidth, -halfHeight, accentWidth, height, [radius, 0, 0, radius]);
ctx.fill();
}
}
export function drawPillStackLayer(
ctx: CanvasRenderingContext2D,
options: {
width: number;
height: number;
radius: number;
offsetX: number;
offsetY: number;
fillColor: string;
fillAlpha: number;
}
): void {
const { width, height, radius, offsetX, offsetY, fillColor, fillAlpha } = options;
const halfWidth = width / 2;
const halfHeight = height / 2;
ctx.beginPath();
ctx.roundRect(-halfWidth + offsetX, -halfHeight + offsetY, width, height, radius);
ctx.fillStyle = hexWithAlpha(fillColor, fillAlpha);
ctx.fill();
}

View file

@ -18,7 +18,9 @@ export function drawProcesses(
selectedId: string | null,
hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | null,
zoom = 1
): void {
const simplify = zoom < 0.2;
for (const node of nodes) {
if (node.kind !== 'process') continue;
@ -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();
}

View file

@ -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<string> | null
focusNodeIds?: ReadonlySet<string> | 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<string> | null
): number {
function getTaskOpacity(node: GraphNode, focusNodeIds?: ReadonlySet<string> | null): number {
if (node.taskStatus === 'deleted') return 0;
if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25;
return 1;
@ -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__') {

View file

@ -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 */

View file

@ -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;

View file

@ -23,12 +23,28 @@ import { getNodeStrategy } from '../strategies';
import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects';
import { getStateColor } from '../constants/colors';
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
import {
LAUNCH_ANCHOR_LAYOUT,
getActivityAnchorId,
getHandoffAnchorBounds,
getLaunchAnchorBounds,
getLaunchAnchorId,
getLaunchAnchorTarget,
isActivityAnchorId,
isLaunchAnchorId,
type WorldBounds,
} from '../layout/launchAnchor';
import { ACTIVITY_ANCHOR_LAYOUT, getActivityAnchorTarget } from '../layout/activityLane';
// ─── Force Node/Link types (properly typed, no loose `string`) ──────────────
type InternalNodeKind = GraphNodeKind | 'launch-anchor' | 'activity-anchor';
interface ForceNode extends SimulationNodeDatum {
id: string;
kind: GraphNodeKind;
kind: InternalNodeKind;
anchorForLeadId?: string;
anchorForNodeId?: string;
}
interface ForceLink extends SimulationLinkDatum<ForceNode> {
@ -51,6 +67,10 @@ export interface UseGraphSimulationResult {
updateData: (nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => void;
/** Tick one simulation frame — called from parent's RAF loop */
tick: (dt: number) => void;
setNodePosition: (nodeId: string, x: number, y: number) => void;
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null;
getExtraWorldBounds: () => WorldBounds[];
}
// ─── Deterministic hash for stable initial positions ─────────────────────────
@ -64,6 +84,70 @@ function deterministicPosition(id: string, seed: number): number {
return ((hash & 0x7fffffff) % 1000) / 1000 - 0.5;
}
function syncLaunchAnchors(forceNodes: ForceNode[]): void {
const forceNodeMap = new Map<string, ForceNode>();
for (const node of forceNodes) {
forceNodeMap.set(node.id, node);
}
const leadNode = forceNodes.find((node) => node.kind === 'lead');
const leadX = leadNode?.x ?? leadNode?.fx ?? null;
for (const node of forceNodes) {
let target: { x: number; y: number } | null = null;
if (node.kind === 'launch-anchor' && node.anchorForLeadId) {
const leadNode = forceNodeMap.get(node.anchorForLeadId);
if (!leadNode) continue;
target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0);
} else if (node.kind === 'activity-anchor' && node.anchorForNodeId) {
const ownerNode = forceNodeMap.get(node.anchorForNodeId);
if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue;
target = getActivityAnchorTarget({
nodeX: ownerNode.x ?? 0,
nodeY: ownerNode.y ?? 0,
nodeKind: ownerNode.kind,
leadX,
});
} else {
continue;
}
if (!target) {
continue;
}
node.fx = target.x;
node.fy = target.y;
node.x = target.x;
node.y = target.y;
node.vx = 0;
node.vy = 0;
}
}
function updateLaunchAnchorCaches(
forceNodes: ForceNode[],
launchPositions: Map<string, { x: number; y: number }>,
activityPositions: Map<string, { x: number; y: number }>,
bounds: WorldBounds[]
): void {
launchPositions.clear();
activityPositions.clear();
bounds.length = 0;
for (const node of forceNodes) {
const x = node.x ?? node.fx ?? 0;
const y = node.y ?? node.fy ?? 0;
if (node.kind === 'launch-anchor' && node.anchorForLeadId) {
launchPositions.set(node.anchorForLeadId, { x, y });
bounds.push(getLaunchAnchorBounds(x, y));
continue;
}
if (node.kind === 'activity-anchor' && node.anchorForNodeId) {
activityPositions.set(node.anchorForNodeId, { x, y });
bounds.push(getHandoffAnchorBounds(x, y));
}
}
}
// ─── Hook ───────────────────────────────────────────────────────────────────
export function useGraphSimulation(): UseGraphSimulationResult {
@ -76,6 +160,9 @@ export function useGraphSimulation(): UseGraphSimulationResult {
});
const simRef = useRef<Simulation<ForceNode, ForceLink> | null>(null);
const launchAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
const activityAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
// Initialize d3-force simulation
const initSimulation = useCallback(() => {
@ -84,9 +171,18 @@ export function useGraphSimulation(): UseGraphSimulationResult {
const sim = forceSimulation<ForceNode, ForceLink>([])
.force('center', forceCenter(0, 0).strength(FORCE.centerStrength))
.force('charge', forceManyBody<ForceNode>().strength((d) => {
if (d.kind === 'launch-anchor' || d.kind === 'activity-anchor') {
return 0;
}
return getNodeStrategy(d.kind).getChargeStrength();
}))
.force('collide', forceCollide<ForceNode>().radius((d) => {
if (d.kind === 'launch-anchor') {
return LAUNCH_ANCHOR_LAYOUT.collisionRadius;
}
if (d.kind === 'activity-anchor') {
return ACTIVITY_ANCHOR_LAYOUT.collisionRadius;
}
return getNodeStrategy(d.kind).getCollisionRadius();
}))
.force('link', forceLink<ForceNode, ForceLink>([]).id((d) => d.id).distance((d) => {
@ -113,6 +209,15 @@ export function useGraphSimulation(): UseGraphSimulationResult {
let sim = simRef.current;
if (!sim) sim = initSimulation();
const prevInternalPositions = new Map<string, { x: number; y: number }>();
for (const forceNode of sim.nodes()) {
if (!isLaunchAnchorId(forceNode.id) && !isActivityAnchorId(forceNode.id)) continue;
prevInternalPositions.set(forceNode.id, {
x: forceNode.x ?? forceNode.fx ?? 0,
y: forceNode.y ?? forceNode.fy ?? 0,
});
}
// Tasks excluded from d3-force — positioned by KanbanLayoutEngine
const forceNodes: ForceNode[] = nodes
.filter((n) => n.kind !== 'task')
@ -128,6 +233,51 @@ export function useGraphSimulation(): UseGraphSimulationResult {
fy: n.fy,
}));
for (const leadNode of nodes.filter((node) => node.kind === 'lead')) {
const anchorId = getLaunchAnchorId(leadNode.id);
const cached = prevInternalPositions.get(anchorId);
const target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0);
const position = cached ?? target;
forceNodes.push({
id: anchorId,
kind: 'launch-anchor',
anchorForLeadId: leadNode.id,
x: position.x,
y: position.y,
vx: 0,
vy: 0,
fx: target.x,
fy: target.y,
});
}
const leadNode = nodes.find((node) => node.kind === 'lead');
for (const ownerNode of nodes.filter(
(node): node is GraphNode & { kind: 'lead' | 'member' } =>
node.kind === 'lead' || node.kind === 'member'
)) {
const anchorId = getActivityAnchorId(ownerNode.id);
const cached = prevInternalPositions.get(anchorId);
const target = getActivityAnchorTarget({
nodeX: ownerNode.x ?? 0,
nodeY: ownerNode.y ?? 0,
nodeKind: ownerNode.kind,
leadX: leadNode?.x ?? null,
});
const position = cached ?? target;
forceNodes.push({
id: anchorId,
kind: 'activity-anchor',
anchorForNodeId: ownerNode.id,
x: position.x,
y: position.y,
vx: 0,
vy: 0,
fx: target.x,
fy: target.y,
});
}
// Links only between non-task nodes (parent-child: lead↔member)
const forceNodeIds = new Set(forceNodes.map((n) => n.id));
const forceLinks: ForceLink[] = edges
@ -144,7 +294,10 @@ export function useGraphSimulation(): UseGraphSimulationResult {
sim.alpha(1);
// Run simulation to near-completion so nodes are settled on first render
for (let i = 0; i < 120; i++) sim.tick();
for (let i = 0; i < 120; i++) {
syncLaunchAnchors(sim.nodes());
sim.tick();
}
sim.alpha(0); // fully settled — no more movement until new data
// Copy settled positions BACK to GraphNode objects
@ -162,6 +315,12 @@ export function useGraphSimulation(): UseGraphSimulationResult {
// Position tasks in kanban zones relative to their owners
KanbanLayoutEngine.layout(nodes);
updateLaunchAnchorCaches(
sim.nodes(),
launchAnchorPositionsRef.current,
activityAnchorPositionsRef.current,
extraWorldBoundsRef.current
);
}, [initSimulation]);
// Track previous node IDs and states for effect spawning
@ -225,7 +384,49 @@ export function useGraphSimulation(): UseGraphSimulationResult {
// Tick one frame (called by parent's RAF loop)
const tick = useCallback((dt: number) => {
tickFrame(stateRef.current, simRef.current, dt);
tickFrame(
stateRef.current,
simRef.current,
dt,
launchAnchorPositionsRef.current,
activityAnchorPositionsRef.current,
extraWorldBoundsRef.current
);
}, []);
const setNodePosition = useCallback((nodeId: string, x: number, y: number) => {
const graphNode = stateRef.current.nodes.find((node) => node.id === nodeId);
if (graphNode) {
graphNode.fx = x;
graphNode.fy = y;
graphNode.x = x;
graphNode.y = y;
graphNode.vx = 0;
graphNode.vy = 0;
}
const sim = simRef.current;
if (!sim) {
return;
}
const simNode = sim.nodes().find((node) => node.id === nodeId);
if (simNode) {
simNode.fx = x;
simNode.fy = y;
simNode.x = x;
simNode.y = y;
simNode.vx = 0;
simNode.vy = 0;
}
syncLaunchAnchors(sim.nodes());
updateLaunchAnchorCaches(
sim.nodes(),
launchAnchorPositionsRef.current,
activityAnchorPositionsRef.current,
extraWorldBoundsRef.current
);
}, []);
// Cleanup
@ -235,7 +436,24 @@ export function useGraphSimulation(): UseGraphSimulationResult {
};
}, []);
return { stateRef, updateData, tick };
const getLaunchAnchorWorldPosition = useCallback((leadNodeId: string) => {
return launchAnchorPositionsRef.current.get(leadNodeId) ?? null;
}, []);
const getExtraWorldBounds = useCallback(() => {
return extraWorldBoundsRef.current;
}, []);
return {
stateRef,
updateData,
tick,
setNodePosition,
getLaunchAnchorWorldPosition,
getActivityAnchorWorldPosition: (nodeId: string) =>
activityAnchorPositionsRef.current.get(nodeId) ?? null,
getExtraWorldBounds,
};
}
function mergeParticles(
@ -261,11 +479,15 @@ function tickFrame(
state: SimulationState,
sim: Simulation<ForceNode, ForceLink> | null,
dt: number,
launchAnchorPositions: Map<string, { x: number; y: number }>,
activityAnchorPositions: Map<string, { x: number; y: number }>,
extraWorldBounds: WorldBounds[],
): void {
state.time += dt;
// Tick d3-force (only when simulation is still active)
if (sim && sim.alpha() > 0.001) {
syncLaunchAnchors(sim.nodes());
sim.tick(1);
const simNodes = sim.nodes();
@ -281,6 +503,15 @@ function tickFrame(
node.vy = sn.vy;
}
}
updateLaunchAnchorCaches(simNodes, launchAnchorPositions, activityAnchorPositions, extraWorldBounds);
} else if (sim) {
syncLaunchAnchors(sim.nodes());
updateLaunchAnchorCaches(
sim.nodes(),
launchAnchorPositions,
activityAnchorPositions,
extraWorldBounds
);
}
// Re-layout tasks in kanban zones — always run to handle new/moved tasks

View file

@ -20,8 +20,10 @@ export type {
GraphNode,
GraphEdge,
GraphParticle,
GraphActivityItem,
GraphNodeKind,
GraphNodeState,
GraphLaunchVisualState,
GraphEdgeType,
GraphParticleKind,
GraphDomainRef,

View file

@ -0,0 +1,196 @@
import { KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants';
import type { GraphActivityItem, GraphNode } from '../ports/types';
export const ACTIVITY_LANE = {
width: 296,
itemHeight: 58,
rowHeight: 62,
maxVisibleItems: 3,
headerHeight: 18,
overflowHeight: 18,
horizontalGapLead: 76,
horizontalGapMember: 84,
bottomClearance: 18,
viewportPadding: 12,
visiblePadding: 80,
minScale: 0,
maxScale: 1,
} as const;
const RESERVED_HEIGHT =
ACTIVITY_LANE.headerHeight
+ ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight
+ ACTIVITY_LANE.overflowHeight;
export const ACTIVITY_ANCHOR_LAYOUT = {
reservedWidth: ACTIVITY_LANE.width,
reservedHeight: RESERVED_HEIGHT,
memberOffsetX: ACTIVITY_LANE.width / 2 + NODE.radiusMember + ACTIVITY_LANE.horizontalGapMember,
memberOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance),
leadOffsetX: -(ACTIVITY_LANE.width / 2 + NODE.radiusLead + ACTIVITY_LANE.horizontalGapLead),
leadOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance),
collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 12,
} as const;
export interface ActivityLaneWindow {
items: GraphActivityItem[];
overflowCount: number;
}
export interface ActivityAnchorScreenPlacement {
x: number;
y: number;
scale: number;
visible: boolean;
}
export interface ActivityLaneItemHit {
ownerNodeId: string;
item: GraphActivityItem;
}
export type ActivityLaneSide = 'left' | 'right';
export function resolveActivityLaneSide(args: {
nodeKind: 'lead' | 'member';
nodeX: number;
leadX?: number | null;
}): ActivityLaneSide {
const { nodeKind, nodeX, leadX } = args;
if (nodeKind === 'lead') {
return 'left';
}
if (leadX == null) {
return 'right';
}
return nodeX < leadX ? 'left' : 'right';
}
export function getActivityAnchorTarget(args: {
nodeX: number;
nodeY: number;
nodeKind: 'lead' | 'member';
leadX?: number | null;
}): { x: number; y: number } {
const { nodeX, nodeY, nodeKind, leadX } = args;
const side = resolveActivityLaneSide({ nodeKind, nodeX, leadX });
if (side === 'left') {
return {
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.leadOffsetX,
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.leadOffsetY,
};
}
return {
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX,
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY,
};
}
export function getActivityLaneBounds(anchorX: number, anchorY: number): {
left: number;
top: number;
right: number;
bottom: number;
} {
const halfWidth = ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2;
const halfHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2;
return {
left: anchorX - halfWidth,
top: anchorY - halfHeight,
right: anchorX + halfWidth,
bottom: anchorY + halfHeight,
};
}
export function getActivityLaneScale(zoom: number): number {
return Math.max(ACTIVITY_LANE.minScale, Math.min(ACTIVITY_LANE.maxScale, zoom));
}
export function getActivityAnchorScreenPlacement(args: {
anchorX: number;
anchorY: number;
cameraX: number;
cameraY: number;
zoom: number;
viewportWidth: number;
viewportHeight: number;
}): ActivityAnchorScreenPlacement {
const { anchorX, anchorY, cameraX, cameraY, zoom, viewportWidth, viewportHeight } = args;
const scale = getActivityLaneScale(zoom);
const scaledWidth = ACTIVITY_LANE.width * scale;
const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale;
const screenX = anchorX * zoom + cameraX;
const screenY = anchorY * zoom + cameraY;
const x = screenX - scaledWidth / 2;
const y = screenY - scaledHeight / 2;
const right = x + scaledWidth;
const bottom = y + scaledHeight;
return {
x,
y,
scale,
visible:
right > -ACTIVITY_LANE.visiblePadding &&
x < viewportWidth + ACTIVITY_LANE.visiblePadding &&
bottom > -ACTIVITY_LANE.visiblePadding &&
y < viewportHeight + ACTIVITY_LANE.visiblePadding,
};
}
export function getVisibleActivityWindow(
items: GraphActivityItem[] | undefined
): ActivityLaneWindow {
const source = items ?? [];
if (source.length <= ACTIVITY_LANE.maxVisibleItems) {
return { items: source, overflowCount: 0 };
}
return {
items: source.slice(0, ACTIVITY_LANE.maxVisibleItems),
overflowCount: source.length - ACTIVITY_LANE.maxVisibleItems,
};
}
export function findActivityItemAt(
worldX: number,
worldY: number,
nodes: GraphNode[]
): ActivityLaneItemHit | null {
const leadNode = nodes.find((node) => node.kind === 'lead' && node.x != null);
const leadX = leadNode?.x ?? null;
for (const node of nodes) {
if (!isActivityOwner(node) || node.x == null || node.y == null) continue;
const { items } = getVisibleActivityWindow(node.activityItems);
if (items.length === 0) continue;
const anchor = getActivityAnchorTarget({
nodeX: node.x,
nodeY: node.y,
nodeKind: node.kind,
leadX,
});
const bounds = getActivityLaneBounds(anchor.x, anchor.y);
const left = bounds.left;
const top = bounds.top;
const itemsTop = top + ACTIVITY_LANE.headerHeight;
for (let index = 0; index < items.length; index += 1) {
const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
if (
worldX >= left &&
worldX <= left + ACTIVITY_LANE.width &&
worldY >= itemTop &&
worldY <= itemTop + ACTIVITY_LANE.itemHeight
) {
return { ownerNodeId: node.id, item: items[index] };
}
}
}
return null;
}
export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } {
return node.kind === 'lead' || node.kind === 'member';
}

View file

@ -10,6 +10,7 @@
import type { GraphNode } from '../ports/types';
import { KANBAN_ZONE } from '../constants/canvas-constants';
import { COLORS } from '../constants/colors';
import { resolveActivityLaneSide } from './activityLane';
/** Column header info for rendering */
export interface KanbanColumnHeader {
@ -40,6 +41,35 @@ const COLUMN_LABELS: Record<string, { label: string; color: string }> = {
approved: { label: 'Approved', color: COLORS.reviewApproved },
};
export function getOwnerKanbanBaseX(args: {
ownerX: number;
ownerKind: GraphNode['kind'];
activeColumnCount: number;
columnWidth: number;
leadX?: number | null;
}): number {
const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args;
if (activeColumnCount <= 0) {
return ownerX;
}
if (ownerKind !== 'lead' && ownerKind !== 'member') {
return ownerX - (activeColumnCount * columnWidth) / 2;
}
const side = resolveActivityLaneSide({
nodeKind: ownerKind,
nodeX: ownerX,
leadX,
});
if (side === 'left') {
return ownerX;
}
return ownerX - (activeColumnCount - 1) * columnWidth;
}
export class KanbanLayoutEngine {
// Reusable collections (cleared each call, never GC'd)
static readonly #nodeMap = new Map<string, GraphNode>();
@ -58,6 +88,7 @@ export class KanbanLayoutEngine {
const nodeMap = this.#nodeMap;
nodeMap.clear();
for (const n of nodes) nodeMap.set(n.id, n);
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
const tasksByOwner = this.#tasksByOwner;
tasksByOwner.clear();
@ -84,7 +115,7 @@ export class KanbanLayoutEngine {
for (const [ownerId, tasks] of tasksByOwner) {
const owner = nodeMap.get(ownerId);
if (!owner || owner.x == null || owner.y == null) continue;
const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y, ownerId);
const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner, ownerId, leadX);
if (zoneInfo) this.zones.push(zoneInfo);
}
@ -93,9 +124,16 @@ export class KanbanLayoutEngine {
// ─── Private ──────────────────────────────────────────────────────────────
static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null {
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[] = [];

View file

@ -0,0 +1,117 @@
import { NODE } from '../constants/canvas-constants';
import {
ACTIVITY_ANCHOR_LAYOUT,
getActivityAnchorTarget,
getActivityLaneBounds,
} from './activityLane';
export interface WorldBounds {
left: number;
top: number;
right: number;
bottom: number;
}
export interface LaunchAnchorScreenPlacement {
x: number;
y: number;
scale: number;
visible: boolean;
}
export const LAUNCH_ANCHOR_LAYOUT = {
compactWidth: 336,
compactHeight: 132,
anchorCenterOffsetX: 336 / 2 + NODE.radiusLead + 40,
anchorCenterOffsetY: -(132 / 2 + NODE.radiusLead + 36),
collisionRadius: Math.ceil(Math.hypot(336 / 2, 132 / 2)) + 14,
viewportPadding: 12,
visiblePadding: 80,
minScale: 0,
maxScale: 1,
} as const;
const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:';
const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:';
export function getLaunchAnchorId(leadNodeId: string): string {
return `${LAUNCH_ANCHOR_PREFIX}${leadNodeId}`;
}
export function getActivityAnchorId(nodeId: string): string {
return `${ACTIVITY_ANCHOR_PREFIX}${nodeId}`;
}
export function isLaunchAnchorId(nodeId: string): boolean {
return nodeId.startsWith(LAUNCH_ANCHOR_PREFIX);
}
export function isActivityAnchorId(nodeId: string): boolean {
return nodeId.startsWith(ACTIVITY_ANCHOR_PREFIX);
}
export function getLaunchAnchorTarget(leadX: number, leadY: number): { x: number; y: number } {
return {
x: leadX + LAUNCH_ANCHOR_LAYOUT.anchorCenterOffsetX,
y: leadY + LAUNCH_ANCHOR_LAYOUT.anchorCenterOffsetY,
};
}
export function getLaunchHudScale(zoom: number): number {
return clamp(zoom, LAUNCH_ANCHOR_LAYOUT.minScale, LAUNCH_ANCHOR_LAYOUT.maxScale);
}
export function getLaunchAnchorBounds(anchorX: number, anchorY: number): WorldBounds {
const halfWidth = LAUNCH_ANCHOR_LAYOUT.compactWidth / 2;
const halfHeight = LAUNCH_ANCHOR_LAYOUT.compactHeight / 2;
return {
left: anchorX - halfWidth,
top: anchorY - halfHeight,
right: anchorX + halfWidth,
bottom: anchorY + halfHeight,
};
}
export const getLaunchHudBounds = getLaunchAnchorBounds;
export const HANDOFF_ANCHOR_LAYOUT = ACTIVITY_ANCHOR_LAYOUT;
export const getHandoffAnchorId = getActivityAnchorId;
export const isHandoffAnchorId = isActivityAnchorId;
export { getActivityAnchorTarget };
export const getHandoffAnchorTarget = getActivityAnchorTarget;
export const getHandoffAnchorBounds = getActivityLaneBounds;
export function getLaunchAnchorScreenPlacement(args: {
anchorX: number;
anchorY: number;
cameraX: number;
cameraY: number;
zoom: number;
viewportWidth: number;
viewportHeight: number;
}): LaunchAnchorScreenPlacement {
const { anchorX, anchorY, cameraX, cameraY, zoom, viewportWidth, viewportHeight } = args;
const scale = getLaunchHudScale(zoom);
const scaledWidth = LAUNCH_ANCHOR_LAYOUT.compactWidth * scale;
const scaledHeight = LAUNCH_ANCHOR_LAYOUT.compactHeight * scale;
const screenX = anchorX * zoom + cameraX;
const screenY = anchorY * zoom + cameraY;
const rawX = screenX - scaledWidth / 2;
const rawY = screenY - scaledHeight / 2;
const maxX = viewportWidth - scaledWidth - LAUNCH_ANCHOR_LAYOUT.viewportPadding;
const maxY = viewportHeight - scaledHeight - LAUNCH_ANCHOR_LAYOUT.viewportPadding;
return {
x: clamp(rawX, LAUNCH_ANCHOR_LAYOUT.viewportPadding, Math.max(LAUNCH_ANCHOR_LAYOUT.viewportPadding, maxX)),
y: clamp(rawY, LAUNCH_ANCHOR_LAYOUT.viewportPadding, Math.max(LAUNCH_ANCHOR_LAYOUT.viewportPadding, maxY)),
scale,
visible:
screenX > -LAUNCH_ANCHOR_LAYOUT.visiblePadding &&
screenX < viewportWidth + LAUNCH_ANCHOR_LAYOUT.visiblePadding &&
screenY > -LAUNCH_ANCHOR_LAYOUT.visiblePadding &&
screenY < viewportHeight + LAUNCH_ANCHOR_LAYOUT.visiblePadding,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}

View file

@ -17,6 +17,13 @@ export type GraphNodeState =
| 'error'
| 'terminated';
export type GraphLaunchVisualState =
| 'waiting'
| 'spawning'
| 'runtime_pending'
| 'settling'
| 'error';
// ─── Edge & Particle Types ───────────────────────────────────────────────────
export type GraphEdgeType = 'parent-child' | 'ownership' | 'blocking' | 'related' | 'message';
@ -29,6 +36,18 @@ export type GraphParticleKind =
| 'review_response'
| 'spawn';
export interface GraphActivityItem {
id: string;
kind: Exclude<GraphParticleKind, 'spawn'>;
timestamp: string;
title: string;
preview?: string;
accentColor?: string;
taskId?: string;
taskDisplayId?: string;
authorLabel?: string;
}
// ─── Graph Node ──────────────────────────────────────────────────────────────
export interface GraphNode {
@ -50,6 +69,8 @@ export interface GraphNode {
avatarUrl?: string;
/** Spawn lifecycle status */
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
/** Shared launch-stage visual derived by the host app */
launchVisualState?: GraphLaunchVisualState;
/** Context window usage ratio (0..1), available for lead only */
contextUsage?: number;
/** Current task ID this member is working on */
@ -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") */

View file

@ -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<GraphCanvasHandle, GraphCanvasProps>(funct
onContextMenu,
className,
},
ref,
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const bloomRef = useRef<BloomRenderer>(new BloomRenderer(bloomIntensity));
const starsRef = useRef<DepthParticle[]>([]);
const shootingStarsRef = useRef<ShootingStarField>(createShootingStarField());
const sizeRef = useRef({ w: 0, h: 0 });
const lastBackgroundTimeRef = useRef<number | null>(null);
// Performance tracking
const perfRef = useRef({ frames: 0, fps: 0, frameTimeMs: 0, lastFpsUpdate: 0, frameTimes: [] as number[] });
const perfRef = useRef({
frames: 0,
fps: 0,
frameTimeMs: 0,
lastFpsUpdate: 0,
frameTimes: [] as number[],
});
// Rate-limited error logging (prevent console flood at 60fps)
const lastDrawErrorRef = useRef(0);
@ -115,6 +134,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
sizeRef.current = { w: width, h: height };
bloomRef.current.resize(width * dpr, height * dpr);
starsRef.current = createDepthParticles(width, height);
shootingStarsRef.current = createShootingStarField();
lastBackgroundTimeRef.current = null;
}
});
@ -134,235 +155,260 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
const lastTeamNameRef = useRef<string | null>(null);
// Imperative draw function — called from RAF, NOT from React render
useImperativeHandle(ref, () => ({
draw: (state: GraphDrawState) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
useImperativeHandle(
ref,
() => ({
draw: (state: GraphDrawState) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const frameStart = performance.now();
const frameStart = performance.now();
const dpr = window.devicePixelRatio || 1;
const { w, h } = sizeRef.current;
if (w === 0 || h === 0) return;
const dpr = window.devicePixelRatio || 1;
const { w, h } = sizeRef.current;
if (w === 0 || h === 0) return;
try {
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<GraphCanvasHandle, GraphCanvasProps>(funct
}, [onWheel]);
return (
<div ref={containerRef} className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
<div ref={containerRef} className={`relative h-full w-full overflow-hidden ${className ?? ''}`}>
<canvas
ref={canvasRef}
className="absolute inset-0"

View file

@ -93,7 +93,7 @@ export function GraphControls({
return (
<>
<div className="absolute left-20 top-3 z-10 flex items-center gap-3 pointer-events-none">
<div className="absolute left-20 top-3 z-20 flex items-center gap-3 pointer-events-none">
<div
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
style={{
@ -110,7 +110,7 @@ export function GraphControls({
</div>
</div>
<div className="absolute right-3 top-3 z-10 flex items-center gap-2 pointer-events-none">
<div className="absolute right-3 top-3 z-20 flex items-center gap-2 pointer-events-none">
<div
className="pointer-events-auto flex items-center rounded-lg px-1 py-0.5 backdrop-blur-sm"
style={{
@ -192,7 +192,7 @@ export function GraphControls({
</div>
</div>
<div className="absolute bottom-3 right-3 z-10 pointer-events-none">
<div className="absolute bottom-3 right-3 z-20 pointer-events-none">
<div
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
style={{
@ -207,7 +207,7 @@ export function GraphControls({
</div>
{showBlockingHint && (
<div className="absolute bottom-3 left-3 z-10 pointer-events-none">
<div className="absolute bottom-3 left-3 z-20 pointer-events-none">
<div
className="pointer-events-auto rounded-lg px-2.5 py-1 text-[10px] font-mono backdrop-blur-sm"
style={{

View file

@ -31,6 +31,8 @@ import {
getEdgeMidpoint,
} from '../canvas/hit-detection';
import { ANIM_SPEED } from '../constants/canvas-constants';
import { getActivityAnchorScreenPlacement as buildActivityAnchorScreenPlacement } from '../layout/activityLane';
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
export interface GraphViewProps {
data: GraphDataPort;
@ -54,6 +56,15 @@ export interface GraphViewProps {
onClose: () => void;
onSelectNode: (nodeId: string) => void;
}) => React.ReactNode;
renderHud?: (props: {
getLaunchAnchorScreenPlacement: (
leadNodeId: string,
) => { x: number; y: number; scale: number; visible: boolean } | null;
getActivityAnchorScreenPlacement: (
ownerNodeId: string,
) => { x: number; y: number; scale: number; visible: boolean } | null;
focusNodeIds: ReadonlySet<string> | 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<string | null>(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 (
<div ref={containerRef} className={`relative w-full h-full ${className ?? ''}`}>
<div ref={containerRef} className={`relative h-full w-full overflow-hidden ${className ?? ''}`}>
<GraphCanvas
ref={canvasHandle}
showHexGrid={config?.showHexGrid ?? true}
@ -586,7 +662,13 @@ export function GraphView({
onZoomToFit={() => {
markUserInteracted();
const el = containerRef.current;
if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight);
if (el)
camera.zoomToFit(
simulation.stateRef.current.nodes,
el.clientWidth,
el.clientHeight,
simulation.getExtraWorldBounds()
);
}}
onRequestClose={onRequestClose}
onRequestPinAsTab={onRequestPinAsTab}
@ -597,8 +679,18 @@ export function GraphView({
showBlockingHint={filters.showEdges && hasBlockingEdges && !selectedNode && !selectedEdge}
/>
{renderHud ? (
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
{renderHud({
getLaunchAnchorScreenPlacement,
getActivityAnchorScreenPlacement,
focusNodeIds: focusState.focusNodeIds,
})}
</div>
) : null}
{(selectedNode || selectedEdge) && (
<div ref={overlayRef} className="fixed z-20 pointer-events-auto">
<div ref={overlayRef} className="pointer-events-auto fixed z-20">
{selectedNode ? (
renderOverlay ? (
renderOverlay({

View file

@ -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<string>();
const seenEdges = new Set<string>();

View file

@ -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,
});

View file

@ -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<HTMLDivElement>(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',

View file

@ -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',

View file

@ -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 => {
)}
</button>
{/* Teams icon */}
<button
onClick={openTeamsTab}
onMouseEnter={() => setTeamsHover(true)}
onMouseLeave={() => setTeamsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: teamsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: teamsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Teams"
>
<Users className="size-4" />
</button>
{/* Extensions icon */}
<button
onClick={openExtensionsTab}
onMouseEnter={() => setExtensionsHover(true)}
onMouseLeave={() => setExtensionsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: extensionsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: extensionsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Extensions"
>
<Puzzle className="size-4" />
</button>
{/* GitHub link */}
<button
onClick={() =>
@ -164,22 +126,28 @@ export const TabBarActions = (): React.JSX.Element => {
</svg>
</button>
{/* Settings gear icon */}
{/* Discord link */}
<button
onClick={() => openSettingsTab()}
onMouseEnter={() => setSettingsHover(true)}
onMouseLeave={() => setSettingsHover(false)}
onClick={() =>
void (isElectronMode()
? window.electronAPI.openExternal('https://discord.gg/qtqSZSyuEc')
: window.open('https://discord.gg/qtqSZSyuEc', '_blank'))
}
onMouseEnter={() => setDiscordHover(true)}
onMouseLeave={() => setDiscordHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Settings"
title="Discord"
>
<Settings className="size-4" />
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.3698A19.791 19.791 0 0 0 15.4319 3.0a13.873 13.873 0 0 0-.6242 1.2757 18.27 18.27 0 0 0-5.6154 0A13.872 13.872 0 0 0 8.5681 3 19.736 19.736 0 0 0 3.683 4.3698C.5334 9.1048-.319 13.7216.099 18.272a19.9 19.9 0 0 0 6.0892 3.1157 14.96 14.96 0 0 0 1.303-2.1356 12.46 12.46 0 0 1-1.9352-.9351c.1624-.1218.3217-.2462.4763-.3736 3.7294 1.7014 7.772 1.7014 11.4572 0 .1546.1274.3139.2518.4763.3736-.6163.3622-1.2638.6754-1.9352.9351.3654.7439.8041 1.4554 1.303 2.1356A19.9 19.9 0 0 0 23.901 18.272c.5003-5.2737-.8381-9.8482-3.584-13.9022ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Zm7.96 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Z" />
</svg>
</button>
{/* More menu (Search, Export, Analyze, Schedules) */}
{/* More menu (Teams, Settings, Extensions, Search, Export, Analyze, Schedules) */}
<MoreMenu
activeTab={activeTab}
activeTabSessionDetail={activeTabSessionDetail}

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import {
Dialog,
DialogContent,
@ -488,12 +489,21 @@ export const ProviderRuntimeSettingsDialog = ({
const runtimeSummary = selectedProvider
? getProviderRuntimeBackendSummary(selectedProvider)
: null;
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
const configuredAuthMode: CliProviderAuthMode | undefined =
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
const connectionMethodCardOptions = selectedProvider
? getConnectionMethodCardOptions(selectedProvider)
: null;
const showConnectionMethodCards =
connectionMethodCardOptions !== null && typeof configuredAuthMode !== 'undefined';
const managedRuntimeSummary = selectedProvider
? getProviderCurrentRuntimeSummary(selectedProvider)
: null;
const connectionManagedRuntime = selectedProvider
? isConnectionManagedRuntimeProvider(selectedProvider)
: false;
const hideConnectionMethodMeta = showConnectionMethodCards;
const canConfigureRuntime =
!connectionManagedRuntime && (selectedProvider?.availableBackends?.length ?? 0) > 0;
@ -505,19 +515,14 @@ export const ProviderRuntimeSettingsDialog = ({
selectedProvider &&
isApiKeyProviderId(selectedProvider.providerId) &&
activeApiKeyFormProviderId === selectedProvider.providerId;
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
const configuredAuthMode: CliProviderAuthMode | undefined =
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
const codexApiKeyBetaEnabled = selectedProvider?.connection?.apiKeyBetaEnabled === true;
const connectionMethodCardOptions = selectedProvider
? getConnectionMethodCardOptions(selectedProvider)
: null;
const showApiKeySection = Boolean(
apiKeyConfig && (selectedProvider?.providerId !== 'codex' || codexApiKeyBetaEnabled)
);
const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider) : null;
const connectionLoading = selectedProviderLoading || connectionSaving;
const connectionBusy = disabled || connectionLoading;
const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving;
const showConnectionMethodCards =
connectionMethodCardOptions !== null && typeof configuredAuthMode !== 'undefined';
const connectionMethodCardsHint = selectedProvider
? getConnectionMethodCardsHint(selectedProvider)
: null;
@ -531,6 +536,18 @@ export const ProviderRuntimeSettingsDialog = ({
Boolean(selectedProvider?.connection?.supportsOAuth && onRequestLogin) &&
configuredAuthMode !== 'api_key' &&
(!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth');
let connectionStatusLabel: string | null = null;
if (selectedProvider) {
if (!hideConnectionMethodMeta && selectedProvider.authenticated) {
connectionStatusLabel = `Using ${formatProviderAuthMethodLabelForProvider(
selectedProvider.providerId,
selectedProvider.authMethod
)}`;
} else if (!hideConnectionMethodMeta) {
connectionStatusLabel = 'Not connected';
}
}
const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime;
const connectionProgressMessage = useMemo(() => {
if (!connectionLoading || !selectedProvider) {
@ -783,7 +800,13 @@ export const ProviderRuntimeSettingsDialog = ({
value={provider.providerId}
className="relative rounded-b-none data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:bg-[var(--color-surface)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:-bottom-px data-[state=active]:after:h-1 data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']"
>
{provider.displayName}
<span className="inline-flex items-center gap-2">
<ProviderBrandLogo
providerId={provider.providerId}
className="size-4 shrink-0"
/>
<span>{provider.displayName}</span>
</span>
</TabsTrigger>
))}
</TabsList>
@ -791,7 +814,7 @@ export const ProviderRuntimeSettingsDialog = ({
</Tabs>
</div>
{selectedProvider ? (
{showSelectedProviderSummary && selectedProvider ? (
<div
className="rounded-lg border px-3 py-2.5"
style={{
@ -816,7 +839,7 @@ export const ProviderRuntimeSettingsDialog = ({
)}`
: selectedProvider.statusMessage || 'Not connected'}
</span>
{managedRuntimeSummary ? (
{managedRuntimeSummary && !hideConnectionMethodMeta ? (
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{managedRuntimeSummary}
</span>
@ -987,7 +1010,7 @@ export const ProviderRuntimeSettingsDialog = ({
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs">
{configuredAuthMode ? (
{configuredAuthMode && !hideConnectionMethodMeta ? (
<span
className="rounded-full px-2 py-0.5"
style={{
@ -1002,23 +1025,20 @@ export const ProviderRuntimeSettingsDialog = ({
)}
</span>
) : null}
<span
className="rounded-full px-2 py-0.5"
style={{
color: selectedProvider.authenticated ? '#86efac' : 'var(--color-text-muted)',
backgroundColor: selectedProvider.authenticated
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
{selectedProvider.authenticated
? `Using ${formatProviderAuthMethodLabelForProvider(
selectedProvider.providerId,
selectedProvider.authMethod
)}`
: 'Not connected'}
</span>
{selectedProvider.connection?.apiKeyConfigured ? (
{connectionStatusLabel ? (
<span
className="rounded-full px-2 py-0.5"
style={{
color: selectedProvider.authenticated ? '#86efac' : 'var(--color-text-muted)',
backgroundColor: selectedProvider.authenticated
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
{connectionStatusLabel}
</span>
) : null}
{selectedProvider.connection?.apiKeyConfigured && !showApiKeySection ? (
<span style={{ color: 'var(--color-text-secondary)' }}>
{selectedProvider.connection.apiKeySourceLabel}
</span>
@ -1039,8 +1059,7 @@ export const ProviderRuntimeSettingsDialog = ({
) : null}
</div>
{apiKeyConfig &&
(selectedProvider.providerId !== 'codex' || codexApiKeyBetaEnabled) ? (
{showApiKeySection ? (
<div
className="space-y-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
@ -1049,7 +1068,8 @@ export const ProviderRuntimeSettingsDialog = ({
<div className="min-w-0">
<div className="flex items-center gap-2">
<div
className="flex size-7 items-center justify-center rounded-md border"
data-testid="provider-api-key-icon"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255,255,255,0.03)',

View file

@ -37,6 +37,8 @@ export interface ProvisioningProgressBlockProps {
tone?: 'default' | 'error';
/** Whether Live output is expanded by default */
defaultLiveOutputOpen?: boolean;
/** Whether CLI logs are expanded by default */
defaultLogsOpen?: boolean;
/** Display step index (0-3 for active steps, 4 for ready/all done, -1 for terminal) */
currentStepIndex: number;
/** If set, this step index shows a red error indicator */
@ -139,6 +141,7 @@ export const ProvisioningProgressBlock = ({
messageSeverity,
tone = 'default',
defaultLiveOutputOpen = true,
defaultLogsOpen,
currentStepIndex,
errorStepIndex,
loading = false,
@ -154,7 +157,9 @@ export const ProvisioningProgressBlock = ({
className,
}: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState(() => Boolean(cliLogsTail) && loading);
const [logsOpen, setLogsOpen] = useState(
() => defaultLogsOpen ?? (Boolean(cliLogsTail) && loading)
);
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
const outputScrollRef = useRef<HTMLDivElement>(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) {

View file

@ -1936,8 +1936,12 @@ export const TeamDetailView = ({
</TeamSidebarPortalSource>
</TeamSidebarHost>
<div className="relative size-full flex-1">
<div ref={contentRef} className="size-full overflow-auto p-4" data-team-name={teamName}>
<div className="relative min-h-0 min-w-0 flex-1">
<div
ref={contentRef}
className="size-full min-w-0 overflow-y-auto overflow-x-hidden p-4"
data-team-name={teamName}
>
<div className="relative -mx-4 -mt-4 mb-3 overflow-hidden border-b border-[var(--color-border)] px-4 py-3">
{headerColorSet ? (
<div
@ -2202,7 +2206,6 @@ export const TeamDetailView = ({
badge={filteredTasks.length}
defaultOpen
forceOpen={kanbanSearch.trim().length > 0}
contentClassName="overflow-x-visible"
action={
<Button
variant="ghost"

View file

@ -1,18 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { memo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
import { X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
import {
DISPLAY_COMPLETE_STEP_INDEX,
getDisplayStepIndex,
getLaunchJoinMilestonesFromMembers,
getLaunchJoinState,
} from './provisioningSteps';
import { TeamProvisioningPanel } from './TeamProvisioningPanel';
interface TeamProvisioningBannerProps {
teamName: string;
@ -21,205 +9,8 @@ interface TeamProvisioningBannerProps {
export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
teamName,
}: TeamProvisioningBannerProps): React.JSX.Element | null {
const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses, memberSpawnSnapshot } =
useStore(
useShallow((s) => ({
progress: getCurrentProvisioningProgressForTeam(s, teamName),
cancelProvisioning: s.cancelProvisioning,
teamMembers: s.selectedTeamName === teamName ? s.selectedTeamData?.members : undefined,
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
}))
);
const [dismissed, setDismissed] = useState(false);
const lastActiveStepRef = useRef(-1);
const bannerInstanceKey = useMemo(() => {
if (!progress) return null;
return `${teamName}:${progress.runId}:${progress.startedAt}`;
}, [teamName, progress?.runId, progress?.startedAt]);
useEffect(() => {
setDismissed(false);
}, [bannerInstanceKey]);
// NOTE: we intentionally do NOT auto-dismiss "ready" banners.
// Users frequently need to inspect launch output after fast stop→start cycles,
// and auto-dismiss can make it look like no progress/logs were produced.
if (!progress || dismissed) {
return null;
}
if (progress.state === 'cancelled' || progress.state === 'disconnected') {
return null;
}
const isReady = progress.state === 'ready';
const isFailed = progress.state === 'failed';
const isActive =
progress.state === 'validating' ||
progress.state === 'spawning' ||
progress.state === 'configuring' ||
progress.state === 'assembling' ||
progress.state === 'finalizing' ||
progress.state === 'verifying';
const canCancel =
progress.state === 'spawning' ||
progress.state === 'configuring' ||
progress.state === 'assembling' ||
progress.state === 'finalizing' ||
progress.state === 'verifying';
const {
expectedTeammateCount: fallbackTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
} = getLaunchJoinMilestonesFromMembers({
members: teamMembers ?? [],
memberSpawnStatuses,
memberSpawnSnapshot,
});
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
getLaunchJoinState({
expectedTeammateCount: fallbackTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
});
const progressStepIndex = getDisplayStepIndex({
progress,
expectedTeammateCount: fallbackTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
});
// Keep the error marker aligned to the last meaningful UI milestone, not the
// raw backend phase enum. The launch flow now moves through some backend
// states too quickly for the old enum mapping to stay user-meaningful.
if (progressStepIndex >= 0 && !isFailed) {
lastActiveStepRef.current = progressStepIndex;
}
if (isFailed) {
return (
<div className="mb-3">
<div className="mb-2 flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2">
<p className="flex-1 text-xs text-[var(--step-error-text)]">{progress.message}</p>
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-[var(--step-error-text)] hover:bg-red-500/10"
onClick={() => setDismissed(true)}
>
<X size={12} />
</Button>
</div>
<ProvisioningProgressBlock
key={progress.runId}
title="Launch failed"
message={progress.error ?? null}
tone="error"
surface="flat"
currentStepIndex={lastActiveStepRef.current}
errorStepIndex={lastActiveStepRef.current >= 0 ? lastActiveStepRef.current : 0}
startedAt={progress.startedAt}
pid={progress.pid}
cliLogsTail={progress.cliLogsTail}
assistantOutput={progress.assistantOutput}
defaultLiveOutputOpen
onCancel={null}
/>
</div>
);
}
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 (
<div className="mb-3">
<ProvisioningProgressBlock
key={progress.runId}
title="Launch details"
message={failedSpawnCount > 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)}
/>
</div>
);
}
if (isActive) {
return (
<div className="mb-3">
<ProvisioningProgressBlock
key={progress.runId}
title="Launching team"
message={progress.message}
messageSeverity={progress.messageSeverity}
surface="flat"
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
loading
startedAt={progress.startedAt}
pid={progress.pid}
cliLogsTail={progress.cliLogsTail}
assistantOutput={progress.assistantOutput}
defaultLiveOutputOpen
onCancel={
canCancel
? () => {
void cancelProvisioning(progress.runId);
}
: null
}
/>
</div>
);
}
return null;
const panel = (
<TeamProvisioningPanel teamName={teamName} surface="flat" dismissible className="mb-3" />
);
return panel;
});

View file

@ -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 = (
<ProvisioningProgressBlock
key={presentation.progress.runId}
title={presentation.panelTitle}
message={presentation.panelMessage}
messageSeverity={presentation.panelMessageSeverity}
tone={presentation.panelTone}
surface={surface}
currentStepIndex={presentation.currentStepIndex}
errorStepIndex={
presentation.isFailed
? lastActiveStepRef.current >= 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 (
<div className={cn('space-y-2', className)}>
<div className="flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2">
<p className="flex-1 text-xs text-[var(--step-error-text)]">
{presentation.progress.message}
</p>
{dismissible ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-[var(--step-error-text)] hover:bg-red-500/10"
onClick={() => setDismissed(true)}
>
<X size={12} />
</Button>
) : null}
</div>
{block}
</div>
);
});

View file

@ -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 }) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M17.304 3.541h-3.672l6.696 16.918H24Zm-10.608 0L0 20.459h3.744l1.37-3.553h7.005l1.369 3.553h3.744L10.536 3.541Zm-.371 10.223 2.291-5.946 2.292 5.946Z" />
</svg>
);
/** OpenAI — official hexagonal knot logo (Simple Icons) */
const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.998 5.998 0 0 0-3.992 2.9 6.042 6.042 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.612-1.5z" />
</svg>
);
const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M12 2.25c.62 3.9 1.6 6.57 3.18 8.15 1.58 1.58 4.25 2.56 8.15 3.18-3.9.62-6.57 1.6-8.15 3.18-1.58 1.58-2.56 4.25-3.18 8.15-.62-3.9-1.6-6.57-3.18-8.15-1.58-1.58-4.25-2.56-8.15-3.18 3.9-.62 6.57-1.6 8.15-3.18C10.4 8.82 11.38 6.15 12 2.25Z" />
</svg>
);
const OpenCodeIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" className={className}>
<defs>
<linearGradient id="opencode-bg" x1="4" y1="3" x2="20" y2="21" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#303030" />
<stop offset="1" stopColor="#161616" />
</linearGradient>
<linearGradient
id="opencode-frame"
x1="7"
y1="4.5"
x2="17"
y2="19.5"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#f4f4f4" />
<stop offset="0.35" stopColor="#d9d9d9" />
<stop offset="0.68" stopColor="#a8a8a8" />
<stop offset="1" stopColor="#ececec" />
</linearGradient>
<linearGradient
id="opencode-frame-stroke"
x1="7"
y1="4.5"
x2="17"
y2="19.5"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#ffffff" stopOpacity="0.9" />
<stop offset="1" stopColor="#5a5a5a" stopOpacity="0.9" />
</linearGradient>
<linearGradient
id="opencode-core"
x1="12"
y1="7"
x2="12"
y2="17"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#121212" />
<stop offset="0.42" stopColor="#3e3b33" />
<stop offset="1" stopColor="#16140f" />
</linearGradient>
<linearGradient
id="opencode-core-stroke"
x1="9"
y1="7"
x2="15"
y2="17"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#f2f2f2" stopOpacity="0.95" />
<stop offset="1" stopColor="#6e6e6e" stopOpacity="0.85" />
</linearGradient>
<filter id="opencode-shadow" x="0" y="0" width="24" height="24" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="1.2" stdDeviation="1.2" floodColor="#000000" floodOpacity="0.42" />
</filter>
</defs>
<rect x="1.5" y="1.5" width="21" height="21" rx="5.2" fill="url(#opencode-bg)" />
<g filter="url(#opencode-shadow)">
<path
d="M7 4.25h10c.3 0 .55.25.55.55v14.4c0 .3-.25.55-.55.55H7c-.3 0-.55-.25-.55-.55V4.8c0-.3.25-.55.55-.55Z"
fill="url(#opencode-frame)"
stroke="url(#opencode-frame-stroke)"
strokeWidth="0.55"
/>
<path
d="M8.95 7.25h6.1c.22 0 .4.18.4.4v8.7c0 .22-.18.4-.4.4h-6.1a.4.4 0 0 1-.4-.4v-8.7c0-.22.18-.4.4-.4Z"
fill="url(#opencode-core)"
stroke="url(#opencode-core-stroke)"
strokeWidth="0.45"
/>
<path
d="M9.25 7.6h5.5"
stroke="#ffffff"
strokeOpacity="0.18"
strokeWidth="0.45"
strokeLinecap="round"
/>
</g>
</svg>
);
// --- 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<TeamModelSelectorProps> = ({
<div className="-mb-px border-b border-[var(--color-border-subtle)]">
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 rounded-none bg-transparent p-0">
{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<TeamModelSelectorProps> = ({
!providerSelectable && 'opacity-50'
)}
>
<Icon className="size-3.5 shrink-0" />
<ProviderBrandLogo providerId={provider.id} className="size-5 shrink-0" />
<span
className={cn(
'min-w-0 truncate text-sm font-medium',
@ -422,19 +315,15 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
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);

View file

@ -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: <ClipboardList size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
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: <PlayCircle size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
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: <CheckCircle2 size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
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: <Eye size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
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: <ShieldCheck size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
},
};
@ -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<HTMLDivElement>(null);
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(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 = (
<>
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
{toolbarLeft != null && <div className="min-w-0 flex-1">{toolbarLeft}</div>}
<div className="flex shrink-0 items-center gap-2">
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
<div
className={cn(
'flex min-w-0 max-w-full items-center gap-2',
viewMode === 'columns' ? 'mb-0' : 'mb-2',
toolbarLeft == null && 'justify-end'
)}
>
{toolbarLeft != null && (
<div className="min-w-0 max-w-full" style={{ width: toolbarLeftWidth }}>
{toolbarLeft}
</div>
)}
<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="inline-flex items-center rounded-md border border-[var(--color-border)]">
<KanbanFilterPopover
filter={filter}
@ -532,7 +645,7 @@ export const KanbanBoard = ({
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('grid')}
onClick={() => switchViewMode('grid')}
aria-label="Grid view"
>
<LayoutGrid size={14} />
@ -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"
>
<Columns3 size={14} />
@ -566,6 +679,9 @@ export const KanbanBoard = ({
{viewMode === 'grid' ? (
<KanbanGridLayout
allColumnIds={COLUMNS.map((column) => 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),
})),
};
})}
/>
) : (
<div className="flex overflow-x-auto pb-2">
{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 (
<div key={column.id} className="flex shrink-0">
<div style={{ width }}>
<KanbanColumn
title={column.title}
count={columnTasks.length}
icon={accent.icon}
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
>
{renderCards(column.id, columnTasks, true)}
</KanbanColumn>
</div>
{index < visibleColumns.length - 1 ? (
<div
className="group relative mx-0.5 flex items-center"
onPointerDown={handleProps.onPointerDown}
style={handleProps.style}
aria-label={handleProps['aria-label']}
>
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2">
<div className="flex min-w-max items-start pr-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 (
<div key={column.id} className="flex shrink-0">
<div style={{ width }}>
<KanbanColumn
title={column.title}
count={columnTasks.length}
icon={accent.icon}
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
bodyClassName="max-h-none overflow-visible"
>
{renderCards(column.id, columnTasks, true)}
</KanbanColumn>
</div>
) : null}
</div>
);
})}
{index < visibleColumns.length - 1 ? (
<div
className="group relative mx-0.5 flex items-center justify-center"
onPointerDown={handleProps.onPointerDown}
style={handleProps.style}
aria-label={handleProps['aria-label']}
>
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
</div>
) : null}
</div>
);
})}
</div>
</div>
)}
</>
</div>
);
if (enableTaskSorting) {

View file

@ -32,11 +32,7 @@ export const KanbanColumn = memo(function KanbanColumn({
}: KanbanColumnProps): React.JSX.Element {
return (
<section
className={cn(
'relative rounded-md border border-[var(--color-border)]',
className,
!bodyBg && 'bg-[var(--color-surface)]'
)}
className={cn('relative rounded-md', className, !bodyBg && 'bg-[var(--color-surface)]')}
style={bodyBg ? { backgroundColor: bodyBg } : undefined}
>
{count > 0 && (
@ -48,11 +44,7 @@ export const KanbanColumn = memo(function KanbanColumn({
</Badge>
)}
<header
className={cn(
'rounded-t-md border-b border-[var(--color-border)] px-3 py-2',
headerClassName,
headerDragClassName
)}
className={cn('rounded-t-md px-3 py-2', headerClassName, headerDragClassName)}
style={headerBg ? { backgroundColor: headerBg } : undefined}
>
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">

View file

@ -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<HTMLElement>): Reac
);
}
const KanbanTaskCardSkeleton = ({ height }: { height: number }): ReactElement => (
<div
className="relative shrink-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-white dark:bg-[var(--color-surface-raised)]"
style={{ height }}
>
<div className="bg-[var(--color-surface-overlay)]/30 absolute left-[3px] top-[4px] h-2 w-14 rounded" />
<div className="bg-[var(--color-surface-overlay)]/25 absolute right-[6px] top-[4px] h-5 w-16 rounded-full" />
<div className="flex h-full flex-col px-1.5 py-3">
<div className="pt-[11px]">
<div className="bg-[var(--color-surface-overlay)]/25 h-4 w-[84%] rounded" />
<div className="bg-[var(--color-surface-overlay)]/18 mt-2 h-4 w-[68%] rounded" />
</div>
<div className="mt-auto flex items-center justify-between gap-2">
<div className="flex gap-2">
<div className="bg-[var(--color-surface-overlay)]/18 size-6 rounded-full border border-[var(--color-border)]" />
<div className="bg-[var(--color-surface-overlay)]/18 size-6 rounded-full border border-[var(--color-border)]" />
</div>
<div className="flex gap-1.5">
<div className="bg-[var(--color-surface-overlay)]/12 size-6 rounded-full border border-[var(--color-border)]" />
<div className="bg-[var(--color-surface-overlay)]/12 size-6 rounded-full border border-[var(--color-border)]" />
</div>
</div>
</div>
</div>
);
const LoadingKanbanGridLayout = ({
columns,
visibleItems,
primaryColumnId,
onPrimaryColumnWidthChange,
className,
}: Readonly<LoadingKanbanGridLayoutProps>): 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<HTMLDivElement>(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 (
<div>
<div ref={containerRef} className={cn('min-w-0 max-w-full', className)}>
<div
className="grid gap-3"
style={{
@ -136,30 +220,54 @@ const LoadingKanbanGridLayout = ({
>
{loadingItems.map((item) => {
const column = columnMap.get(item.id as KanbanColumnId);
if (!column) {
return <div key={item.id} />;
}
const skeletonCards = column.skeletonCards ?? [];
const hasTasks = skeletonCards.length > 0;
const showAddButton = column.showAddButton === true;
return (
<section
<div
key={item.id}
className="min-h-[400px] animate-pulse rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]"
className="min-h-0"
style={{
gridColumn: `${item.x + 1} / span ${item.w}`,
gridRow: `${item.y + 1} / span ${item.h}`,
}}
>
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<div
className="h-5 rounded bg-[var(--color-surface-raised)]"
style={{ width: column ? 96 : 72 }}
/>
<div className="h-6 w-10 rounded-md bg-[var(--color-surface-raised)]" />
</header>
<div className="flex h-[calc(100%-41px)] flex-col gap-3 p-3">
<div className="bg-[var(--color-surface-raised)]/35 h-12 rounded-md border border-dashed border-[var(--color-border-emphasis)]" />
<div className="h-24 rounded-md bg-[var(--color-surface-raised)]" />
<div className="bg-[var(--color-surface-raised)]/80 h-20 rounded-md" />
<div className="bg-[var(--color-surface-raised)]/60 h-16 rounded-md" />
</div>
</section>
<KanbanColumn
title={column.title}
count={column.count}
icon={column.icon}
headerBg={column.headerBg}
bodyBg={column.bodyBg}
className="flex h-full min-h-0 animate-pulse flex-col"
headerClassName="shrink-0"
bodyClassName="min-h-0 max-h-none flex-1 overflow-hidden"
>
{hasTasks ? (
<>
{skeletonCards.map((card) => (
<KanbanTaskCardSkeleton key={card.key} height={card.height} />
))}
{showAddButton ? (
<div className="bg-[var(--color-surface-overlay)]/15 flex h-12 shrink-0 items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] px-3 text-xs text-[var(--color-text-muted)]">
Add task
</div>
) : null}
</>
) : showAddButton ? (
<div className="bg-[var(--color-surface-overlay)]/15 flex h-12 shrink-0 items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] px-3 text-xs text-[var(--color-text-muted)]">
Add task
</div>
) : (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
)}
</KanbanColumn>
</div>
);
})}
</div>
@ -171,11 +279,38 @@ const LoadedKanbanGridLayout = ({
columns,
visibleItems,
onPersistLayout,
primaryColumnId,
onPrimaryColumnWidthChange,
className,
}: Readonly<LoadedKanbanGridLayoutProps>): ReactElement => {
const columnMap = useMemo(() => new Map(columns.map((column) => [column.id, column])), [columns]);
const [renderLayout, setRenderLayout] = useState<Layout>(() =>
visibleItems.map(toReactGridLayoutItem)
);
const visibleLayout = useMemo(() => visibleItems.map(toReactGridLayoutItem), [visibleItems]);
const [renderLayout, setRenderLayout] = useState<Layout>(() => visibleLayout);
const containerRef = useRef<HTMLDivElement>(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 (
<div>
<div ref={containerRef} className={cn('min-w-0 max-w-full', className)}>
<WidthAwareGridLayout
className="kanban-grid-layout"
measureBeforeMount
layout={renderLayout}
cols={GRID_COLS}
rowHeight={GRID_ROW_HEIGHT}
@ -238,6 +390,9 @@ const LoadedKanbanGridLayout = ({
export const KanbanGridLayout = ({
columns,
allColumnIds,
primaryColumnId,
onPrimaryColumnWidthChange,
skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
}: KanbanGridLayoutProps): React.JSX.Element => {
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 <LoadingKanbanGridLayout columns={columns} visibleItems={visibleItems} />;
}
const showSkeletonOverlay = !showResolvedLayout || !isLoaded;
const gridKey = visibleItems.map((item) => item.id).join('|');
return (
<LoadedKanbanGridLayout
key={gridKey}
columns={columns}
visibleItems={visibleItems}
onPersistLayout={applyReactGridLayout}
/>
<div className="relative min-w-0 max-w-full">
<LoadedKanbanGridLayout
key={gridKey}
columns={columns}
visibleItems={visibleItems}
onPersistLayout={applyReactGridLayout}
primaryColumnId={primaryColumnId}
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
className={cn(
'transition-opacity duration-150',
showSkeletonOverlay ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
/>
{showSkeletonOverlay ? (
<LoadingKanbanGridLayout
columns={columns}
visibleItems={visibleItems}
primaryColumnId={primaryColumnId}
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
className="pointer-events-none absolute inset-0 z-10"
/>
) : null}
</div>
);
};
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. */

View file

@ -122,7 +122,7 @@ export const KanbanSearchInput = ({
);
return (
<div ref={containerRef} className="relative max-w-[240px]">
<div ref={containerRef} className="relative w-full max-w-full">
<Search
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
@ -134,7 +134,7 @@ export const KanbanSearchInput = ({
value={value}
onChange={(e) => 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 && (
<Tooltip>
@ -155,7 +155,7 @@ export const KanbanSearchInput = ({
{showDropdown && suggestions.length > 0 && (
<div
ref={listRef}
className="absolute left-0 top-full z-50 mt-1 max-h-[280px] w-[360px] overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-1 shadow-xl shadow-black/30"
className="absolute left-0 top-full z-50 mt-1 max-h-[280px] w-[360px] min-w-full max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-1 shadow-xl shadow-black/30"
>
<div className="flex items-center gap-1.5 px-3 py-1.5">
<Hash size={10} className="text-[var(--color-text-muted)]" />

View file

@ -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}

View file

@ -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 (
<div
@ -97,6 +100,7 @@ export const LeadModelRow = ({
variant="outline"
size="sm"
className="h-8 w-full justify-start gap-1 overflow-hidden text-left"
aria-label={modelButtonAriaLabel}
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
@ -104,7 +108,8 @@ export const LeadModelRow = ({
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
<span className="truncate">{modelButtonLabel}</span>
</Button>
</div>
</div>

View file

@ -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;

View file

@ -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;

View file

@ -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 = ({
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
<ProviderBrandLogo
providerId={effectiveProviderId}
className="size-3.5 shrink-0"
/>
<span className="truncate">{modelButtonLabel}</span>
</Button>
</span>
</TooltipTrigger>

View file

@ -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)

View file

@ -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<void>) | 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,
};
}

View file

@ -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

View file

@ -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;
}

View file

@ -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<TeamTaskStatus, { bg: string; text: string }> = {
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' },

View file

@ -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<string, MemberSpawnStatusEntry>
| Map<string, MemberSpawnStatusEntry>
| 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<TeamProvisioningProgress, 'state'> | 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<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
}): 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;
}

View file

@ -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.');
});

View file

@ -14,6 +14,16 @@ const storeState = {
{ name: 'jack', agentType: 'developer' },
] as Array<Record<string, unknown>>,
},
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<string, { members: Array<Record<string, unknown>> }>,
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<string, unknown>;
storeState.memberSpawnStatusesByTeam['northstar-core'] = {
alice: { status: 'waiting', launchState: 'starting' },
bob: { status: 'waiting', launchState: 'starting' },
jack: { status: 'waiting', launchState: 'starting' },
} as Record<string, unknown>;
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 = {

View file

@ -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)', () => {

View file

@ -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(

View file

@ -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');
});
});