diff --git a/.github/SECURITY.md b/.github/SECURITY.md index c23e4b37..579f1c17 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -34,10 +34,11 @@ Or with Docker Compose, uncomment `network_mode: "none"` in `docker/docker-compo ## IPC & Input Validation -- All IPC handlers validate inputs with strict path containment checks -- File reads are constrained to the project root and `~/.claude/` +- Electron IPC and standalone HTTP handlers validate IDs, paths, and payloads at the boundary +- Project editing and write operations are constrained to the selected project root +- Read-only discovery may access local Claude data under `~/.claude/` and app-owned state paths when needed - Path traversal attacks are blocked -- Sensitive credential paths are rejected +- Sensitive config and credential-like paths are rejected or treated as protected targets ## Supported Versions diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2ebeeaa5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Agent Navigation + +This file is a navigation layer for architecture and implementation guidance. + +Start here: +- Repo overview and commands: [README.md](README.md) +- Working instructions and project conventions: [CLAUDE.md](CLAUDE.md) +- Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md) + +For new features: +- Default home for medium and large features: `src/features//` +- Reference implementation: `src/features/recent-projects` +- Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md) + +Do not treat this file as a second source of truth. +Keep architecture rules centralized in [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md). diff --git a/CLAUDE.md b/CLAUDE.md index 858c0dcf..2d7f0988 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,21 @@ Use path aliases for imports: - `@preload/*` → `src/preload/*` ## Features Architecture -**All new features MUST be created in `src/renderer/features//`.** -See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns. +**All new medium and large features should follow the canonical slice standard in [`docs/FEATURE_ARCHITECTURE_STANDARD.md`](docs/FEATURE_ARCHITECTURE_STANDARD.md).** + +Default location: +- `src/features//` + +Reference implementation: +- `src/features/recent-projects` + +Feature-local guidance: +- `src/features/CLAUDE.md` + +Legacy note: +- `src/renderer/features/*` still exists for older renderer-only slices +- do not use `src/renderer/features/*` as the default for new cross-process features +- thin renderer-only slices may still stay local when they do not need `core/`, transport wiring, or multi-process boundaries ## Data Sources ~/.claude/projects/{encoded-path}/*.jsonl - Session files diff --git a/README.md b/README.md index 2f42dd3c..c3e70e24 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Settings

-

Claude Agent Teams UI

+

Agent Teams UI

You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee. @@ -23,7 +23,7 @@

- 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. + 100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.

demo @@ -91,6 +91,7 @@ No prerequisites - the app can detect supported runtimes/providers and guide set - [Installation](#installation) - [Table of contents](#table-of-contents) - [What is this](#what-is-this) +- [Developer architecture docs](#developer-architecture-docs) - [Comparison](#comparison) - [Quick start](#quick-start) - [FAQ](#faq) @@ -107,15 +108,14 @@ No prerequisites - the app can detect supported runtimes/providers and guide set 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 +- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions or API keys - **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 - **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own - **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 agent/runtime 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 (tools), actions 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 @@ -156,9 +156,18 @@ A local orchestration layer for AI agent teams across Claude and Codex. +## Developer architecture docs + +For feature architecture and implementation guidance: + +- Canonical standard - [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md) +- Repo working instructions - [CLAUDE.md](CLAUDE.md) +- Feature root guidance - [src/features/README.md](src/features/README.md) +- Reference implementation - `src/features/recent-projects` + ## Comparison -| Feature | Claude Agent Teams UI | Vibe Kanban | Aperant | Cursor | Claude Code CLI | +| Feature | Agent Teams UI | Vibe Kanban | Aperant | Cursor | Claude Code CLI | |---|---|---|---|---|---| | **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ | | **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) | @@ -296,7 +305,7 @@ pnpm dist # macOS + Windows + Linux - [ ] Planning mode to organize agent plans before execution - [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop -- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models +- [ ] Support more models/providers (including local) e.g OpenCode (with many providers) - [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config) - [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc. - [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them) @@ -307,6 +316,8 @@ pnpm dist # macOS + Windows + Linux - [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow - [ ] Custom kanban columns - [ ] Run terminal commands +- [ ] Monitor agents processes/stats +- [ ] Reusable agents with SOUL.md --- @@ -316,7 +327,7 @@ See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development guidelines. Pleas ## Security -IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](.github/SECURITY.md) for details. +IPC and standalone HTTP handlers validate IDs, paths, and payload shape at the boundary. Project editing and write operations are constrained to the selected project root, while read-only discovery also accesses local Claude data under `~/.claude/` and app-owned state paths when required. Path traversal and sensitive config/credential targets are blocked. See [SECURITY.md](.github/SECURITY.md) for details. ## License diff --git a/docs/FEATURE_ARCHITECTURE_STANDARD.md b/docs/FEATURE_ARCHITECTURE_STANDARD.md new file mode 100644 index 00000000..4154d02e --- /dev/null +++ b/docs/FEATURE_ARCHITECTURE_STANDARD.md @@ -0,0 +1,314 @@ +# Feature Architecture Standard + +**Status**: team standard +**Reference implementation**: `src/features/recent-projects` + +This document defines the default architecture for medium and large features in this repository. + +## Goals + +- keep business rules isolated from Electron-specific runtime details +- make features easier to scale, test, and review +- keep renderer code closer to browser and Tauri portability +- enforce architecture with tooling, not only with code review comments + +## Canonical Template + +```text +src/features// + contracts/ + core/ + domain/ + application/ + main/ + composition/ + adapters/ + input/ + output/ + infrastructure/ + preload/ + renderer/ +``` + +Use this template by default when a feature: + +- spans more than one process boundary +- introduces its own use case or business policy +- needs its own transport bridge or integration surface +- is expected to grow with new providers, sources, or presentation flows + +## Layer Responsibilities + +### `contracts/` + +Cross-process public API for the feature. + +Allowed content: + +- DTOs +- API fragment types +- IPC or route constants + +Not allowed: + +- store access +- Electron APIs +- business orchestration + +### `core/domain/` + +Pure business rules and invariants. + +Examples: + +- merge policies +- provider-agnostic models +- selection rules +- dedupe logic + +Not allowed: + +- infrastructure access +- framework access +- side effects + +### `core/application/` + +Use cases and ports. + +Examples: + +- orchestration flow +- output ports +- cache ports +- source ports +- response models + +Not allowed: + +- Electron, Fastify, React, Zustand, child processes + +### `main/composition/` + +Feature composition root in the main process. + +Responsibilities: + +- instantiate infrastructure +- wire adapters +- wire use cases +- expose a small facade to app shell entrypoints + +### `main/adapters/input/` + +Driving adapters for the main process. + +Examples: + +- IPC handlers +- HTTP route registration + +Responsibilities: + +- translate transport input into use case calls +- keep transport concerns out of use cases + +### `main/adapters/output/` + +Driven adapters that implement application ports. + +Examples: + +- presenters +- source adapters + +Responsibilities: + +- translate between external data and core models +- stay thin around infrastructure helpers + +### `main/infrastructure/` + +Concrete technical implementation details. + +Examples: + +- file system adapters +- JSON-RPC transport clients +- binary discovery +- cache implementation +- git identity helpers + +Responsibilities: + +- know about runtime, process, OS, or protocol details + +### `preload/` + +Thin transport bridge between renderer and main. + +Responsibilities: + +- expose a feature API fragment +- depend on `contracts/` + +Not allowed: + +- main composition code +- renderer logic + +### `renderer/` + +Feature presentation and interaction. + +Recommended structure: + +```text +renderer/ + index.ts + adapters/ + hooks/ + ui/ + utils/ +``` + +Responsibilities: + +- `ui/` renders +- `hooks/` orchestrate interaction and transport usage +- `adapters/` transform DTOs into view models +- `utils/` contain small pure renderer helpers + +## Import Rules + +### Public entrypoints only + +Outside the feature, import only: + +- `@features//contracts` +- `@features//main` +- `@features//preload` +- `@features//renderer` + +Do not deep-import feature internals from app shell or from other features. + +### Core isolation + +`core/domain` must not import: + +- `@main/*` +- `@renderer/*` +- `@preload/*` +- adapters +- infrastructure +- Electron APIs +- Fastify +- child process modules + +`core/application` must not import: + +- `main/*` +- `renderer/*` +- Electron APIs +- Fastify +- child process modules + +### UI isolation + +`renderer/ui` must not import: + +- `@renderer/api` +- `@renderer/store` +- `@main/*` +- Electron APIs + +Push transport and store access into feature hooks or adapters. + +## Browser and Tauri Friendly Guidance + +The default transport direction should be: + +`renderer -> feature contracts -> app api abstraction -> preload/http adapter` + +This keeps renderer code closer to: + +- browser mode through HTTP adapters +- a future Tauri bridge +- alternative shells with minimal feature rewrites + +To keep that path clean: + +- never call `window.electronAPI` directly inside feature UI or hooks +- go through shared renderer API adapters +- keep Electron-specific concerns in `main/` and `preload/` +- keep business rules in `core/` + +## When To Use The Full Slice + +Use the full template when a feature has: + +- its own business rules +- its own merge or filtering policy +- transport wiring +- more than one adapter +- a roadmap beyond a one-off screen tweak + +## When A Thin Slice Is Enough + +A smaller feature may skip `core/` and `preload/` when it is: + +- purely presentational +- only reshaping already-owned data +- not adding a new use case +- not adding a new transport boundary + +If the feature still owns meaningful pure semantics or projection rules, keep +`core/` and skip only the process layers you do not need. + +Example: +- `src/features/agent-graph` keeps `core/domain` and `renderer`, but does not add fake `main/` or `preload/` folders because the transport boundary lives elsewhere. +## Definition Of Done For A Reference Feature + +A feature is reference-quality when: + +- structure matches the canonical template +- core is side-effect free +- app shell imports only public entrypoints +- renderer UI is dumb and presentational +- at least the main domain and application rules are tested +- architecture is enforced by lint rules +- feature has a concise standard or plan doc if it introduces a new pattern + +## Recommended Test Coverage + +For medium and large features, cover at least: + +- domain policy tests +- application use case tests +- critical renderer interaction utilities +- one adapter-level mapping test + +## Recent Projects As The Reference + +`src/features/recent-projects` is the first slice that follows this standard end-to-end. + +Use it as the example for: + +- contracts ownership +- core/application separation +- composition-root wiring +- renderer dumb UI + hook orchestration +- browser-friendly transport direction +- feature-level lint guard rails + +## Agent Graph As The Thin-Slice Reference + +`src/features/agent-graph` is the thin-slice example for a renderer integration +feature built on top of a reusable package. + +Use it as the example for: + +- keeping pure graph semantics in `core/domain` +- exposing a renderer-only public entrypoint +- integrating `packages/agent-graph` without inventing fake process layers +- migrating legacy `src/renderer/features/*` code into the canonical feature root diff --git a/docs/research/codex-dashboard-recent-projects-plan.md b/docs/research/codex-dashboard-recent-projects-plan.md new file mode 100644 index 00000000..85f01393 --- /dev/null +++ b/docs/research/codex-dashboard-recent-projects-plan.md @@ -0,0 +1,1260 @@ +# Codex Dashboard Recent Projects Plan + +**Дата**: 2026-04-14 +**Статус**: Reference-quality architecture plan +**Goal**: сделать `Dashboard -> Recent Projects` эталонной feature по `SOLID`, `Clean Architecture`, `Ports/Adapters`, `DRY` +**Canonical standard**: этот документ фиксирует рекомендуемый shape для будущих feature в проекте + +## Executive Summary + +Выбранный вариант: + +`Full vertical slice in src/features with core separated from process adapters` +`🎯 9 🛡️ 10 🧠 8` +Примерно `700-1000` строк изменений + +Это решение фиксируется как **предпочтительный architectural template для будущих feature**, если feature: + +- затрагивает более одного process boundary +- содержит заметную бизнес-логику +- имеет отдельный use case, который хочется развивать независимо + +### Что это значит + +- создаём feature `src/features/recent-projects` +- внутри feature есть **свои слои**: + - `contracts` + - `core/domain` + - `core/application` + - `main/composition` + - `main/adapters` + - `main/infrastructure` + - `preload` + - `renderer` +- app shell только **собирает** feature, но не владеет её логикой + +### Главная корректировка относительно прошлого плана + +Прошлая версия уже была неплохой, но для "эталонной" фичи ей не хватало жёсткости в четырёх местах: + +1. use case был описан слишком близко к DTO, а не как application core +2. adapters и infrastructure были недостаточно разведены +3. не был явно сформулирован набор архитектурных запретов +4. inner circles были слишком близко привязаны к `main`, хотя это не main-specific business logic + +Этот документ исправляет именно это. + +--- + +## 1. Top 3 Architecture Options + +### 1. Full vertical slice + strict Clean Architecture inside the feature + +`🎯 9 🛡️ 10 🧠 8` +Примерно `700-1000` строк + +Идея: + +- feature lives in one place +- core/domain and core/application isolated from process details +- ports explicit +- adapters explicit +- process boundaries preserved inside the feature + +Почему это лучший вариант: + +- лучший long-term shape +- легко расширять новыми providers +- легче удерживать логику фичи в одном bounded context +- это реально можно показывать как reference implementation + +### 2. Vertical slice, но без жёсткого разделения `application / adapters / infrastructure` + +`🎯 7 🛡️ 7 🧠 6` +Примерно `500-750` строк + +Плюсы: + +- меньше файлов +- быстрее в реализации + +Минусы: + +- быстро поплывут ответственности +- adapters начнут смешиваться с use case +- через несколько итераций feature станет "папкой со всем подряд" + +### 3. Renderer feature + main service outside feature + +`🎯 8 🛡️ 8 🧠 6` +Примерно `450-700` строк + +Плюсы: + +- проще +- достаточно хорошо для одной задачи + +Минусы: + +- хуже ощущается как единая feature +- логика снова размазывается по repo + +### Final choice + +Берём **вариант 1**. + +--- + +## 2. Product Goal + +Когда пользователь открывает homepage, он должен увидеть **последние проекты, где недавно реально работал**, включая: + +- Claude activity +- native Codex activity +- merged карточки, если это один repo или folder + +### Acceptance criteria + +1. Claude-only пользователь не видит поведенческой регрессии. +2. Codex-only пользователь видит свои проекты на homepage. +3. Claude + Codex в одном repo не создают дубликатов. +4. Карточка показывает provider logos. +5. Если standalone `codex` отсутствует или `codex app-server` не стартует, homepage спокойно деградирует в Claude-only. +6. В `ssh` context локальные native Codex проекты не подмешиваются. +7. `DashboardView` остаётся экраном-компоновщиком, а не бизнес-слоем. + +--- + +## 3. Non-Goals For Phase 1 + +Не делаем: + +- native Codex sessions в sidebar +- native Codex session detail opening +- model badges +- file-based Codex session index +- persistent `codex app-server` +- live notifications from Codex +- global provider-agnostic session index +- injection Codex data into `repositoryGroups` + +--- + +## 4. Architecture Standards For This Feature + +## 4.1 Single Responsibility + +Каждый модуль меняется по одной причине: + +- `domain` - business rules recent projects +- `application` - orchestration use case +- `adapters` - translation in/out +- `infrastructure` - конкретные технологии и внешние системы +- `input adapters` - IPC/HTTP wiring +- `preload` - renderer bridge +- `renderer/ui` - rendering +- `renderer/hooks` - interaction orchestration + +## 4.2 Open / Closed + +Новый provider должен подключаться новым source adapter, а не переписыванием use case. + +## 4.3 Liskov + +Любой source adapter должен удовлетворять одному и тому же порту: + +- Claude source +- Codex source +- будущий Gemini source + +## 4.4 Interface Segregation + +Порты должны быть узкими: + +- `RecentProjectsSourcePort` +- `RecentProjectsCachePort` +- `ClockPort` +- `LoggerPort` +- `ListDashboardRecentProjectsOutputPort` + +Не должно быть толстых универсальных сервисов. + +## 4.5 Dependency Inversion + +Use case зависит только от портов. + +Use case **не знает** про: + +- `ipcMain` +- `Fastify` +- `ipcRenderer` +- `child_process` +- `electron` +- Zustand +- React + +## 4.6 DRY + +Нельзя дублировать: + +- merge rules +- provider presentation mapping +- navigation fallback flow +- task/team aggregation by associated paths +- Codex thread fetch policy + +## 4.7 Clean Architecture Rule + +Допустимое направление зависимостей: + +`main/adapters/input -> core/application -> core/domain` +`main/adapters/output -> core/application ports` +`main/infrastructure -> core/application ports` +`renderer/hooks -> renderer/adapters -> contracts` +`preload -> contracts` + +Недопустимо: + +- `core/domain -> core/application` +- `core/application -> main/infrastructure` +- `core/application -> main/adapters` +- `renderer/ui -> store/api` +- `shared/app-shell -> feature internals` + +--- + +## 5. Hard Architectural Corrections + +Это места, где нужно быть особенно аккуратным. + +### Correction 1 - Feature contracts are not the same thing as app shell contracts + +Ошибка, которую легко сделать: + +- положить DTO в feature +- а потом заставить весь `src/shared/types/api.ts` зависеть от feature internals как от обычного shared-layer + +Правильнее: + +- `feature contracts` живут внутри feature и описывают форму recent-projects +- `app shell` может **композировать** feature API fragment, но не должен владеть feature моделью + +Это тонкая, но важная разница. + +### Correction 2 - Use case must not return infrastructure-shaped data + +Use case работает с `core/application response model`, а не с raw app-server rows и не с renderer card model. + +### Correction 3 - UI must not own navigation policy + +`RecentProjectCard` не должен знать, как работает: + +- worktree match +- refresh repository groups +- add custom path +- synthetic fallback group + +Это responsibility interaction adapter/hook. + +### Correction 4 - `DashboardView` must become a composition root for the screen + +Он не должен: + +- ходить в API +- мержить данные +- решать navigation flow +- агрегировать task/team decorations + +--- + +## 6. Recommended Feature Structure + +```text +src/features/recent-projects/ + contracts/ + index.ts + dto.ts + channels.ts + api.ts + core/ + domain/ + RecentProjectCandidate.ts + RecentProjectAggregate.ts + ProviderId.ts + policies/ + mergeRecentProjectCandidates.ts + application/ + use-cases/ + ListDashboardRecentProjectsUseCase.ts + ports/ + ClockPort.ts + LoggerPort.ts + RecentProjectsCachePort.ts + ListDashboardRecentProjectsOutputPort.ts + RecentProjectsSourcePort.ts + models/ + ListDashboardRecentProjectsResponse.ts + main/ + index.ts + composition/ + createRecentProjectsFeature.ts + adapters/ + input/ + ipc/ + registerRecentProjectsIpc.ts + http/ + registerRecentProjectsHttp.ts + output/ + presenters/ + DashboardRecentProjectsPresenter.ts + sources/ + ClaudeRecentProjectsSourceAdapter.ts + CodexRecentProjectsSourceAdapter.ts + infrastructure/ + cache/ + InMemoryRecentProjectsCache.ts + identity/ + RecentProjectIdentityResolver.ts + codex/ + CodexBinaryResolver.ts + CodexAppServerClient.ts + JsonRpcStdioClient.ts + preload/ + index.ts + createRecentProjectsBridge.ts + renderer/ + index.ts + adapters/ + RecentProjectsSectionAdapter.ts + hooks/ + useRecentProjectsSection.ts + useOpenRecentProject.ts + ui/ + RecentProjectsSection.tsx + RecentProjectCard.tsx + utils/ + navigation.ts + projectDecorations.ts +``` + +### Why this structure is the cleanest + +- `contracts` - cross-process public contract +- `core/domain` - invariant business rules +- `core/application` - use case orchestration through ports +- `main/adapters/input` - driving adapters +- `main/adapters/output` - driven adapters +- `main/infrastructure` - concrete implementations +- `main/composition` - feature composition root +- `preload` - isolated renderer bridge +- `renderer` - feature presentation + +Это уже не просто "feature folder", а полноценный vertical slice. + +### Canonical template for future features + +Для будущих feature в проекте фиксируем такой шаблон как базовый: + +```text +src/features// + contracts/ + core/ + domain/ + application/ + main/ + composition/ + adapters/ + input/ + output/ + infrastructure/ + preload/ + renderer/ +``` + +Использовать этот шаблон по умолчанию **для feature среднего и большого размера**. + +Если feature маленькая и: + +- не имеет отдельного use case +- не вводит новый bridge/API +- не содержит сложной business logic + +то допускается упрощённый вариант без полного `core/main/preload` набора. + +### What is mandatory + +- `contracts/` +- `core/domain/` +- `core/application/` +- `main/composition/` +- хотя бы один из: + - `main/adapters/input/` + - `main/adapters/output/` +- `renderer/`, если у feature есть UI + +### What is optional + +- `preload/`, если feature не требует нового bridge/API +- `main/infrastructure/`, если feature не ходит во внешние runtime dependencies +- `renderer/adapters/`, если feature очень маленькая +- `renderer/hooks/`, если feature purely presentational + +### When `core/` is required + +`core/` обязателен, если feature: + +- содержит независимые business rules +- имеет merge/filter/decision policy +- имеет хотя бы один use case, который хочется тестировать изолированно +- потенциально будет расширяться новыми providers / sources / policies + +### When `core/` may be skipped + +`core/` можно не создавать, если feature: + +- purely presentational +- только прокидывает данные без трансформации правил +- не имеет собственного use case +- по сути является thin UI wrapper around existing app logic + +### What should not be used as the default standard + +- `main/domain/application/presentation` +- `preload/domain/application/presentation` +- `ui/domain/application/presentation` + +Причина: + +- это смешивает process axis и architecture axis +- это создаёт ложную симметрию +- это размывает единственный business core + +--- + +## 7. Dependency Matrix + +### Allowed imports + +#### `contracts` + +Can import: + +- nothing app-specific +- TS built-ins only + +#### `core/domain` + +Can import: + +- `contracts` if types are pure and stable +- other `core/domain` files + +Cannot import: + +- `core/application` +- `adapters` +- `infrastructure` +- `electron` +- `fastify` +- `@main/*` + +#### `core/application` + +Can import: + +- `core/domain` +- `core/application/ports` +- `contracts` + +Cannot import: + +- `ipcMain` +- `Fastify` +- `child_process` +- `@renderer/*` + +#### `main/adapters/input` + +Can import: + +- `core/application` +- `core/domain` +- `contracts` + +Cannot import: + +- renderer code + +#### `main/adapters/output` + +Can import: + +- `core/application` +- `core/domain` +- `contracts` +- `main/infrastructure` + +Cannot import: + +- renderer code + +#### `main/infrastructure` + +Can import: + +- `core/application/ports` +- `core/domain` +- `contracts` +- technology-specific libs + +#### `main/composition` + +Can import: + +- `core/application` +- `main/adapters/input` +- `main/adapters/output` +- `main/infrastructure` +- `contracts` +- `@main/*` + +#### `preload` + +Can import: + +- `contracts` +- feature preload helpers +- Electron preload APIs + +Cannot import: + +- `main/*` +- renderer code + +#### `renderer/ui` + +Can import: + +- renderer hooks +- renderer adapters +- simple feature-local UI utilities + +Cannot import: + +- store directly +- API directly +- main code + +#### `renderer/hooks` + +Can import: + +- `api` +- `useStore` +- feature contracts +- feature adapters/utils + +--- + +## 8. Ports And Adapters Design + +## 8.1 Domain Model + +### `RecentProjectCandidate` + +Это provider-agnostic внутренний объект, из которого потом строится response. + +```ts +export interface RecentProjectCandidate { + identity: string; + displayName: string; + primaryPath: string; + associatedPaths: string[]; + lastActivityAt: number; + providerIds: ProviderId[]; + sourceKind: 'claude' | 'codex'; + openTarget: + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; + branchName?: string; +} +``` + +### `RecentProjectAggregate` + +Это уже merged domain shape до presenter mapping. + +```ts +export interface RecentProjectAggregate { + identity: string; + displayName: string; + primaryPath: string; + associatedPaths: string[]; + lastActivityAt: number; + providerIds: ProviderId[]; + source: 'claude' | 'codex' | 'mixed'; + openTarget: + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; + branchName?: string; +} +``` + +### Merge policy + +`core/domain/policies/mergeRecentProjectCandidates.ts` + +Это чистая функция без side effects. + +Именно тут живёт истинная business rule: + +- dedupe by identity +- prefer `existing-worktree` +- merge provider ids +- max by recency +- unset conflicting branch + +--- + +## 8.2 Application Ports + +### `RecentProjectsSourcePort` + +```ts +export interface RecentProjectsSourcePort { + list(): Promise; +} +``` + +### `RecentProjectsCachePort` + +```ts +export interface RecentProjectsCachePort { + get(key: string): Promise; + set(key: string, value: T, ttlMs: number): Promise; +} +``` + +### `ClockPort` + +```ts +export interface ClockPort { + now(): number; +} +``` + +### `LoggerPort` + +```ts +export interface LoggerPort { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; +} +``` + +### `ListDashboardRecentProjectsOutputPort` + +```ts +export interface ListDashboardRecentProjectsOutputPort { + present(aggregates: RecentProjectAggregate[]): TViewModel; +} +``` + +Это важный момент. +Если хотим действительно clean use case, use case не должен знать финальный transport DTO напрямую. + +--- + +## 8.3 Application Use Case + +`core/application/use-cases/ListDashboardRecentProjectsUseCase.ts` + +```ts +export interface ListDashboardRecentProjectsDeps { + sources: RecentProjectsSourcePort[]; + cache: RecentProjectsCachePort; + output: ListDashboardRecentProjectsOutputPort; + clock: ClockPort; + logger: LoggerPort; +} + +export class ListDashboardRecentProjectsUseCase { + constructor(private readonly deps: ListDashboardRecentProjectsDeps) {} + + async execute(cacheKey: string): Promise { + const cached = await this.deps.cache.get(cacheKey); + if (cached) { + return cached; + } + + const batches = await Promise.all(this.deps.sources.map((s) => s.list())); + const aggregates = mergeRecentProjectCandidates(batches.flat()); + const viewModel = this.deps.output.present(aggregates); + + await this.deps.cache.set(cacheKey, viewModel, 10_000); + return viewModel; + } +} +``` + +Почему это лучше прошлого варианта: + +- use case orchestration не знает ни про renderer, ни про IPC DTO +- output model отдаётся через presenter port +- cache тоже внешний порт, а не private field in use case + +Это уже настоящий `ports/adapters` shape. + +--- + +## 8.4 Adapters + +### `DashboardRecentProjectsPresenter` + +Responsibility: + +- `RecentProjectAggregate[] -> DashboardRecentProject[]` + +Это output adapter, реализующий `ListDashboardRecentProjectsOutputPort`. + +### `ClaudeRecentProjectsSourceAdapter` + +Responsibility: + +- взять active Claude context +- получить grouped repos +- превратить их в `RecentProjectCandidate[]` + +Это driven/output adapter, реализующий `RecentProjectsSourcePort`. + +### `CodexRecentProjectsSourceAdapter` + +Responsibility: + +- если context не local -> `[]` +- если `codex` missing -> `[]` +- через `codex app-server` достать recent thread summaries +- применить identity resolver +- превратить это в `RecentProjectCandidate[]` + +Это тоже driven/output adapter, реализующий `RecentProjectsSourcePort`. + +### Why adapters are separate from infrastructure + +Потому что adapter переводит **между моделями и портами**, а infrastructure решает **как технически достать данные**. + +Пример: + +- `CodexAppServerClient` - infrastructure +- `CodexRecentProjectsSourceAdapter` - adapter + +Это разные причины для изменения. + +--- + +## 8.5 Infrastructure + +### `CodexBinaryResolver` + +Только определяет доступен ли standalone `codex`. + +### `CodexAppServerClient` + +Только транспорт к `codex app-server`. + +### `JsonRpcStdioClient` + +Только generic stdio JSON-RPC plumbing. + +### `RecentProjectIdentityResolver` + +Хотя он помогает бизнес-логике, по природе он infrastructure helper, потому что зависит на git/path environment semantics. + +### `InMemoryRecentProjectsCache` + +Concrete cache adapter. + +--- + +## 9. Contracts Layer + +Feature contracts должны содержать только: + +- публичные DTO +- API fragment interface +- channel constants + +### Recommended files + +```text +src/features/recent-projects/contracts/ + dto.ts + api.ts + channels.ts + index.ts +``` + +### DTO + +```ts +export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini'; + +export type DashboardRecentProjectOpenTarget = + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; + +export interface DashboardRecentProject { + id: string; + name: string; + primaryPath: string; + associatedPaths: string[]; + mostRecentActivity: number; + providerIds: DashboardProviderId[]; + source: 'claude' | 'codex' | 'mixed'; + openTarget: DashboardRecentProjectOpenTarget; + primaryBranch?: string; +} +``` + +### API fragment + +```ts +export interface RecentProjectsElectronApi { + getDashboardRecentProjects(): Promise; +} +``` + +### Channel constant + +```ts +export const GET_DASHBOARD_RECENT_PROJECTS = 'get-dashboard-recent-projects'; +``` + +### Important shell rule + +`src/shared/types/api.ts` не должен "владеть" этой feature. +Он может только **композировать** feature fragment: + +```ts +import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; + +export interface ElectronAPI extends RecentProjectsElectronApi { + // existing app methods... +} +``` + +Это намного чище, чем дублировать feature method shape внутри `shared/types/api.ts`. + +--- + +## 10. Public API, Input Adapters And Composition Root + +## 10.1 Feature Main Public API + +`src/features/recent-projects/main/index.ts` + +Должен экспортировать только: + +- use case factory/composer +- input adapter registration helpers +- necessary public ports if really needed + +Лучше не экспортировать наружу все внутренние классы поштучно без необходимости. + +### Better pattern + +```ts +export { createRecentProjectsFeature } from './composition/createRecentProjectsFeature'; +export { registerRecentProjectsIpc } from './adapters/input/ipc/registerRecentProjectsIpc'; +export { registerRecentProjectsHttp } from './adapters/input/http/registerRecentProjectsHttp'; +``` + +### Why factory is better than many exports + +Это уменьшает coupling composition root к внутреннему строению feature. + +## 10.2 Feature Composition + +Добавить: + +```text +src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +``` + +```ts +export function createRecentProjectsFeature(deps: { + getActiveContext: () => ServiceContext; + getLocalContext: () => ServiceContext | undefined; + logger: LoggerPort; +}) { + // instantiate infrastructure + // instantiate adapters + // instantiate presenter + // instantiate cache + // instantiate use case + // return entrypoint-facing surface +} +``` + +Именно это делает feature self-contained и масштабируемой. + +## 10.3 IPC input adapter + +`main/adapters/input/ipc/registerRecentProjectsIpc.ts` + +Он не должен знать, как строится Codex client, cache или merge policy. + +Он должен только: + +- принять feature facade +- зарегистрировать handler +- прокинуть `cacheKey` + +## 10.4 HTTP input adapter + +То же самое для Fastify route. + +## 10.5 Preload bridge + +`preload/createRecentProjectsBridge.ts` + +Bridge должен возвращать только feature API fragment: + +```ts +export function createRecentProjectsBridge(): RecentProjectsElectronApi { + return { + getDashboardRecentProjects: () => + ipcRenderer.invoke(GET_DASHBOARD_RECENT_PROJECTS), + }; +} +``` + +Это хороший пример thin interface adapter. + +--- + +## 11. Renderer Architecture + +### 11.1 Renderer public entrypoint + +`src/features/recent-projects/renderer/index.ts` + +```ts +export { RecentProjectsSection } from './ui/RecentProjectsSection'; +``` + +Внешний мир должен импортировать только это. + +### 11.2 `useRecentProjectsSection` + +Responsibility: + +- загрузить `DashboardRecentProject[]` +- взять decorations из store +- через adapter получить card models +- отдать state for UI + +### 11.3 `RecentProjectsSectionAdapter` + +Responsibility: + +- `DashboardRecentProject[] + task/team decorations -> RecentProjectCardModel[]` + +Он не должен: + +- выполнять fetch +- открывать проекты + +### 11.4 `useOpenRecentProject` + +Responsibility: + +- encapsulate navigation policy + +Это interaction adapter, а не UI helper. + +Сюда выносится: + +- `findMatchingWorktree` +- refresh repository groups +- `addCustomProjectPath` +- synthetic fallback group + +### 11.5 `RecentProjectCard` + +Responsibility: + +- чистая presentation + +Не импортирует: + +- `useStore` +- `api` +- navigation utils + +### 11.6 `RecentProjectsSection` + +Responsibility: + +- layout +- error/loading/empty states +- search filtering +- cards grid + +--- + +## 12. DRY Rules For This Feature + +### Rule 1 + +Одна merge policy в `domain/policies/mergeRecentProjectCandidates.ts` + +### Rule 2 + +Одна navigation policy в `renderer/hooks/useOpenRecentProject.ts` + +### Rule 3 + +Одна provider presentation mapping function в `renderer/utils/projectDecorations.ts` + +### Rule 4 + +Одна Codex fetch policy в `CodexRecentProjectsSourceAdapter` + +### Rule 5 + +Одна cache policy в `InMemoryRecentProjectsCache` + +--- + +## 13. Tech Decisions For Codex + +### Decision + +Для phase 1 использовать: + +- ephemeral `codex app-server` +- only `sourceKinds: ['vscode', 'cli']` +- include live + archived +- no file parsing + +### Why + +- этого достаточно для homepage use case +- не тянем слишком рискованную нативную storage-зависимость +- уменьшаем complexity + +### Timeouts + +- initialize: `3_000 ms` +- each list call: `3_000 ms` +- total Codex fetch: `8_000 ms` +- cache TTL: `10_000 ms` + +--- + +## 14. Guard Rails + +Если хотим, чтобы feature правда была эталонной, нужны guard rails. + +### Recommended rule set + +1. Outside feature import only public feature entrypoints: + - `@features/recent-projects/contracts` + - `@features/recent-projects/main` + - `@features/recent-projects/preload` + - `@features/recent-projects/renderer` + +2. No deep imports from one process subtree into another. + +3. `renderer/ui` components cannot import store or API directly. + +4. `core/application` cannot import `electron`, `fastify`, `child_process`. + +5. `core/domain` must remain side-effect free. + +### Optional but recommended later + +- add `no-restricted-imports` rules to enforce this automatically + +--- + +## 15. Implementation Sequence + +### Step 1 + +Create feature tree: + +- `src/features/recent-projects` + +### Step 2 + +Add alias support: + +- `tsconfig.json` +- `tsconfig.node.json` +- `electron.vite.config.ts` + +### Step 3 + +Create contracts: + +- DTO +- API fragment +- channel constant + +### Step 4 + +Create domain: + +- candidate +- aggregate +- provider id +- merge policy + +### Step 5 + +Create application layer: + +- use case +- ports +- response model + +### Step 6 + +Create output adapters: + +- Claude source adapter +- Codex source adapter +- presenter + +### Step 7 + +Create infrastructure: + +- cache +- identity resolver +- Codex binary/client/json-rpc + +### Step 8 + +Create feature composition root: + +- `createRecentProjectsFeature` + +### Step 9 + +Create driving input adapters: + +- IPC +- HTTP + +### Step 10 + +Create preload bridge + +### Step 11 + +Create renderer: + +- section adapter +- section hook +- open hook +- card UI +- section UI + +### Step 12 + +Integrate into app shell: + +- `src/main/index.ts` +- `src/main/ipc/handlers.ts` +- `src/main/http/index.ts` +- `src/preload/index.ts` +- `src/renderer/components/dashboard/DashboardView.tsx` + +### Step 13 + +Add tests and architecture review pass + +--- + +## 16. Test Strategy + +### Domain tests + +- merge policy +- branch conflict behaviour +- openTarget preference +- provider dedupe + +### Application tests + +- use case orchestrates sources + cache + presenter +- cache hit path +- cache miss path +- degraded Codex path still returns Claude result + +### Adapter tests + +- Claude source mapping +- Codex source mapping +- presenter mapping +- renderer section adapter mapping +- `useOpenRecentProject` navigation policy + +### Infrastructure tests + +- in-memory cache TTL +- Codex binary resolver success/failure +- app-server client request/timeout handling + +### Renderer tests + +- loading / empty / error states +- provider logos shown +- task/team decorations aggregated by `associatedPaths` + +### Manual checks + +1. local context without `codex` +2. local context with `codex` +3. mixed Claude + Codex repo +4. Codex-only non-git folder +5. ssh context + +--- + +## 17. Anti-Patterns To Avoid + +Не делать: + +- одну папку feature без внутренних слоёв +- use case, который напрямую знает про `CodexAppServerClient` +- presenter logic inside React components +- store/API imports inside `RecentProjectCard` +- дублирование navigation fallback +- прямой import feature internals из app shell +- подмешивание recent-projects logic обратно в `DashboardView` + +--- + +## 18. Final Recommendation + +Если эта feature должна быть эталоном, то лучший shape такой: + +- **feature-центричная структура** +- **строгий ports/adapters** +- **composition root внутри самой feature** +- **тонкий app shell** +- **тонкие input adapters и preload bridge** +- **чистый renderer UI без бизнес-логики** + +Итоговый выбранный подход: + +`Full vertical slice in src/features with core separated from process adapters` +`🎯 9 🛡️ 10 🧠 8` +Примерно `700-1000` строк + +Это дороже, чем прагматичный hybrid, но это уже действительно можно считать reference implementation по `SOLID`, `Clean Architecture`, `Ports/Adapters` и `DRY`. diff --git a/docs/research/tmux-hybrid-installer-plan.md b/docs/research/tmux-hybrid-installer-plan.md new file mode 100644 index 00000000..f3869558 --- /dev/null +++ b/docs/research/tmux-hybrid-installer-plan.md @@ -0,0 +1,2443 @@ +# План: Hybrid tmux Installer для Desktop App + +Дата: 2026-04-14 + +Рекомендуемый подход: `Hybrid installer` + +Оценка подхода: 🎯 9 🛡️ 8 🧠 6 + +Примерный объём первой качественной реализации: +- `macOS/Linux installer + UI + status/progress + manual fallback` - `900-1400` строк +- `Windows WSL wizard + richer status` - ещё `700-1200` строк +- `Windows runtime enablement через WSL tmux` - ещё `600-1200` строк + +Итого полноценный вариант, где Windows не просто умеет "установить", а реально получает пользу от `tmux` - примерно `2200-3800` строк. + +## 1. TL;DR + +Нужно сделать отдельный feature slice `tmux-installer`, где main-side orchestration layer по UX-паттерну похож на существующий `CliInstallerService`, но не копирует его буквально. + +Ключевая идея: +- `macOS` - автодетект `Homebrew`, fallback на `MacPorts`, иначе manual install +- `Linux` - автодетект native package manager, установка через `sudo` в PTY, честный step-based progress +- `Windows` - не притворяться native installer'ом, а сделать честный `WSL wizard` + проверку/re-check каждого шага + +Самый важный architectural gotcha: + +⚠️ Сейчас Windows-путь в приложении вообще не использует `tmux`, даже если пользователь сам его поставит в WSL. + +Это видно прямо в коде: +- `src/main/services/team/runtimeTeammateMode.ts` жёстко отключает process/tmux path на `win32` +- `src/main/ipc/tmux.ts` проверяет только host `tmux`, а не `wsl ... tmux` + +Следствие: +- сделать только installer на Windows недостаточно +- если хотим честно обещать "лучший опыт после установки", нужно добавить хотя бы базовую WSL-aware tmux detection +- если хотим реальный runtime gain на Windows, нужно отдельно включить WSL tmux path в runtime-решении teammate mode + +## 2. Цели + +### Продуктовые цели + +- Пользователь может установить `tmux` максимально удобно из UI +- UI честно показывает, что происходит: `checking`, `installing`, `verifying`, `completed`, `error` +- Если установка не удалась, пользователь не остаётся в тупике +- Manual fallback всегда есть и всегда OS-specific +- Никаких фейковых "100% success", если verification не прошло +- Ошибки формулируются человеческим языком, а не сырым stack trace + +### Технические цели + +- Не ломать текущий `TmuxStatusBanner` +- Сделать фичу по canonical standard из `docs/FEATURE_ARCHITECTURE_STANDARD.md` + - source of truth для новой фичи - `src/features/tmux-installer/` + - `src/renderer/components/dashboard/TmuxStatusBanner.tsx` должен стать thin wrapper или compatibility entrypoint, который импортирует только public renderer entrypoint фичи +- Переиспользовать существующие примитивы: + - `CliInstallerService` как референс по progress/event architecture + - `PtyTerminalService` как база для PTY lifecycle, но не полагаться на него "как есть" + - `TerminalModal` только как визуальный reference, не как источник истины для installer flow + - shell env resolution из существующих infra helpers +- Сделать state machine, которую можно тестировать unit/integration тестами +- Не делать platform-specific хаос прямо в React-компоненте + +### Не-цели v1 + +- Не собирать `tmux` из source автоматически +- Не устанавливать Homebrew автоматически +- Не поддерживать экспериментальные native Windows forks `tmux` по умолчанию +- Не делать "реальный процент" там, где package manager его не даёт +- Не пытаться тихо обходить OS security model + +## 3. Внешние факты, на которых строится план + +- tmux official install wiki перечисляет package manager команды для Linux/macOS и отдельно static binaries только для common Linux/macOS platforms: [tmux Installing wiki](https://github.com/tmux/tmux/wiki/Installing) +- Homebrew formula для `tmux` сейчас показывает `brew install tmux`, bottle support для macOS и Linux, stable `3.6a`: [Homebrew tmux](https://formulae.brew.sh/formula/tmux) +- MacPorts для `tmux` сейчас рекомендует `sudo port install tmux`, версия `3.6a`: [MacPorts tmux](https://ports.macports.org/port/tmux/) +- Microsoft для WSL пишет: открыть PowerShell в administrator mode, выполнить `wsl --install`, затем restart machine: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install) +- Microsoft также документирует `wsl --list --online`, `wsl --distribution ... --user ...`, `wsl --status`, `wsl --shutdown`: [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) +- Microsoft отдельно держит manual WSL install path для older Windows builds / server-like scenarios: [Manual installation steps for older versions of WSL](https://learn.microsoft.com/en-us/windows/wsl/install-manual) + +## 3.1 Самые рискованные места плана после перепроверки + +Это секции, где изначально была наименьшая уверенность и которые были усилены после дополнительной проверки. + +### ⚠️ Windows installer != Windows runtime support + +Самый критичный момент: +- `WSL wizard` сам по себе ещё не даёт реальной выгоды рантайму +- пока `runtimeTeammateMode` и `tmux status` не станут `WSL-aware`, Windows-часть будет только подготавливать окружение + +Именно поэтому в этом плане Windows runtime enablement выделен как обязательный follow-up, а не "nice to have". + +### ⚠️ Не надо по умолчанию ставить tmux внутри WSL как `root` + +Изначальная идея с: + +```powershell +wsl --distribution Ubuntu --user root -- sh -lc "apt-get install -y tmux" +``` + +слишком оптимистична для v1. + +Почему это риск: +- distro может быть ещё не bootstrap-ready +- поведение imported/custom distros менее предсказуемо +- мы можем получить "сработало технически, но пользователь не понимает, что произошло" + +Более надёжное решение: +- сначала довести distro до явного `bootstrapped` состояния +- затем запускать install flow через PTY внутри `wsl.exe` +- по умолчанию ставить как обычный Linux-user через `sudo`, а не скрыто через root +- `--user root` оставить только как optional future optimization, не как default v1 path + +### ⚠️ Linux immutable distros надо считать unsupported для v1 auto-install + +Если это: +- `rpm-ostree` +- Silverblue / Kinoite +- transactional-update / MicroOS + +то "обычный package manager install в хост" либо не тот путь, либо вообще misleading UX. + +Надёжнее: +- детектить immutable-host признаки +- сразу переводить пользователя в `manual-only guidance` +- не делать вид, что обычный `dnf install tmux` или `zypper install tmux` там надёжен + +### ⚠️ Не надо делать `apt-get update` всегда перед install + +Изначальный пример с: + +```bash +apt-get update && apt-get install -y tmux +``` + +практически рабочий, но хуже как default: +- медленнее +- больше сетевых точек отказа +- лишний шум в логах + +Надёжнее: +- сначала пробовать `install` +- если ошибка похожа на stale metadata / package not found due to old index, делать controlled retry с `update` + +### ⚠️ Windows elevation надо проектировать как отдельный тип шага, а не как "ещё один child process" + +Это место долго оставалось самым неформализованным. + +Проблема: +- WSL core install часто требует elevation +- типичный способ вызвать UAC это PowerShell `Start-Process -Verb RunAs` +- по официальному синтаксису `Start-Process` режим с `-Verb` относится к `Use Shell Execute`, а redirect-параметры живут в другом parameter set, поэтому надёжного "RunAs + live redirected stdout/stderr в тот же процесс" по умолчанию ожидать нельзя. Это мой вывод из PowerShell docs: [Start-Process](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process?view=powershell-7.6) + +Следствие: +- elevated Windows step нельзя моделировать как обычный `spawn child and stream logs` +- это должен быть отдельный class of step в installer state machine + +Надёжнее: +- либо делать `external elevated step + fresh probe afterwards` +- либо, если очень нужны diagnostics, запускать временный elevated helper script, который пишет итоговый JSON/status file в temp location, а app его потом читает +- но даже в варианте с status file финальное решение всё равно принимать только после fresh system probe + +### ⚠️ Нельзя строить Windows path вокруг "default distro" как единственного источника истины + +Это ещё один недооценённый риск. + +Проблема: +- пользователь может установить `tmux` в `Ubuntu` +- потом default distro поменяется на `Debian` или custom distro +- если приложение смотрит только на default distro, UX станет "tmux снова пропал", хотя на самом деле он установлен в целевом окружении + +Надёжнее: +- default distro использовать только как initial heuristic +- после явного wizard choice или успешного install persist'ить `preferred WSL distro` +- дальше status/runtime сначала пробуют persisted distro, а уже потом fallback на default + +### ⚠️ Windows WSL runtime должен явно выбрать binary model для `claude` + +Это один из самых недооценённых технических рисков. + +Проблема: +- текущий `ClaudeBinaryResolver` на Windows может вернуть разные executable shapes: `.exe`, `.cmd`, `.bat`, `.com` +- runtime через WSL tmux не может бездумно считать, что любой найденный host binary одинаково пригоден +- `.cmd` / shell-wrapper path особенно рискованны из-за quoting, shell fallback и cleanup semantics + +Дополнительный факт из Microsoft docs: +- Windows tools from WSL must include executable extension +- batch scripts are not direct executables there and need `cmd.exe /C ...` +Источник: [Working across Windows and Linux file systems](https://learn.microsoft.com/en-us/windows/wsl/filesystems) + +Практический вывод для v1 Windows runtime follow-up: +- если идём через WSL tmux + Windows interop, prefer native Windows `.exe` binary +- не считать `.cmd`/`.bat` автоматически runtime-ready без отдельного validation path +- Linux binary inside WSL - это отдельная модель с другими config/auth consequences, её нельзя смешивать с host-binary path без явного решения + +## 3.2 Дополнительные design constraints после IOF + +- Не использовать `pkexec` в v1 как основной Linux privilege path + - слишком много variability по desktop environment, polkit agent, headless режимам + - для нашего desktop app более предсказуемый путь это `PTY + sudo` +- Не строить installer на существующем `PtyTerminalService`/`TerminalModal` без доработки + - текущий `PtyTerminalService` стримит output только в renderer и не даёт main-side control surface + - текущий `TerminalModal` сам спавнит процесс и потому не подходит как source of truth для service-owned installer state +- Не показывать raw terminal output без ограничения размера + - нужен ring buffer + - нужен redaction policy для чувствительных строк +- Не запускать одновременно `auto-install` и `manual terminal` для одной и той же установки +- Не собирать install команды строковой конкатенацией + - в плане должны фигурировать `command + args + env + requiresPty` + - shell string допустим только для заранее заданного inner `sh -lc`, собранного из наших шаблонов, а не из пользовательского ввода +- Для Windows WSL core install не обещать live stdout/stderr как обязательную возможность + - elevated child process может открываться во внешнем окне/UAC flow + - progress для этого шага должен быть step-based, а не "мы всегда покажем все логи" +- Для Windows elevated steps нужен отдельный execution contract + - `pending_external_elevation` + - `waiting_for_external_step` + - `external_step_finished` + - `external_step_failed` + - это не то же самое, что PTY-backed install на Linux/WSL userland +- Не требовать WSL 2 там, где `tmux` уже реально работает в WSL 1 + - для продукта важнее usable tmux path, чем конкретная WSL version label +- Не оставлять в `TmuxStatus` двусмысленное поле `available` + - после добавления WSL-aware path нужен явный раздел `host` / `wsl` / `effective` +- Все platform-specific решения должны жить в main-process service layer, не в React + +## 4. Текущее состояние кодовой базы + +### Feature-стандарт, который надо учитывать + +- authoritative document для этой задачи - `docs/FEATURE_ARCHITECTURE_STANDARD.md` +- он задаёт canonical template для medium/large feature: + - `src/features//contracts` + - `src/features//core/domain` + - `src/features//core/application` + - `src/features//main/composition` + - `src/features//main/adapters/input` + - `src/features//main/adapters/output` + - `src/features//main/infrastructure` + - `src/features//preload` + - `src/features//renderer` +- эта задача точно подпадает под full slice, потому что: + - пересекает больше одной process boundary + - вводит свой transport bridge + - вводит свой use case и policy logic + - имеет main/preload/renderer orchestration +- `src/renderer/features/CLAUDE.md` можно использовать только как локальную подсказку для внутренних renderer-паттернов, но не как главный стандарт этой фичи +- structural reference implementation для этой фичи - `src/features/recent-projects` + - public entrypoints + - composition-root wiring + - preload bridge pattern + - renderer dumb UI + hook orchestration + +### Что уже есть + +- `src/main/ipc/tmux.ts` + - простой `getStatus()` + - cache TTL + - probe через `tmux -V` +- `src/shared/types/tmux.ts` + - минимальный `TmuxStatus` +- `src/renderer/components/dashboard/TmuxStatusBanner.tsx` + - баннер на дашборде + - OS-specific manual commands +- `src/main/services/infrastructure/CliInstallerService.ts` + - хороший референс по installer progress events + - `setMainWindow()` + - `sendProgress()` + - `checking/downloading/verifying/installing/completed/error` +- `src/main/services/infrastructure/PtyTerminalService.ts` + - PTY для interactive terminal workflows +- `src/renderer/components/terminal/TerminalModal.tsx` + - уже есть UI для живого терминала и status footer +- `src/main/ipc/config.ts` + - уже есть полезные WSL helpers: + - candidate resolution для `wsl.exe` + - UTF-16-aware decode WSL output +- `src/main/utils/pathDecoder.ts` + - уже есть path translation utility для WSL mount paths + +### Где уже есть ограничения + +- `src/main/services/team/runtimeTeammateMode.ts` + - на `win32` сейчас всегда возвращает `forceProcessTeammates: false` +- `src/main/services/team/TeamProvisioningService.ts` + - Windows-пропуски по `ps`-based live process detection + - `kill-pane` работает только через host `tmux` +- `src/main/services/infrastructure/PtyTerminalService.ts` + - сейчас умеет только `spawn/write/resize/kill` + - data/exit события уходят напрямую в renderer, но не в installer service + - сам `node-pty` там optional native addon, так что installer не должен предполагать его наличие без capability check +- `src/renderer/components/terminal/TerminalModal.tsx` + - это self-managed terminal UI, который сам запускает PTY + - attach к уже идущему installer session сейчас не поддерживается + +### Что можно переиспользовать из `agent_teams_orchestrator` + +- package manager resolver: + - `src/utils/nativeInstaller/packageManagers.ts` +- WSL-aware tmux execution: + - `src/utils/tmuxSocket.ts` + +Это не код "скопировать как есть", а скорее reference implementation и source of ideas. + +## 5. Главный продуктовый вывод + +### Что можно обещать пользователю честно + +#### macOS / Linux + +Можно обещать: +- установка из UI +- понятный progress/status +- проверка результата +- fallback на manual install + +#### Windows + +Можно обещать: +- удобный guided WSL setup +- честный status по каждому шагу +- понятный "что делать дальше" + +Нельзя честно обещать: +- silent one-click install без admin/reboot/setup user +- что это сработает на каждом корпоративном ноуте без вмешательства IT + +## 6. Рекомендуемая архитектура + +### 6.1 Новые сущности внутри feature slice + +Добавить: + +- `src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts` +- `src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts` +- `src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts` +- `src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts` +- `src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts` +- `src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts` +- `src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts` +- `src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts` +- `src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts` +- `src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts` +- `src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts` +- `src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts` +- `src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts` +- `src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts` +- `src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts` +- `src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPathBridge.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts` + +### 6.2 Canonical feature slice по стандарту + +Для `tmux-installer` нужен full feature slice, а не renderer-local feature. + +Рекомендуемая структура: + +```text +src/features/tmux-installer/ + contracts/ + api.ts + channels.ts + dto.ts + index.ts + core/ + domain/ + models/ + policies/ + application/ + models/ + ports/ + use-cases/ + main/ + index.ts + composition/ + createTmuxInstallerFeature.ts + adapters/ + input/ + ipc/ + registerTmuxInstallerIpc.ts + output/ + presenters/ + sources/ + runtime/ + infrastructure/ + installer/ + platform/ + wsl/ + preload/ + createTmuxInstallerBridge.ts + index.ts + renderer/ + index.ts + adapters/ + hooks/ + ui/ + utils/ +``` + +Почему именно full slice: +- feature пересекает `main -> preload -> renderer` +- у feature есть собственные transport contracts +- у feature есть собственная orchestration logic и policy layer +- у feature есть platform-specific runtime/infrastructure детали, которые нельзя размазывать по app shell + +### 6.2.1 Slice responsibilities + +`contracts/` +- DTO +- API fragment types +- IPC channel constants +- без store access, Electron-specific wiring и business orchestration + +`core/domain/` +- чистые business rules +- capability classification +- error normalization policy +- manual hint selection rules +- completion / retry / fallback invariants +- без Electron, child_process, PTY, package manager calls + +`core/application/` +- use cases +- source/output/cache ports +- response models +- orchestration contracts +- без Electron, React, Zustand, child process modules + +`main/composition/` +- composition root feature +- wiring use cases, adapters, infrastructure +- небольшой facade для app shell registration + +`main/adapters/input/` +- IPC handlers +- перевод transport input в use case calls + +`main/adapters/output/` +- presenters +- adapters для source/cache/runtime ports +- тонкий слой между infra и core/application + +`main/infrastructure/` +- `PtyTerminalService` integration +- OS/package manager specifics +- WSL detection +- elevated step runner +- binary probing +- path bridge + +`preload/` +- thin bridge +- зависит от `contracts/` +- без composition logic + +`renderer/` +- dumb UI +- hooks orchestration +- adapters DTO -> view model +- небольшие pure utils + +### 6.2.2 Renderer sub-structure внутри canonical slice + +Внутри `src/features/tmux-installer/renderer/`: + +```text +renderer/ + index.ts + adapters/ + TmuxInstallerBannerAdapter.ts + hooks/ + useTmuxInstallerBanner.ts + ui/ + TmuxInstallerBannerView.tsx + utils/ + formatTmuxInstallerText.ts +``` + +Правила: +- `renderer/ui` не импортирует `@renderer/api`, `@renderer/store`, `@main/*`, Electron APIs +- hooks не ходят в `window.electronAPI` напрямую +- transport usage идёт через shared renderer API abstraction +- `TmuxStatusBanner.tsx` остаётся compatibility wrapper и импортирует только `@features/tmux-installer/renderer` + +### 6.2.3 Жёсткие правила соответствия standard doc + +Обязательные правила: +- app shell и другие фичи импортируют только: + - `@features/tmux-installer/contracts` + - `@features/tmux-installer/main` + - `@features/tmux-installer/preload` + - `@features/tmux-installer/renderer` +- не делать deep-import feature internals снаружи +- `core/domain` side-effect free +- `core/application` не знает про Electron/React/Zustand/child processes +- `renderer/ui` dumb and presentational +- transport bridge тонкий и живёт в `preload/` +- architecture ориентируется на browser/Tauri-friendly direction + +Public entrypoints: + +```ts +// src/features/tmux-installer/contracts/index.ts +export * from './api'; +export * from './channels'; +export * from './dto'; + +// src/features/tmux-installer/main/index.ts +export { createTmuxInstallerFeature } from './composition/createTmuxInstallerFeature'; +export { registerTmuxInstallerIpc } from './adapters/input/ipc/registerTmuxInstallerIpc'; + +// src/features/tmux-installer/renderer/index.ts +export { TmuxInstallerBannerView } from './ui/TmuxInstallerBannerView'; + +// src/features/tmux-installer/preload/index.ts +export { createTmuxInstallerBridge } from './createTmuxInstallerBridge'; +``` + +Чего не делать: + +```ts +// не импортировать снаружи +import { TmuxInstallerBannerAdapter } from '@features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter'; +import { InstallTmuxUseCase } from '@features/tmux-installer/core/application/use-cases/InstallTmuxUseCase'; +``` + +App-shell shim rules: +- если трогаем `src/main/ipc/tmux.ts`, он должен остаться только registration/compatibility shim +- если трогаем `src/preload/index.ts`, он должен только подключать `@features/tmux-installer/preload` +- никакой business logic не должна жить в этих shared shell файлах + +### 6.2.4 Renderer error model по standard + +Внутри feature нужен typed error layer, а не просто `string | null` everywhere. + +Рекомендуемо: + +```ts +export class TmuxInstallerFeatureError extends Error { + constructor( + readonly code: + | 'ADAPTER_ERROR' + | 'SNAPSHOT_ERROR' + | 'PROGRESS_STREAM_ERROR' + | 'INVALID_STATUS' + | 'INVALID_PROGRESS', + message: string, + readonly cause?: unknown + ) { + super(message); + this.name = 'TmuxInstallerFeatureError'; + } +} +``` + +Где: +- adapter ловит IPC/external shape проблемы и заворачивает их в typed error +- domain service мапит их в user-facing banner state +- UI не работает напрямую с exception shapes + +### 6.3 IPC и contracts + +Расширить `tmux` API: + +```ts +export interface TmuxAPI { + getStatus: () => Promise; + install: () => Promise; + invalidateStatus: () => Promise; + cancelInstall: () => Promise; + onProgress: (cb: (event: unknown, data: TmuxInstallerProgress) => void) => () => void; +} +``` + +Новые IPC channels: + +```ts +export const TMUX_GET_STATUS = 'tmux:getStatus'; +export const TMUX_INSTALL = 'tmux:install'; +export const TMUX_INVALIDATE_STATUS = 'tmux:invalidateStatus'; +export const TMUX_CANCEL_INSTALL = 'tmux:cancelInstall'; +export const TMUX_INSTALLER_PROGRESS = 'tmux:progress'; +``` + +⚠️ Для feature-based renderer architecture этого недостаточно. Нужен ещё snapshot endpoint, чтобы feature мог восстановиться после remount/reload и не зависеть от того, был ли progress event пойман вживую. + +Добавить: + +```ts +export const TMUX_GET_INSTALLER_SNAPSHOT = 'tmux:getInstallerSnapshot'; + +export interface TmuxAPI { + getInstallerSnapshot: () => Promise; +} +``` + +Где: +- main process держит актуальный installer state как source of truth +- renderer slice при mount сначала делает `getStatus()` + `getInstallerSnapshot()` +- затем подписывается на live progress + +Это должно жить в `src/features/tmux-installer/contracts/`, а не в случайных shared renderer types. + +### 6.4 Shared types + +Рекомендуемое расширение feature contracts/dto в `src/features/tmux-installer/contracts/`: + +```ts +export type TmuxInstallStrategy = + | 'homebrew' + | 'macports' + | 'apt' + | 'dnf' + | 'yum' + | 'zypper' + | 'pacman' + | 'wsl' + | 'manual' + | 'unknown'; + +export type TmuxInstallerPhase = + | 'idle' + | 'checking' + | 'preparing' + | 'requesting_privileges' + | 'pending_external_elevation' + | 'waiting_for_external_step' + | 'installing' + | 'verifying' + | 'needs_restart' + | 'needs_manual_step' + | 'completed' + | 'error' + | 'cancelled'; + +export interface TmuxInstallHint { + title: string; + description: string; + command?: string; + url?: string; +} + +export interface TmuxAutoInstallCapability { + supported: boolean; + strategy: TmuxInstallStrategy; + packageManagerLabel?: string | null; + requiresTerminalInput: boolean; + requiresAdmin: boolean; + requiresRestart: boolean; + mayOpenExternalWindow?: boolean; + reasonIfUnsupported?: string | null; + manualHints: TmuxInstallHint[]; +} + +export interface TmuxWslStatus { + wslInstalled: boolean; + rebootRequired: boolean; + distroName: string | null; + distroVersion: 1 | 2 | null; + distroBootstrapped: boolean; + innerPackageManager: TmuxInstallStrategy | null; + tmuxAvailableInsideWsl: boolean; + tmuxVersion: string | null; + tmuxBinaryPath?: string | null; + statusDetail?: string | null; +} + +export interface TmuxWslPreference { + preferredDistroName: string | null; + source: 'persisted' | 'default' | 'manual' | null; +} + +export interface TmuxBinaryProbe { + available: boolean; + version: string | null; + binaryPath: string | null; + error: string | null; +} + +export interface TmuxEffectiveAvailability { + available: boolean; + location: 'host' | 'wsl' | null; + version: string | null; + binaryPath: string | null; + runtimeReady: boolean; + detail?: string | null; +} + +export interface TmuxStatus { + platform: TmuxPlatform; + nativeSupported: boolean; + checkedAt: string; + host: TmuxBinaryProbe; + effective: TmuxEffectiveAvailability; + error: string | null; + autoInstall: TmuxAutoInstallCapability; + wsl?: TmuxWslStatus | null; + wslPreference?: TmuxWslPreference | null; +} + +export interface TmuxInstallerProgress { + type: TmuxInstallerPhase | 'status'; + detail?: string; + percent?: number; + rawChunk?: string; + error?: string; + status?: TmuxStatus; + nextManualHint?: TmuxInstallHint | null; + externalStepLabel?: string; + canRetryNow?: boolean; +} +``` + +Смысл полей: +- `host` - что доступно нативно в текущей ОС host +- `wsl` - что доступно внутри WSL на Windows +- `effective` - какой path приложение реально может использовать +- `effective.runtimeReady` важнее простого "binary found", потому что именно он отвечает на вопрос "persistent teammate path действительно можно включать" +- `wslPreference` - какой distro приложение считает целевым для tmux path и почему + +## 7. UX и state machine + +### 7.1 Принципы UX + +- Не писать "сломано", если app просто работает без `tmux` +- Не скрывать platform-specific ограничения +- Не показывать фейковый `%`, если это невозможный процент +- Всегда давать следующий шаг +- Ошибка должна отвечать на 3 вопроса: + - что не получилось + - почему это могло случиться + - что делать дальше + +### 7.2 Основные UI состояния + +- `Not installed, can auto-install` + - CTA: `Install tmux` +- `Installing` + - шаги + - status detail + - optional raw logs + - если `mayOpenExternalWindow === true`, явно предупреждать про отдельное admin/system window +- `Waiting for external admin step` + - не показывать пустой терминал + - показывать понятный текст, что Windows мог открыть отдельное elevated окно + - CTA: `Re-check` +- `Needs manual step` + - CTA: `Open guide` + - CTA: `Retry` +- `Needs restart` + - CTA: `Re-check after restart` +- `Completed` + - CTA: `Re-check` +- `Error` + - human-readable error + - retry + - manual fallback + +### 7.3 Что показывать вместо fake percent + +Для package managers использовать не byte progress, а step progress: + +```ts +const STEP_WEIGHTS = { + checking: 10, + preparing: 20, + requesting_privileges: 30, + installing: 70, + verifying: 90, + completed: 100, +}; +``` + +То есть progress bar остаётся, но он отражает phase progression, а не сеть. + +Это честно и UX-wise полезно. + +## 8. Платформенная матрица + +## 8.1 macOS + +### Стратегия + +Порядок: +1. Если `tmux` уже есть - успех, install не нужен +2. Если найден `brew` - использовать Homebrew +3. Иначе если найден `port` - использовать MacPorts +4. Иначе manual fallback + +### Почему так + +- `brew install tmux` - лучший UX путь для macOS +- MacPorts useful fallback, но более niche +- автоматическая установка Homebrew сама по себе слишком тяжёлая и рискованная для v1 + +### Команды + +#### Homebrew + +```bash +brew install tmux +``` + +#### MacPorts + +```bash +sudo port install tmux +``` + +### Реализация + +- использовать shell-aware PATH resolution +- проверять `brew` не только в текущем `PATH`, но и в common prefixes: + - `/opt/homebrew/bin/brew` + - `/usr/local/bin/brew` +- `brew install tmux` можно запускать как обычный child process со streaming stdout/stderr +- не вводить `HOMEBREW_NO_AUTO_UPDATE=1` как default optimisation без отдельной валидации + - это может ускорить UX, но также меняет expected brew behavior + - если захотим это делать, лучше отдельным tiny spike и только с fallback probe после install +- `port install tmux` запускать через PTY, потому что возможен пароль +- если `brew` install вдруг начинает требовать interactive input или ведёт себя нестабильно через pipes, разрешён fallback на PTY и для `brew` + +### Edge cases + +- GUI app стартанул не из shell, `brew` не в PATH +- на Intel/macOS старый prefix +- `brew` есть, но formula tap сломан +- bottle download не удался, formula уходит в source build +- `xcode-select` / CLT не установлены +- MacPorts есть, но пользователь отменил `sudo` +- и `brew`, и `port` есть одновременно +- `brew` установлен, но prefix permissions повреждены +- приложение запущено без полного login-shell PATH, но `brew` реально установлен + +### Решение по приоритету + +Если есть и `brew`, и `port`: +- по умолчанию брать `brew` +- в detail UI можно написать `Using Homebrew (preferred)` + +## 8.2 Linux + +### Стратегия + +Порядок: +1. Если `tmux` уже есть - успех +2. Разобрать `/etc/os-release` +3. Определить distro family +4. Подтвердить наличие package manager binary +5. Сформировать install command +6. Выполнить через PTY + +### Почему PTY, а не `execFile` + +- `sudo` часто требует TTY +- пользователю может понадобиться ввести пароль +- package manager output полезен как live log +- но PTY должен быть owned main-process installer service, а не renderer modal + +### Поддерживаемые менеджеры v1 + +- Debian / Ubuntu -> `apt-get` +- Fedora / RHEL -> `dnf` +- older RHEL / CentOS -> `yum` +- openSUSE -> `zypper` +- Arch -> `pacman` + +### Явно unsupported для auto-install v1 + +- immutable rpm-ostree hosts +- transactional-update based hosts +- нестандартные embedded/container systems без нормального package DB + +Для них: +- auto-install capability = `supported: false` +- только manual guidance +- reason text должен быть явным, не generic + +### Команды + +#### Debian / Ubuntu + +```bash +sudo apt-get install -y tmux +``` + +#### Fedora / RHEL + +```bash +sudo dnf install -y tmux +``` + +#### old RHEL / CentOS + +```bash +sudo yum install -y tmux +``` + +#### openSUSE + +```bash +sudo zypper --non-interactive install tmux +``` + +#### Arch + +```bash +sudo pacman -S --needed --noconfirm tmux +``` + +### Почему именно такие флаги + +- `apt-get -y` + - Debian manpage: `-y, --yes, --assume-yes` делает install non-interactive и abort'ит на unsafe conditions вроде unauthenticated packages: [apt-get(8)](https://manpages.debian.org/bookworm/apt/apt-get.8.en.html) +- `dnf -y` + - DNF command reference: `-y, --assumeyes` автоматически отвечает yes на вопросы: [DNF Command Reference](https://dnf.readthedocs.io/en/latest/command_ref.html) +- `zypper --non-interactive install` + - SUSE docs явно рекомендуют `--non-interactive` до команды `install` для scripted usage: [SUSE zypper docs](https://documentation.suse.com/sles/15-SP5/html/SLES-all/cha-sw-cl.html) +- `pacman --noconfirm` + - Arch manual: bypasses confirmation prompts и предназначен для scripted usage; `--needed` не переустанавливает уже актуальный target: [pacman(8)](https://man.archlinux.org/man/pacman.8.en) + +### Почему `apt-get`, а не `apt` + +Официальная tmux wiki в user-facing тексте показывает `apt install tmux`, но для scripted/background workflow стабильнее использовать `apt-get`. + +Это design inference, а не quote from source. + +### Почему не `pkexec` + +Хотя `pkexec` теоретически даёт более desktop-native privilege prompt, в v1 он хуже по надёжности: +- зависит от polkit integration в системе +- отличается по поведению между DE/WM +- в некоторых средах отсутствует или ведёт себя нестабильно + +Поэтому default path: +- `PTY + sudo` + +### Почему не делаем `apt-get update` always-on + +Лучший default flow: + +```bash +sudo apt-get install -y tmux +``` + +Fallback retry only if needed: + +```bash +sudo apt-get update && sudo apt-get install -y tmux +``` + +Причина: +- меньше network surface area +- быстрее happy-path +- проще читать логи + +### Linux edge cases + +- пользователь уже root -> не добавлять `sudo` +- `sudo` не установлен +- пользователь не в sudoers +- package manager lock: + - `apt` lock + - `pacman` lock +- repository metadata stale +- offline network +- unsupported distro family +- host/container environment без нормального package database +- `tmux` установился, но verification через `tmux -V` всё равно не проходит из-за PATH/env +- immutable distro, где host package install не должен быть auto path +- `sudo` требует TTY password prompt, но пользователь закрывает modal +- `pacman` keyring / mirror init issues +- `dnf` metadata or repo config corruption +- `zypper` registration/repo state issues на SUSE +- `yum` / enterprise repo family, где `tmux` отсутствует в подключённых repo +- `zypper` interactive repo/license conditions, которые не должны auto-accept'иться молча + +### Ошибки, которые нужно распознавать отдельно + +- permission denied / authentication failure +- package manager locked +- package not found +- network / mirror resolution failed +- cancelled by user +- immutable host unsupported +- package manager present, but repository configuration invalid +- repository missing required channel / EPEL-like repo not enabled + +## 8.3 Windows + +### Главная продуктовая позиция + +Windows в v1 не должен притворяться нативной `tmux` платформой. + +Путь: +- `WSL wizard` +- затем установка `tmux` внутри WSL +- затем re-check + +### Важный architectural caveat + +⚠️ Пока runtime Windows не станет WSL-aware, этот wizard даст только "prepared environment", но не реальный runtime win. + +Поэтому план на Windows нужно делить на два слоя: + +#### Layer A + +Installer / wizard / detection / guidance + +#### Layer B + +Runtime enablement: +- WSL-aware `tmux` detection +- WSL-aware teammate mode decision +- WSL-aware pane/session ops + +### Рекомендуемый flow + +#### Шаг 1. Проверка WSL core и distro state + +Пробуем: + +```powershell +wsl --status +``` + +Если WSL не установлен: +- показываем CTA `Install WSL` +- detail: нужен admin и, возможно, restart + +Если `wsl --status` itself unsupported on older build: +- не классифицировать это как обычный "WSL missing" +- это отдельный capability signal +- дальше либо fallback probe через `wsl --list --verbose`, либо сразу manual Microsoft guidance для older Windows path + +Если WSL установлен, но дистрибутива ещё нет: +- это отдельное состояние, не путать с `WSL missing` +- дальше нужен install конкретного distro, а не повторная "общая" диагностика + +Дополнительное правило: +- не блокировать сценарий только потому, что distro на WSL 1 +- если inner `tmux` запускается и будущий runtime smoke test проходит, это usable path + +#### Шаг 2. Установка WSL + +Если WSL отсутствует полностью, рекомендуемый путь: + +```powershell +wsl --install --no-distribution +``` + +Если хотим объединённый flow "WSL + сразу Ubuntu": + +```powershell +wsl --install -d Ubuntu --no-launch +``` + +Если install hangs или Store path problematic, дать fallback hint: + +```powershell +wsl --install --web-download -d Ubuntu +``` + +Флаги тут выбраны не случайно: +- `--no-launch` documented in Microsoft basic commands as "install distro but do not launch it automatically": [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) +- `--web-download` documented in Microsoft install guide как fallback, если install hangs at `0.0%`: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install) +- `--no-distribution` documented in Microsoft basic commands for the case where WSL itself is not installed and you want to skip distro install during the core setup: [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) + +⚠️ Самое важное ограничение этого шага: +- `wsl --install` обычно требует elevation +- из обычного Electron process нельзя обещать, что мы всегда тихо запустим elevated child и будем стримить его live output назад в UI + +Поэтому для v1 надёжный UX такой: +- step-based progress внутри баннера +- явный текст `An administrator window may open` +- если нужен внешний elevated PowerShell, это нормально +- после завершения шага всегда делать fresh re-check, а не верить только exit code внешнего окна + +Почему это лучше для нашего плана: +- у нас уже есть отдельные UX steps `Install WSL` и `Install Ubuntu` +- `--no-distribution` лучше совпадает с этой state machine +- меньше путаницы, когда WSL core установился, а distro ещё нет + +Предпочтительный execution model для этого шага: +1. app создаёт temp marker directory +2. app запускает elevated helper через PowerShell `Start-Process -Verb RunAs -Wait` +3. helper выполняет `wsl --install ...` +4. helper пишет короткий JSON result file в temp directory +5. app читает result file, но всё равно делает fresh probe через `wsl --status` / `wsl --list` + +Это лучше, чем надеяться на redirected stdout/stderr, который в `RunAs` flow ненадёжен. + +Implementation note: +- helper лучше запускать как temp `.ps1` file, а не как огромную inline command string +- так меньше quoting bugs и проще сохранять diagnostics/result file + +### Почему `--no-launch` + +Это даёт нам больше контроля над wizard flow: +- сначала установить +- потом отдельно проверить reboot requirement +- потом отдельно вести пользователя в first-launch/bootstrap + +### Шаг 3. Restart detection + +После WSL install: +- если `wsl --status` / `wsl -l -v` всё ещё не готово +- или Windows сообщает, что optional components activated but reboot required + +UI state: +- `needs_restart` +- кнопка `Re-check after restart` + +Отдельный edge: +- для older Windows builds, где `wsl --install` вообще не поддерживается, сразу переводить в manual guidance на official Microsoft manual install page, а не пытаться эмулировать unsupported flow: [Manual installation steps for older versions of WSL](https://learn.microsoft.com/en-us/windows/wsl/install-manual) + +### Шаг 3.5. Установка Linux distro, если WSL core уже есть + +Если `wsl --status` успешен, но список дистрибутивов пуст: +- это не повод повторять full WSL core install +- дальше нужен именно install distro + +Рекомендуемый путь: + +```powershell +wsl --list --online +wsl --install -d Ubuntu --no-launch +``` + +Если online catalog недоступен или Store path заблокирован: +- сразу дать manual Microsoft guidance +- не обещать, что приложение само обойдёт Store/corporate restrictions + +Продуктовое правило: +- `Install WSL` и `Install Ubuntu` должны быть разными step labels в UI +- это уменьшает путаницу при поддержке и в error analytics + +Design rule: +- `Install Ubuntu` тоже не надо моделировать как гарантированно одинаковый in-app step на всех Windows машинах +- где-то он пройдёт как normal command path, где-то уйдёт в Store / external flow, где-то упрётся в policy +- поэтому и этот шаг должен завершаться только fresh probe'ом по факту появившегося distro, а не optimistic success UI + +### Шаг 4. Distro bootstrap + +Даже когда WSL установлен, пользователь может ещё не пройти first-launch distro setup. + +Признаки: +- distro установлена, но inner command падает +- first launch требует initial decompression или user creation + +Решение: +- отдельный шаг `Complete Linux distro setup` +- открываем пользователю команду: + +```powershell +wsl -d +``` + +После этого пользователь: +- ждёт распаковку +- создаёт Linux username/password + +Затем возвращается в app и нажимает `Re-check`. + +### Почему bootstrap должен быть отдельным шагом, а не скрытой автоматикой + +Это более надёжно и честно: +- пользователь видит создание Linux account/password +- меньше магии +- меньше platform-specific assumptions про state дистрибутива +- проще поддержка и диагностика + +### Шаг 4.1 Distro selection policy + +Если default distro уже есть: +- используем её + +Если distro нет: +- предлагаем `Ubuntu` как recommended default +- но в коде не хардкодим будущие команды на имя `Ubuntu` +- реальное имя выбранного/установленного дистрибутива всегда берём из `wsl --list --verbose` / `wsl --list --quiet` + +Лучший practical rule: +- для списка имён дистрибутивов prefer `wsl --list --quiet` +- `--list --verbose` использовать в основном для diagnostics и best-effort `distroVersion` +- это снижает зависимость от локализованных header/state строк в output. Microsoft docs отдельно показывают `--quiet` как режим "show only distribution names": [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) +- если нужно понять именно default distro, лучше опираться на stable markers вроде `*` prefix / persisted preference, а не на локализованные текстовые суффиксы + +Если их несколько: +- в v1 можно брать default distro +- если default не определён, нужна явная UI selection или forced recommendation + +Нельзя молча выбирать произвольную distro, если их несколько и default неочевиден. + +Если default distro есть, но его family unsupported для нашего auto-install v1: +- не пытаться "угадать" команды +- либо даём manual guidance для этого дистрибутива +- либо предлагаем отдельно установить supported distro, например Ubuntu +- не меняем default distro молча + +Важное уточнение после IOF: +- default distro нужен только как first guess +- после user choice или успешной установки приложение должно persist'ить `preferred distro` +- дальнейшие `status`, `verify` и будущий runtime follow-up сначала используют persisted distro +- если persisted distro исчезла из `wsl --list`, это отдельный recoverable state: + - clear stale preference + - показать понятный re-select / re-install flow + +### Шаг 5. Установка tmux внутри WSL + +Когда distro доступна, дальше стратегия как на Linux, но команды выполняются внутри WSL. + +Надёжный v1 default: +- запускать install внутри `wsl.exe` через PTY +- использовать Linux-user path с `sudo` +- не делать hidden root install по умолчанию + +Например для выбранного distro: + +```powershell +wsl --distribution -- sh -lc "sudo apt-get install -y tmux" +``` + +Если distro не Debian-based: +- читаем `/etc/os-release` внутри WSL +- подбираем inner package manager как для Linux +- если family unsupported, останавливаем auto path и даём manual guidance вместо рискованной "магии" + +Нюанс UX: +- пароль здесь обычно не Windows admin password, а Linux password выбранного WSL user +- это нужно явно подписать в UI, иначе пользователь будет думать, что приложение просит "не тот пароль" + +Нюанс надёжности: +- если делаем retry вроде `apt-get update && apt-get install`, лучше держать это в том же PTY session +- так мы не теряем `sudo` timestamp и не заставляем пользователя вводить пароль дважды без причины + +Optional future optimization: +- после явной проверки bootstrap-ready state можно отдельно исследовать `--user root` +- но это не должно быть основным путём v1 + +### Шаг 6. Verification + +```powershell +wsl --distribution -- sh -lc "tmux -V" +``` + +Но для надёжности этого мало. + +Рекомендуемая verification ladder: +1. `tmux -V` +2. безопасный smoke test на отдельном socket name, чтобы не трогать пользовательские tmux sessions + +Например: + +```powershell +wsl --distribution -- sh -lc "tmux -L codex-smoke -f /dev/null new-session -d true && tmux -L codex-smoke kill-server" +``` + +Именно smoke test лучше отвечает на вопрос "runtime path реально живой", а не только "binary существует". + +Для Windows follow-up это особенно важно: +- smoke test должен по возможности использовать тот же adapter path, который потом будет использовать runtime +- иначе можно случайно проверить "tmux работает в интерактивном `wsl sh -lc`", но не проверить "наш main-process adapter реально умеет работать с ним стабильно" + +### Windows edge cases + +- user denied UAC prompt +- WSL install succeeded partially +- reboot required +- no distro installed +- distro exists but not bootstrapped +- imported distro без launcher quirks +- default distro не Ubuntu +- inner distro не Debian-based +- inner distro unsupported для v1 auto-install +- WSL networking / store download issues +- corporate policy blocks virtualization +- `wsl.exe` есть, но kernel/update broken +- WSL установлен, но default distro не выбрана +- `wsl --install` partially succeeded, но distro не зарегистрировалась +- пользователь завершил bootstrap partially и закрыл окно +- Windows build слишком старый для `wsl --install` +- app не elevated и WSL core install ушёл во внешний admin window без live stdout +- 32-bit helper process path quirks для `wsl.exe`; defensive note из Microsoft docs - при необходимости `C:\\Windows\\Sysnative\\wsl.exe --command`: [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) +- locale-sensitive WSL output, если где-то случайно полагаться на human-readable headers вместо quiet list / stable markers + +## 9. Самое важное решение по Windows runtime + +Если хотим сделать Windows path не "paper feature", а реально полезным, нужно обязательно сделать follow-up: + +### 9.1 `tmux:getStatus` на Windows должен стать WSL-aware + +Сейчас status проверяет host binary. Это не подходит. + +Нужно: +- сначала probe host tmux +- если `win32` и host tmux нет: + - resolve `wsl.exe` через candidate list, а не слепо через `'wsl'` + - decode output defensively, потому что `wsl.exe` может возвращать UTF-16LE / mixed encoding + - probe `wsl --status` + - probe persisted/default/selected distro + - probe inner `tmux -V` +- собирать не один boolean, а нормальный `host / wsl / effective` snapshot +- не считать WSL 1 автоматическим fail, если smoke test на `tmux` проходит + +У нас уже есть полезные reference pieces в кодовой базе: +- `src/main/ipc/config.ts` + - candidate paths для `wsl.exe` (`System32`, `Sysnative`, fallback `wsl.exe`) + - UTF-16-aware decoding output + - `listWslDistros()` с несколькими command variants (`--list --quiet`, `-l -q`, `-l`) + +Их нужно не дублировать ad-hoc, а переиспользовать или вынести в общий helper. + +Ещё одно правило: +- `distroVersion` полезен как diagnostic field, но не должен быть gating factor сам по себе +- gating factor это usable adapter path + smoke test, а не просто цифра `1` или `2` + +### 9.2 `resolveDesktopTeammateModeDecision()` на Windows + +Сейчас: + +```ts +if (process.platform === 'win32') { + return { + injectedTeammateMode: null, + forceProcessTeammates: false, + }; +} +``` + +Для полноценного Windows support это надо заменить на WSL-aware decision path. + +### 9.3 Team runtime ops + +Нужно отдельно решить: +- как запускать `tmux` команды через `wsl -e tmux ...` +- как резолвить `wsl.exe` и его output decoding consistently во всех runtime ops +- как хранить pane ids / target ids +- как делать cleanup +- как переводить Windows `cwd` в WSL path и обратно +- как не ломаться на UNC paths вида `\\\\wsl.localhost\\\\...` +- как пинить `WSL_INTEROP` для долгоживущего tmux server +- как persist'ить целевой distro и не ломаться, если default distro изменился +- какой `claude` binary model считается supported внутри WSL tmux runtime + +Это не детали, а критичные runtime requirements. + +Отдельное правило: +- прямые tmux runtime-команды на Windows должны идти через direct exec path (`wsl -e tmux ...` / `wsl --exec tmux ...`), а не через shell wrapper +- shell wrapper оставляем только там, где реально нужен `sh -lc`, например для package-manager install steps + +Почему это важно: +- tmux format strings вроде `#{socket_path}` содержат `#` +- если команду отдать login shell'у, shell может интерпретировать это как comment и сломать вызов +- этот баг уже отражён в orchestrator reference + +#### 9.3.1 Path translation + +Если team runtime на Windows будет реально работать через WSL tmux, то `request.cwd` и любые project-related paths нельзя просто пробрасывать как есть. + +Нужно: +- для Windows host paths делать conversion в WSL path +- для UNC WSL paths проверять совпадение distro +- иметь fallback manual conversion, если `wslpath` unavailable + +Reference ideas уже есть в: +- `agent_teams_orchestrator/src/utils/idePathConversion.ts` +- `src/main/utils/pathDecoder.ts` + +Без этого можно получить очень неприятные полубаги: +- tmux стартует, но в неправильном `cwd` +- runtime работает только для `C:\\...`, но ломается на `\\\\wsl.localhost\\...` +- путь формально передан, но внутри WSL не существует + +#### 9.3.1.a Cross-filesystem performance and case semantics + +Это отдельный риск, который нельзя путать с "путь существует". + +По Microsoft docs: +- при работе из Linux command line fastest path - хранить файлы в WSL filesystem +- работа по `/mnt/c/...` возможна, но медленнее +- Windows и Linux по-разному ведут себя по case sensitivity +Источник: [Working across Windows and Linux file systems](https://learn.microsoft.com/en-us/windows/wsl/filesystems) + +Следствие для плана: +- `translatedCwdExists === true` ещё не означает "runtime path хороший" +- если project cwd живёт на Windows filesystem и внутри WSL превращается в `/mnt/c/...`, runtime может быть functional, но slower +- это не должно блокировать v1, но это должно попадать: + - в diagnostics + - в optional UX hint вроде `For best performance on Windows + WSL, store the project inside the WSL filesystem` + +Ещё один subtle point: +- Windows file systems часто case-insensitive +- Linux file systems case-sensitive +- любые runtime assumptions на равенство путей должны быть нормализованы очень аккуратно + +Дополнительный факт: +- Microsoft документирует `WSLENV` как bridge для env vars между Windows и WSL, включая path translation flags +Источник: [Working across Windows and Linux file systems](https://learn.microsoft.com/en-us/windows/wsl/filesystems) + +Практический вывод: +- `WSLENV` можно рассматривать как future optimisation/helper +- но для v1 лучше не делать его primary correctness layer +- path bridge должен оставаться явным и testable в нашем коде + +#### 9.3.2 `WSL_INTEROP` pinning + +Самый скрытый и опасный runtime bug сейчас подсвечен в orchestrator reference: +- tmux server, стартовавший через краткоживущий `wsl.exe`, может унаследовать нестабильный interop socket +- после detach/exit spawning `wsl.exe` interop перестаёт корректно работать для дальнейших Win32 launches из tmux + +Reference: +- `agent_teams_orchestrator/src/utils/tmuxSocket.ts` + +Практический вывод для плана: +- Windows runtime follow-up должен явно закладывать `WSL_INTEROP=/run/WSL/1_interop` при создании isolated tmux server +- и ставить его в tmux global environment для новых sessions + +Иначе можно получить очень неприятный класс багов: +- installer/verify выглядит успешным +- tmux server поднимается +- но позже реальные команды из persistent teammate path начинают падать на interop/timeouts + +#### 9.3.3 Isolated socket and `TMUX` env override + +Ещё один обязательный runtime requirement: +- приложение не должно работать в пользовательском tmux server по умолчанию +- нужен отдельный isolated socket, как в orchestrator reference + +Нужно: +- создавать свой socket name, например `claude-` или другой deterministic app-specific format +- все tmux команды гонять через `-L ` +- дочерним процессам внутри teammate runtime прокидывать правильный `TMUX` env, указывающий на наш socket +- cleanup всегда делать только по нашему socket name / server pid, не по generic tmux targets + +Иначе можно словить очень неприятный bug class: +- user уже работает в своём tmux внутри WSL +- приложение начинает управлять не своим server/session +- cleanup и `kill-pane` задевают не тот runtime + +#### 9.3.4 `claude` binary model inside WSL tmux + +Нужно принять explicit решение, что именно запускает teammate runtime внутри WSL pane: + +Вариант A - host Windows `claude.exe` через WSL interop +- 🎯 8 🛡️ 7 🧠 6 +- `150-350` строк follow-up logic +- Плюсы: + - ближе к текущей архитектуре desktop app + - reuse существующего host-side `ClaudeBinaryResolver` + - меньше шансов разъехаться по auth/config flows +- Риски: + - нужно жёстко prefer `.exe` + - `.cmd`/`.bat` wrappers нельзя считать автоматически эквивалентными + - interop/runtime smoke test должен проверять именно этот path + +Вариант B - Linux `claude` внутри WSL distro +- 🎯 5 🛡️ 6 🧠 8 +- `400-900` строк follow-up logic +- Плюсы: + - более "нативная" Linux execution model внутри tmux +- Риски: + - отдельная установка binary внутри WSL + - потенциально другой auth/config root + - больше drift от текущего Windows desktop runtime + - если бинарь/скрипты лежат на mounted Windows filesystem, semantics file permissions в WSL становятся отдельным источником проблем: [File permissions for WSL](https://learn.microsoft.com/en-us/windows/wsl/file-permissions) + +Рекомендация для v1 follow-up: +- идти через вариант A, но только если available binary это пригодный `.exe` +- если resolver на Windows дал только `.cmd`/`.bat`, не поднимать `runtimeReady=true` без отдельного validated wrapper path + +#### 9.3.5 Config/auth root semantics for Windows `claude.exe` from WSL + +Это ещё один важный слой, который легко пропустить. + +Факт из Microsoft docs: +- Windows executables, запущенные из WSL, работают как Win32 apps активного Windows user и retain WSL working directory for the most part +Источник: [Working across Windows and Linux file systems](https://learn.microsoft.com/en-us/windows/wsl/filesystems) + +Inference для нашего проекта: +- если teammate runtime внутри WSL pane запускает host `claude.exe`, нужно явно проверить, какие config/auth/session roots он использует в таком режиме +- нельзя автоматически считать, что "working dir внутри WSL" означает "и config root тоже WSL" + +Практический вывод: +- Windows runtime readiness probe должен включать не только запуск бинаря, но и sanity check на auth/config behavior тем же adapter path +- для v1 лучше держать модель консервативной: + - prefer host Windows auth/config expectations + - не объявлять runtime-ready, если probing показывает разъехавшийся config root или странный auth state + +Самый логичный reference - `agent_teams_orchestrator/src/utils/tmuxSocket.ts`. + +Если это не входит в текущую итерацию, UI/документация должны честно говорить: +- `WSL setup prepares tmux support` +- `full Windows persistent teammate runtime will be enabled in follow-up` + +## 10. Надёжность: как сделать "без багов" + +## 10.1 Installer mutex + +Нельзя запускать 2 инсталла параллельно. + +Нужно: + +```ts +if (this.installing) { + this.sendProgress({ + type: 'error', + error: 'tmux installation is already in progress', + }); + return; +} +``` + +## 10.2 Every install ends with verification + +Нельзя считать install успешным по exit code package manager alone. + +Успех только если: +- install command exit code success +- повторный status probe нашёл `tmux` +- `tmux -V` реально исполняется +- для runtime-critical enablement желательно проходит и лёгкий smoke test на isolated socket/session + +Для Windows runtime-critical enablement это лучше уточнить ещё жёстче: +- smoke test должен по возможности запускать тот же `claude` binary model, который потом реально пойдёт в teammate runtime +- тест уровня `tmux ... new-session -d true` полезен, но не доказывает, что interop launch path для реального CLI тоже живой +- если binary model это host `claude.exe` from WSL, probe должен подтверждать и auth/config sanity, а не только факт старта процесса + +## 10.2.1 Do not gate only on tmux version string + +Ещё один скрытый риск - слишком доверять `tmux -V`. + +Почему: +- package-manager версии на enterprise / LTS системах могут быть сильно старыми +- но для нас важен не номер сам по себе, а наличие конкретных runtime capabilities + +Практический rule: +- installer status может показывать `tmux -V` +- но runtime readiness должен опираться на capability probes: + - isolated socket create + - `has-session` + - `display-message -p` + - `set-environment -g` + - если Windows follow-up использует это - `new-session -e` + +Иными словами: +- `tmux` version string полезен для diagnostics +- capability contract важнее, чем числовой version gate, если мы заранее не зафиксировали минимальную supported версию + +## 10.3 No fake silent fallback + +Нельзя: +- проглотить ошибку `brew not found` +- переключиться на другой strategy без явного detail +- показать completed, если verification failed + +## 10.4 Structured error taxonomy + +Нужен нормализатор ошибок: + +```ts +type TmuxInstallErrorCode = + | 'ALREADY_INSTALLED' + | 'PACKAGE_MANAGER_NOT_FOUND' + | 'UNSUPPORTED_PLATFORM' + | 'AUTH_CANCELLED' + | 'PERMISSION_DENIED' + | 'PTY_UNAVAILABLE' + | 'NETWORK_ERROR' + | 'PACKAGE_LOCKED' + | 'PACKAGE_NOT_FOUND' + | 'RESTART_REQUIRED' + | 'WSL_NOT_INSTALLED' + | 'WSL_COMMAND_UNSUPPORTED' + | 'WSL_DISTRO_MISSING' + | 'WSL_DISTRO_NOT_READY' + | 'EXTERNAL_ELEVATION_CANCELLED' + | 'EXTERNAL_ELEVATION_FAILED' + | 'VERIFY_FAILED' + | 'USER_CANCELLED' + | 'UNKNOWN'; +``` + +И map в user-friendly messages. + +Важное правило для нормализатора: +- не полагаться только на exact English stderr text +- где возможно, использовать: + - exit code + - observable state вроде lock files / missing binary / missing distro + - несколько broad regex patterns вместо одного exact message + +Иначе: +- локализованные Linux/Windows системы будут часто падать в `UNKNOWN` +- а UI станет выглядеть "случайно нестабильным", хотя root cause классифицируемый + +## 10.5 Retry semantics + +- `Retry` должен re-run plan resolution с нуля +- перед retry: + - kill active child/pty + - invalidate status cache + - clear stale progress state + +## 10.5.1 Cache semantics during and after install + +Кэш статуса здесь легко превратить в источник ложных UI состояний. + +Правила: +- пока install active, negative cache entries нельзя считать authoritative +- после любого install step, который потенциально меняет system state, нужен explicit invalidate +- после Windows external elevated step нужен mandatory fresh probe, даже если helper сообщил success +- после app restart UI должен восстанавливаться не из in-memory installer state, а из свежего system probe + +Итог: +- installer flow должен быть idempotent +- partial install / app restart / crashed renderer не должны оставлять "залипшее installing" состояние + +## 10.5.2 Recovery after app restart or renderer crash + +Особенно важно для Windows external elevated steps. + +Правила: +- in-memory progress state не считается durable truth +- после app relaunch service сначала смотрит на system state, а не пытается "доигрывать" старый progress bar +- если остались temp marker/result files от elevated step: + - их можно использовать только как diagnostics hint + - final UI state всё равно решает fresh probe +- старые marker/result dirs нужно периодически cleanup'ить по age, чтобы не копить мусор и не читать stale outcome как новый + +## 10.6 Cancellation semantics + +Нужен `cancelInstall()`: +- убивает child/pty +- шлёт `cancelled` +- не оставляет broken installing flag + +Но для Windows external elevated step это работает иначе: +- после `Start-Process -Verb RunAs` приложение уже не контролирует тот процесс так же, как обычный child +- значит `Cancel` в этом состоянии это скорее `stop waiting locally`, а не гарантированное terminate внешнего admin window + +Надёжный UX contract: +- если step ещё не ушёл во внешний elevated flow, `Cancel` действительно отменяет install locally +- если step уже ушёл во внешний elevated flow: + - app переводит себя в cancelled/abandoned waiting state + - явно пишет, что external administrator window may still be open + - после этого truth source всё равно fresh probe, а не предположение "мы точно всё остановили" + +## 10.7 Diagnostics + +Добавить diagnostic log рядом по стилю с `CliInstallerService`: +- detected platform +- chosen strategy +- PATH used +- package manager path +- raw exit code +- stderr tail +- verify result + +### 10.8 Raw log hygiene + +Нельзя бесконтрольно складывать весь terminal output в renderer/store. + +Нужно: +- ring buffer по строкам или байтам +- upper bound на память +- redaction rules для очевидно чувствительных фрагментов +- не persist raw install terminal logs на диск по умолчанию +- не логировать пользовательский input в PTY вообще +- пароль/keystrokes пользователя не должны попадать ни в raw chunks, ни в diagnostics + +Минимум: + +```ts +const MAX_RAW_CHUNKS = 400; +const MAX_RAW_BYTES = 256 * 1024; +``` + +## 11. Детальный implementation plan + +## Phase 1. Feature contracts and registration + +Файлы: +- `src/features/tmux-installer/contracts/api.ts` +- `src/features/tmux-installer/contracts/channels.ts` +- `src/features/tmux-installer/contracts/dto.ts` +- `src/features/tmux-installer/contracts/index.ts` +- `src/features/tmux-installer/main/index.ts` +- `src/features/tmux-installer/preload/createTmuxInstallerBridge.ts` +- `src/features/tmux-installer/preload/index.ts` +- `src/renderer/api/httpClient.ts` +- host registration points: + - `src/preload/index.ts` + - app shell main bootstrap + +Что делаем: +- расширяем типы +- добавляем install/invalidate/cancel/progress API +- browser-mode stubs возвращают safe no-op behavior + +## Phase 2. Main-process status refactor + +Файлы: +- `src/features/tmux-installer/main/index.ts` +- `src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts` +- `src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts` +- `src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts` +- `src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts` +- host registration point: + - app shell main bootstrap, который импортирует только `@features/tmux-installer/main` + - если `src/main/ipc/tmux.ts` сохраняется ради совместимости, то только как thin registration shim + +Что делаем: +- выносим status computation из голого IPC handler в feature use case + adapter chain +- status probe должен использовать enriched PATH / interactive shell env, а не только process PATH +- для macOS дополнительно проверять common brew prefixes, иначе получим false negative после успешного install +- feature facade умеет: + - `getStatus()` + - `install()` + - `cancelInstall()` + - `invalidateStatus()` + - `setMainWindow()` + +## Phase 2.2 WSL preference persistence + +Новый модуль: +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts` + +Что делаем: +- сохраняем `preferred distro` после явного Windows wizard choice или после первого успешного install/verify +- status service сначала пробует persisted distro +- если persisted distro больше не существует, store self-heals: + - mark stale + - clear preference + - вернуть UI в `re-select distro` / `manual guidance` + +## Phase 2.1 PTY plumbing refactor + +Файлы: +- `src/main/services/infrastructure/PtyTerminalService.ts` +- feature-local wrapper `src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts` +- возможно новый attach-style renderer component вместо прямого reuse `TerminalModal` + +Что делаем: +- добавляем main-side control surface для PTY session +- installer service должен получать: + - raw chunks + - exit code + - ability to write input + - explicit dispose lifecycle +- renderer не должен быть владельцем installer process +- если attach UI делаем позже, installer всё равно остаётся source of truth + +## Phase 3. Strategy resolver + +Новый модуль: +- `src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts` + +Псевдокод: + +```ts +export async function resolveTmuxInstallPlan(): Promise { + if (process.platform === 'darwin') return resolveMacPlan(); + if (process.platform === 'linux') return resolveLinuxPlan(); + if (process.platform === 'win32') return resolveWindowsPlan(); + return manualOnlyPlan('Unsupported platform'); +} +``` + +Важно: +- resolver должен учитывать не только OS/package manager, но и локальные capability flags +- пример: если путь требует interactive `sudo`, а PTY capability недоступен, auto-install надо честно выключать и переводить пользователя в manual guidance + +## Phase 4. macOS install runner + +- detect `brew` +- else detect `port` +- else manual + +Нерискованный rule: +- не auto-install Homebrew +- не auto-run remote shell scripts + +## Phase 5. Linux install runner + +- parse `/etc/os-release` +- choose command +- run command in PTY +- stream raw logs +- verify +- if immutable host detected -> short-circuit to manual fallback +- if retry with `update` is needed, prefer doing it in the same PTY session + +## Phase 6. Renderer slice inside feature + +Новые файлы: +- `src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts` +- `src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts` +- `src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx` +- `src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts` +- `src/features/tmux-installer/renderer/index.ts` + +Не делать primary architecture через новый global slice, пока это не стало реально нужно нескольким независимым surface'ам. + +Лучше так: +- installer state canonical в main process +- renderer slice читает snapshot + подписывается на progress events +- local React state внутри feature hook достаточно для v1 +- если позже появится второй surface с тем же state, можно отдельно решить, нужен ли shared renderer cache + +Feature hook return shape: + +```ts +{ + viewModel: TmuxInstallerBannerViewModel; + actions: { + install: () => Promise; + cancel: () => Promise; + refresh: () => Promise; + toggleDetails: () => void; + }; +} +``` + +Hook boundary rules: +- hook не должен импортировать `api` напрямую +- hook не должен знать про IPC channel names +- hook не должен нормализовать platform-specific ошибки +- это ответственность renderer adapter и main-side use case/presenter layers + +## Phase 7. Banner UX + +Создать feature view: +- `src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx` + +И оставить `src/renderer/components/dashboard/TmuxStatusBanner.tsx` как thin wrapper: + +```tsx +import { TmuxInstallerBannerView } from '@features/tmux-installer/renderer'; + +export function TmuxStatusBanner(): JSX.Element { + return ; +} +``` + +Внутри feature view: + +- `Install tmux` button +- status line +- progress bar +- `Show details` / `Hide details` +- error block +- manual fallback buttons + +UI logic: +- если `autoInstall.supported === false`, не показывать install CTA +- если `needs_manual_step`, показывать step-specific CTA +- если `needs_restart`, показывать restart CTA +- если live installer session уже идёт, feature должен подхватить её через snapshot и не сбрасывать UI в `idle` + +## Phase 7.1 Feature integration points checklist + +Чтобы реализация реально соответствовала guide, в PR и в финальной implementation notes надо перечислить изменённые host integration points. + +Ожидаемые integration points для этой задачи: +- `src/features/tmux-installer/contracts/*` +- `src/features/tmux-installer/core/*` +- `src/features/tmux-installer/main/*` +- `src/features/tmux-installer/preload/*` +- `src/features/tmux-installer/renderer/*` +- app shell registration points for main/preload/renderer +- `src/renderer/components/dashboard/TmuxStatusBanner.tsx` + +Если появятся дополнительные host touchpoints, это должно быть явно объяснено, а не "само выросло". + +## Phase 7.2 Definition of done по feature standard + +Перед merge реализация считается готовой только если: +- feature живёт в `src/features/tmux-installer/` +- структура соответствует canonical template из `docs/FEATURE_ARCHITECTURE_STANDARD.md` +- `contracts`, `core`, `main`, `preload`, `renderer` заполнены осмысленно +- `core/domain` side-effect free +- `core/application` покрывает use case orchestration +- public imports идут только через feature entrypoints +- shared shell files не содержат business logic, только registration shims +- renderer business logic не осталась в `TmuxStatusBanner.tsx` +- есть как минимум: + - domain policy tests + - application use case tests + - critical renderer utility tests + - one adapter-level mapping test +- `pnpm typecheck` проходит +- `pnpm build` проходит +- import/lint guard rails не позволяют deep-import feature internals снаружи +- integration points перечислены в PR notes + +## Phase 8. Windows WSL wizard + +Сделать это отдельным sub-flow внутри feature use case + infrastructure path, а не if/else spaghetti inside banner. + +Новый helper: +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts` + +Методы: + +```ts +interface TmuxWslService { + getStatus(): Promise; + installWsl(): Promise; + ensureDistro(): Promise; + ensureBootstrapReady(): Promise; + installTmuxInsideDistro(): Promise; + verifyTmux(): Promise; +} +``` + +Важно: +- `installWsl()` должен поддерживать сценарий `external elevated step` +- его контракт не должен предполагать "я всегда верну полный live log" +- success этого шага всегда подтверждается только последующим fresh probe + +## Phase 8.1 Windows elevated runner + +Новый модуль: +- `src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts` + +Ответственность: +- создавать temp working dir для elevated step +- писать helper script / args +- запускать PowerShell `Start-Process -Verb RunAs -Wait` +- читать result JSON / stderr tail file если helper успел их записать +- нормализовать outcome в: + - `elevated_succeeded` + - `elevated_cancelled` + - `elevated_failed` + - `elevated_unknown_outcome` + +Ключевое правило: +- даже `elevated_succeeded` не завершает installer flow само по себе +- после него всегда идёт fresh probe реального system state + +## Phase 9. Windows runtime follow-up + +Это можно вынести в отдельную PR/итерацию, но в этом документе оно считается обязательным follow-up. + +Файлы: +- `src/main/services/team/runtimeTeammateMode.ts` +- `src/main/ipc/tmux.ts` +- `src/main/services/team/TeamProvisioningService.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPathBridge.ts` + +Нужно: +- сделать WSL-aware tmux detection +- перестать hard-disable tmux path на Windows, если WSL tmux реально доступен +- добавить отдельный adapter для `tmux` команд через `wsl -e` +- добавить path bridge для `cwd` и project-related paths +- заложить `WSL_INTEROP` pinning strategy при создании isolated tmux server +- ввести explicit policy для supported `claude` binary shape внутри WSL runtime + +## 12. Пример скелета feature facade + +```ts +export class TmuxInstallerFeatureFacade { + #mainWindow: BrowserWindow | null = null; + #installing = false; + #activeChild: ChildProcess | null = null; + #activePty: TmuxInstallTerminalSession | null = null; + #cachedStatus: { value: TmuxStatus; at: number } | null = null; + + setMainWindow(window: BrowserWindow | null): void { + this.#mainWindow = window; + } + + async getStatus(): Promise { + // 1. detect host tmux + // 2. detect install capability + // 3. on Windows also detect WSL readiness + // 4. return unified snapshot + } + + async install(): Promise { + if (this.installing) { + this.sendProgress({ type: 'error', error: 'tmux installation is already in progress' }); + return; + } + + this.installing = true; + try { + this.sendProgress({ type: 'checking', detail: 'Detecting installation strategy...' }); + const plan = await resolveTmuxInstallPlan(); + + if (!plan.autoInstallSupported) { + this.sendProgress({ + type: 'error', + error: plan.reasonIfUnsupported ?? 'Automatic install is not available on this machine', + nextManualHint: plan.manualHints[0] ?? null, + }); + return; + } + + await this.executePlan(plan); + + this.sendProgress({ type: 'verifying', detail: 'Verifying tmux...' }); + const status = await this.invalidateAndGetFreshStatus(); + if (!status.effective.available) { + throw new Error('tmux installation finished but verification failed'); + } + + if (!status.effective.runtimeReady) { + this.sendProgress({ + type: 'needs_manual_step', + detail: 'tmux is installed, but runtime integration is not ready yet on this machine', + status, + }); + return; + } + + this.sendProgress({ + type: 'completed', + detail: `Installed ${status.effective.version ?? 'tmux'} via ${status.effective.location ?? 'unknown path'}`, + status, + }); + } catch (error) { + this.sendProgress({ + type: 'error', + error: normalizeTmuxInstallError(error).userMessage, + }); + } finally { + this.installing = false; + this.activeChild = null; + this.activePty = null; + } + } + + async cancelInstall(): Promise { + killProcessTree(this.activeChild); + this.activePty?.dispose(); + this.sendProgress({ type: 'cancelled', detail: 'Installation cancelled' }); + } + + private sendProgress(progress: TmuxInstallerProgress): void { + safeSendToRenderer(this.mainWindow, TMUX_INSTALLER_PROGRESS, progress); + } +} +``` + +## 12.2 Пример runtime readiness rule для Windows follow-up + +```ts +function isWindowsWslRuntimeReady(ctx: { + tmuxSmokeOk: boolean; + wslInteropPinned: boolean; + targetDistroExists: boolean; + translatedCwdExists: boolean; + claudeBinaryPath: string | null; + configRootSanityOk: boolean; +}): boolean { + if (!ctx.tmuxSmokeOk) return false; + if (!ctx.wslInteropPinned) return false; + if (!ctx.targetDistroExists) return false; + if (!ctx.translatedCwdExists) return false; + if (!ctx.claudeBinaryPath) return false; + if (!ctx.configRootSanityOk) return false; + + const lower = ctx.claudeBinaryPath.toLowerCase(); + return lower.endsWith('.exe'); +} +``` + +Это intentionally conservative rule. + +Смысл: +- лучше временно недовключить Windows WSL runtime, чем считать runtime-ready сценарий с `.cmd` wrapper или broken path translation + +## 12.1 Пример Windows elevated step runner + +```ts +interface WindowsElevatedStepResult { + outcome: 'elevated_succeeded' | 'elevated_cancelled' | 'elevated_failed' | 'elevated_unknown_outcome'; + detail?: string | null; + resultFilePath?: string | null; +} + +export class WindowsElevatedStepRunner { + async runWslInstall(args: string[]): Promise { + const tempDir = await fsp.mkdtemp(join(tmpdir(), 'tmux-wsl-install-')); + const resultFile = join(tempDir, 'result.json'); + const helperScript = join(tempDir, 'run-wsl-install.ps1'); + + await fsp.writeFile( + helperScript, + buildWslInstallScript({ + wslArgs: args, + resultFile, + }), + 'utf8' + ); + + const ps = await execFileNoThrow('powershell.exe', [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `Start-Process -FilePath powershell.exe -Verb RunAs -Wait -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"${escapePowerShell(helperScript)}\"'`, + ]); + + // Do not trust only PowerShell exit code. UAC cancellation and helper failures + // are better inferred from the result file plus a fresh WSL probe. + if (await fileExists(resultFile)) { + const payload = JSON.parse(await fsp.readFile(resultFile, 'utf8')) as { + ok?: boolean; + detail?: string; + }; + return { + outcome: payload.ok ? 'elevated_succeeded' : 'elevated_failed', + detail: payload.detail ?? null, + resultFilePath: resultFile, + }; + } + + if (looksLikeElevationCancelled(ps.stderr, ps.stdout, ps.code)) { + return { + outcome: 'elevated_cancelled', + detail: 'Administrator permission request was cancelled', + resultFilePath: null, + }; + } + + return { + outcome: 'elevated_unknown_outcome', + detail: ps.stderr || ps.stdout || null, + resultFilePath: null, + }; + } +} +``` + +Это не production-ready код, а reference shape. + +Главная идея здесь важнее синтаксиса: +- elevated Windows step отделён от PTY/Linux install logic +- результат helper script это только дополнительная диагностика +- truth source всё равно fresh `wsl --status` / `wsl --list` probe после шага + +## 13. Пример Linux resolver + +```ts +export async function resolveLinuxPackageManager(): Promise { + const osRelease = await readOsRelease(); + + if (isDistroFamily(osRelease, ['debian'])) return 'apt'; + if (isDistroFamily(osRelease, ['fedora', 'rhel'])) return (await hasBinary('dnf')) ? 'dnf' : 'yum'; + if (isDistroFamily(osRelease, ['suse', 'opensuse'])) return 'zypper'; + if (isDistroFamily(osRelease, ['arch'])) return 'pacman'; + + if (await hasBinary('apt-get')) return 'apt'; + if (await hasBinary('dnf')) return 'dnf'; + if (await hasBinary('yum')) return 'yum'; + if (await hasBinary('zypper')) return 'zypper'; + if (await hasBinary('pacman')) return 'pacman'; + + return 'unknown'; +} +``` + +## 14. Пример Windows WSL status probe + +```ts +async function getWslStatus(): Promise { + const wslStatus = await execWslNoThrow(['--status']); + if (wslStatus.code !== 0) { + return { + wslInstalled: false, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + statusDetail: wslStatus.stderr || wslStatus.stdout || 'WSL not available', + }; + } + + const preferredDistro = await wslPreferenceStore.getPreferredDistro(); + const list = await execWslNoThrow(['--list', '--verbose']); + const distro = resolveTargetDistro({ + preferredDistro, + listOutput: list.stdout, + }); + const distroVersion = parseDefaultDistroVersion(list.stdout); + if (!distro) { + return { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + statusDetail: 'WSL installed but no Linux distribution is configured', + }; + } + + const bootstrapProbe = await execWslNoThrow([ + '--distribution', + distro, + '--', + 'sh', + '-lc', + 'printf ready', + ]); + + if (bootstrapProbe.code !== 0 || !bootstrapProbe.stdout.includes('ready')) { + return { + wslInstalled: true, + rebootRequired: false, + distroName: distro, + distroVersion, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + statusDetail: + bootstrapProbe.stderr || bootstrapProbe.stdout || 'Linux distro bootstrap is not finished', + }; + } + + const tmuxProbe = await execWslNoThrow([ + '--distribution', + distro, + '--', + 'tmux', + '-V', + ]); + return { + wslInstalled: true, + rebootRequired: false, + distroName: distro, + distroVersion, + distroBootstrapped: true, + innerPackageManager: null, + tmuxAvailableInsideWsl: tmuxProbe.code === 0, + tmuxVersion: tmuxProbe.code === 0 ? extractTmuxVersion(tmuxProbe.stdout || tmuxProbe.stderr) : null, + statusDetail: tmuxProbe.code === 0 ? null : tmuxProbe.stderr || tmuxProbe.stdout || null, + }; +} +``` + +Где `execWslNoThrow()`: +- сам выбирает подходящий `wsl.exe` candidate +- запускает с `encoding: buffer` +- затем декодирует stdout/stderr defensively, потому что `wsl.exe` output на Windows не всегда стабильно UTF-8 + +А `resolveTargetDistro()`: +- сначала пробует persisted preference +- если её нет или она stale, использует default distro / selected distro heuristic +- не должен silently switch runtime target, если preference сломалась и это меняет expected behavior пользователя + +А `parseDefaultDistroVersion()`: +- должен считаться best-effort helper только для diagnostics +- если parser не уверен, лучше вернуть `null`, чем принять неправильное runtime decision + +## 15. Error UX copy guidelines + +Плохой текст: +- `ENOENT` +- `spawn failed` +- `exit code 1` + +Хороший текст: +- `Homebrew was not found. Install Homebrew first or use the manual instructions below.` +- `The package manager asked for administrator privileges and the request was cancelled.` +- `Interactive installation is not available because terminal support is missing in this app build. Use the manual command below.` +- `WSL was installed, but Windows restart is still required before tmux can be set up.` +- `This Windows version does not support the automatic WSL setup flow used by the app. Follow the Microsoft manual steps below.` +- `WSL is available, but no Linux distribution is installed yet. Install Ubuntu first, then continue.` +- ` is installed in WSL, but its first-launch setup is not finished yet. Open it once, finish Linux user setup, then re-check.` +- `WSL setup needs an administrator step in a separate window. Complete that step, then come back and press Re-check.` + +## 16. Тест-план + +### Unit tests + +- status resolver for every platform +- package manager resolver +- error normalizer +- WSL output parsers +- WSL executable candidate resolver + UTF-16/mixed output decoding +- progress phase mapping +- WSL preferred-distro resolver +- elevated-step restart recovery logic +- locale-robust default-distro resolution +- supported Windows `claude` binary-shape policy +- locale-robust package-manager error classification +- config/auth-root sanity probe for Windows `claude.exe` from WSL +- tmux capability probes vs plain version string + +### Integration tests with mocks + +- install success on brew +- brew missing -> manual fallback +- apt sudo password prompt path +- package manager lock errors +- verification failure after install +- PTY capability missing -> no interactive auto-install path +- Windows: + - WSL missing + - WSL install goes through external elevated step and requires re-check + - external elevated step returns no result file -> unknown outcome -> fresh probe decides + - WSL install requires restart + - `wsl --status` unsupported on older build -> manual guidance + - distro missing + - distro bootstrap pending + - unsupported default distro -> manual or install-supported-distro guidance + - tmux install inside WSL success + - runtime smoke test passes only when same adapter path is used + - persisted preferred distro disappears -> self-heal without misleading "installed" state + - app restart during external elevated step -> self-heal from fresh probe, not stale in-memory state + - direct `wsl -e tmux ...` path works for tmux format strings with `#` + - host Windows resolver returns `.cmd` only -> runtime stays conservative, no false ready + - host `claude.exe` launches from WSL but config/auth root behaves unexpectedly -> runtime stays not ready + - `tmux -V` succeeds but required runtime capability probe fails -> no false ready + +### Manual matrix + +- macOS Apple Silicon + Homebrew +- macOS Intel + Homebrew +- macOS + MacPorts only +- Ubuntu +- Fedora +- Arch +- openSUSE +- Fedora Silverblue / similar immutable host -> manual fallback only +- Windows 11 clean machine without WSL +- Windows 11 with WSL but no distro bootstrap +- Windows 11 with ready supported distro +- Windows 11 with WSL 1 distro where tmux still works +- Windows 10 older build -> manual Microsoft guidance only +- Windows runtime with project on `C:\\...` path -> WSL path translation works +- Windows runtime with project on `\\\\wsl.localhost\\\\...` path -> distro match handling works +- Windows with multiple distros and changed default distro -> persisted target stays stable +- Windows runtime on `/mnt/c/...` project -> works but surfaces performance diagnostic + +## 16.1 Остаточные uncertainty points после самого жёсткого IOF + +Они уже не блокируют план, но их лучше закрыть маленькими spikes до полной реализации. + +1. Windows elevated helper UX + - 🎯 8 🛡️ 8 🧠 5 + - `100-200` строк spike + - Нужно проверить на реальной машине, как стабильно работает `Start-Process -Verb RunAs -Wait` + temp result file и какие коды/сообщения приходят при UAC cancel. + +2. WSL distro install path under corporate restrictions + - 🎯 7 🛡️ 7 🧠 4 + - `50-120` строк spike + - Нужно подтвердить, когда `wsl --install -d ...` реально помогает, а когда сразу надо переводить пользователя в manual Microsoft docs. + +3. PTY attach model для installer UI + - 🎯 8 🛡️ 9 🧠 6 + - `150-300` строк spike + - Нужно быстро выбрать: расширяем существующий `PtyTerminalService` или делаем небольшой отдельный installer-session adapter. + +4. WSL runtime path bridge + interop pinning + - 🎯 7 🛡️ 9 🧠 7 + - `150-350` строк spike + - Нужно проверить на реальном Windows path mix: `C:\\...`, `\\\\wsl.localhost\\...`, spaces, different distro names, и подтвердить поведение `WSL_INTEROP=/run/WSL/1_interop` для долгоживущего tmux server. + +5. Preferred distro persistence semantics + - 🎯 8 🛡️ 8 🧠 5 + - `80-180` строк spike + - Нужно подтвердить, где и как лучше хранить `preferred WSL distro`, чтобы не получить ложные переключения при смене default distro и при этом не создать лишнюю сложность миграции настроек. + +6. Windows `claude` binary model through WSL interop + - 🎯 7 🛡️ 8 🧠 6 + - `120-260` строк spike + - Нужно подтвердить на реальной машине: `claude.exe` path, `.cmd` fallback behavior, quoting, cleanup и минимальный smoke test именно через тот binary shape, который потом пойдёт в teammate runtime. + +7. Config/auth root sanity for `claude.exe` launched from WSL + - 🎯 6 🛡️ 8 🧠 6 + - `100-220` строк spike + - Нужно проверить, какой effective config/auth/session root реально использует host `claude.exe`, запущенный из WSL tmux pane, и не появляется ли drift между Windows root и WSL working directory. + +## 17. Rollout strategy + +### PR 1 + +- shared types +- main service skeleton +- macOS/Linux installer +- banner UI +- manual fallback UX + +### PR 2 + +- Windows WSL wizard +- richer status snapshot for WSL + +### PR 3 + +- Windows runtime enablement through WSL tmux + +Это лучше, чем пытаться протащить всё одним PR. + +## 18. Самый важный список "не наступить" + +- не делать Windows wizard без честного copy, если runtime ещё не использует WSL tmux +- не auto-install Homebrew в фоне +- не считать install успешным без `tmux -V` +- не считать `tmux:getStatus().effective.available === true` синонимом "host tmux есть" +- не смешивать service-owned installer PTY с renderer-owned terminal modal +- не использовать fake 0-100 network progress для package managers +- не терять stderr/stdout +- не запускать `sudo` через non-TTY child +- не делать default hidden root install внутри WSL +- не пытаться auto-manage immutable Linux hosts как обычный `dnf/apt` Linux +- не хардкодить PATH без shell env merge +- не запускать Windows WSL runtime без path translation layer +- не поднимать Windows tmux server в WSL без явного `WSL_INTEROP` pinning strategy +- не привязывать Windows runtime только к current default distro +- не молча менять целевой WSL distro после успешной установки tmux +- не считать `.cmd`/`.bat` эквивалентом `.exe` для Windows WSL runtime без отдельной валидации +- не считать успешный старт `claude.exe` из WSL достаточным доказательством корректного config/auth root +- не прятать manual fallback, если auto-install unavailable +- не делать installer logic inside React component + +## 19. Мой итоговый recommendation + +Делать именно так: + +1. Сначала качественный `tmux-installer` feature slice для `macOS/Linux` +2. Сразу же заложить правильный shared contract под Windows WSL +3. На Windows не останавливаться на "installer wizard only" +4. Обязательно follow-up на WSL-aware runtime support, иначе ценность Windows-части будет неполной + +Если делать по этому плану, получится действительно качественно: +- удобно +- понятно +- с нормальной диагностикой +- без дешёвых UX-обманок +- с честным fallback для ручной установки diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 67727de3..fc6d5aeb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -73,6 +73,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@main': resolve(__dirname, 'src/main'), '@shared': resolve(__dirname, 'src/shared'), '@preload': resolve(__dirname, 'src/preload') @@ -111,6 +112,7 @@ export default defineConfig({ preload: { resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@preload': resolve(__dirname, 'src/preload'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main') @@ -141,6 +143,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@renderer': resolve(__dirname, 'src/renderer'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main'), diff --git a/eslint.config.js b/eslint.config.js index a26f1660..5a191478 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -78,7 +78,7 @@ export default defineConfig([ // Import plugin configuration - Renderer (uses tsconfig.json) { name: 'import-plugin-renderer', - files: ['src/renderer/**/*.{ts,tsx}'], + files: ['src/renderer/**/*.{ts,tsx}', 'src/features/**/*.{ts,tsx}'], plugins: { import: importPlugin, }, @@ -96,6 +96,255 @@ export default defineConfig([ 'import/no-default-export': 'warn', }, }, + // Feature-specific architecture guard rails - recent-projects + { + name: 'feature-recent-projects-public-entrypoints', + files: ['src/**/*.{ts,tsx}'], + ignores: ['src/features/recent-projects/**/*'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/recent-projects/contracts/**', + '@features/recent-projects/core/**', + '@features/recent-projects/main/**', + '@features/recent-projects/preload/**', + '@features/recent-projects/renderer/**', + ], + message: + 'Import recent-projects only through its public entrypoints: @features/recent-projects/contracts, @features/recent-projects/main, @features/recent-projects/preload, or @features/recent-projects/renderer.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-recent-projects-core-domain-guards', + files: ['src/features/recent-projects/core/domain/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/recent-projects/core/application/**', + '@features/recent-projects/main/**', + '@features/recent-projects/preload/**', + '@features/recent-projects/renderer/**', + '@main/**', + '@renderer/**', + '@preload/**', + 'electron', + 'fastify', + 'child_process', + 'node:child_process', + ], + message: + 'recent-projects core/domain must stay side-effect free and cannot depend on application, adapters, infrastructure, or platform code.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-recent-projects-core-application-guards', + files: ['src/features/recent-projects/core/application/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/recent-projects/main/**', + '@features/recent-projects/preload/**', + '@features/recent-projects/renderer/**', + '@renderer/**', + 'electron', + 'fastify', + 'child_process', + 'node:child_process', + ], + message: + 'recent-projects core/application may depend only on domain, contracts, and application ports - not on adapters or runtime frameworks.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-recent-projects-preload-guards', + files: ['src/features/recent-projects/preload/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/recent-projects/main/**', + '@main/**', + '@renderer/**', + ], + message: + 'recent-projects preload may depend only on contracts and preload-local bridge helpers.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-recent-projects-renderer-ui-guards', + files: ['src/features/recent-projects/renderer/ui/**/*.tsx'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@renderer/api', + '@renderer/api/**', + '@renderer/store', + '@renderer/store/**', + '@main/**', + 'electron', + ], + message: + 'recent-projects renderer/ui must stay presentational. Move transport, store access, and navigation logic into hooks or adapters.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-agent-graph-public-entrypoints', + files: ['src/**/*.{ts,tsx}'], + ignores: ['src/features/agent-graph/**/*'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/agent-graph/core/**', + '@features/agent-graph/renderer/**', + ], + message: + 'Import agent-graph only through its public entrypoint: @features/agent-graph/renderer.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-agent-graph-core-domain-guards', + files: ['src/features/agent-graph/core/domain/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/agent-graph/renderer/**', + '@main/**', + '@renderer/**', + '@preload/**', + 'electron', + 'fastify', + 'child_process', + 'node:child_process', + ], + message: + 'agent-graph core/domain must stay pure and cannot depend on renderer, main, preload, or platform code.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-agent-graph-renderer-boundaries', + files: ['src/features/agent-graph/renderer/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@main/**', + '@preload/**', + 'electron', + ], + message: + 'agent-graph renderer may depend on shared, renderer, package, and feature-local modules, but not on main/preload or Electron APIs directly.', + }, + ], + }, + ], + }, + }, + + // Import plugin configuration - Feature main/preload slices + { + name: 'import-plugin-features-node', + files: ['src/features/**/main/**/*.ts', 'src/features/**/preload/**/*.ts'], + plugins: { + import: importPlugin, + }, + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: ['./tsconfig.node.json', './tsconfig.json'], + }, + }, + }, + rules: { + 'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }], + 'import/no-unresolved': 'error', + 'import/no-default-export': 'warn', + }, + }, + + // Import plugin configuration - Feature contracts/core/renderer slices + { + name: 'import-plugin-features-web', + files: [ + 'src/features/**/contracts/**/*.ts', + 'src/features/**/core/**/*.ts', + 'src/features/**/renderer/**/*.{ts,tsx}', + ], + plugins: { + import: importPlugin, + }, + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: ['./tsconfig.json', './tsconfig.node.json'], + }, + }, + }, + rules: { + 'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }], + 'import/no-unresolved': 'error', + 'import/no-default-export': 'warn', + }, + }, // Module boundaries - Enforce Electron three-process architecture { @@ -548,7 +797,7 @@ export default defineConfig([ // === Import Restrictions === // Note: boundaries/element-types handles main/renderer separation - 'no-restricted-imports': 'warn', + 'no-restricted-imports': 'off', // === Mutation Prevention === 'no-param-reassign': 'warn', @@ -624,6 +873,137 @@ export default defineConfig([ }, }, + { + name: 'feature-public-entrypoints-only', + files: [ + 'src/main/**/*.{ts,tsx}', + 'src/preload/**/*.{ts,tsx}', + 'src/renderer/**/*.{ts,tsx}', + 'src/shared/**/*.{ts,tsx}', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/*/contracts/*', + '@features/*/core/**', + '@features/*/main/*', + '@features/*/preload/*', + '@features/*/renderer/*', + ], + message: 'Import feature public entrypoints only.', + }, + ], + }, + ], + }, + }, + + { + name: 'feature-core-domain-guards', + files: ['src/features/*/core/domain/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { name: 'electron', message: 'core/domain must stay Electron-free.' }, + { name: 'fastify', message: 'core/domain must stay transport-free.' }, + { name: 'child_process', message: 'core/domain must stay side-effect free.' }, + { name: 'node:child_process', message: 'core/domain must stay side-effect free.' }, + ], + patterns: [ + { + group: ['@main/*', '@preload/*', '@renderer/*'], + message: 'core/domain must stay process-agnostic.', + }, + { + group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'], + message: 'core/domain must not import runtime or transport layers.', + }, + ], + }, + ], + }, + }, + + { + name: 'feature-core-application-guards', + files: ['src/features/*/core/application/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { name: 'electron', message: 'core/application must stay Electron-free.' }, + { name: 'fastify', message: 'core/application must stay transport-free.' }, + { name: 'child_process', message: 'core/application must not spawn processes directly.' }, + { + name: 'node:child_process', + message: 'core/application must not spawn processes directly.', + }, + ], + patterns: [ + { + group: ['@main/*', '@preload/*', '@renderer/*'], + message: 'core/application must stay framework-agnostic.', + }, + { + group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'], + message: 'core/application must depend on ports, not runtime adapters.', + }, + ], + }, + ], + }, + }, + + { + name: 'feature-preload-guards', + files: ['src/features/*/preload/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@main/*'], + message: 'Feature preload should not import app-shell main modules.', + }, + { + group: ['@features/*/main/**'], + message: 'Feature preload must not reach into feature main internals.', + }, + ], + }, + ], + }, + }, + + { + name: 'feature-renderer-ui-guards', + files: ['src/features/*/renderer/ui/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { name: '@renderer/api', message: 'renderer/ui must stay presentational.' }, + { name: '@renderer/store', message: 'renderer/ui must stay store-free.' }, + { name: 'electron', message: 'renderer/ui must stay Electron-free.' }, + ], + patterns: [ + { group: ['@main/*'], message: 'renderer/ui must not import main modules.' }, + { group: ['@renderer/store/*'], message: 'renderer/ui must stay store-free.' }, + ], + }, + ], + }, + }, + // === IMPORTANT: eslint-config-prettier MUST be LAST === // This disables all ESLint rules that conflict with Prettier // Prettier handles formatting, ESLint handles code quality diff --git a/package.json b/package.json index 9c9c5cdc..1d963d43 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "main": "dist-electron/main/index.cjs", "scripts": { "dev": "node ./scripts/dev-with-runtime.mjs", + "dev:web": "node ./scripts/dev-web.mjs", "dev:kill": "node bin/kill-dev.js", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", "build": "electron-vite build", @@ -321,6 +322,9 @@ "tsconfig*.json" ], "paths": { + "@features/*": [ + "./src/features/*" + ], "@main/*": [ "./src/main/*" ], diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 21a1d228..ed8db002 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -71,7 +71,7 @@ export function drawAgents( 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); + drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); drawLaunchStage(ctx, x, y, r, node.launchVisualState, time); } @@ -122,7 +122,17 @@ export function drawAgents( 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); + drawLabel( + ctx, + x, + y, + r, + labelText, + color, + node.runtimeLabel, + node.launchStatusLabel, + node.launchVisualState + ); } // TODO: Context ring disabled — LeadContextUsage.percent is unreliable @@ -256,52 +266,87 @@ function drawLaunchStage( 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)); + const ringR = r + 8 + Math.sin(time * 3.2) * 1.4; + const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const dotOrbit = r + 11; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); - ctx.lineWidth = 2; + ctx.lineWidth = 2.5; + ctx.setLineDash([4, 5]); ctx.stroke(); + ctx.setLineDash([]); + for (let index = 0; index < 3; index += 1) { + const angle = time * 1.2 + (Math.PI * 2 * index) / 3; + ctx.beginPath(); + ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72); + ctx.fill(); + } break; } case 'spawning': { const ringR = r + 7; - const rotation = time * 2.4; + const rotation = time * 2.7; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); - ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72); - ctx.lineWidth = 2; + ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8); + ctx.lineWidth = 2.8; ctx.lineCap = 'round'; ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x, y, ringR + 4, rotation + Math.PI, rotation + Math.PI + Math.PI * 0.4); + ctx.strokeStyle = hexWithAlpha('#fbbf24', 0.65); + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.stroke(); + + const glow = ctx.createRadialGradient(x, y, r * 0.5, x, y, ringR + 12); + glow.addColorStop(0, hexWithAlpha('#f59e0b', 0.18)); + glow.addColorStop(1, hexWithAlpha('#f59e0b', 0)); + ctx.beginPath(); + ctx.arc(x, y, ringR + 12, 0, Math.PI * 2); + ctx.fillStyle = glow; + ctx.fill(); 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.strokeStyle = hexWithAlpha('#38bdf8', 0.48); + ctx.lineWidth = 1.9; + ctx.setLineDash([5, 4]); ctx.stroke(); + ctx.setLineDash([]); - 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(); + const orbit = time * 1.8; + for (let index = 0; index < 2; index += 1) { + const angle = orbit + Math.PI * index; + const dotX = x + Math.cos(angle) * ringR; + const dotY = y + Math.sin(angle) * ringR; + ctx.beginPath(); + ctx.arc(dotX, dotY, 2.3, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha(index === 0 ? '#67e8f9' : '#38bdf8', 0.92); + ctx.fill(); + } break; } case 'settling': { const ringR = r + 6; - const arc = 0.65 + 0.08 * Math.sin(time * 2.2); + const arc = 0.72 + 0.08 * Math.sin(time * 2.2); const rotation = time * 1.25; + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.18); + ctx.lineWidth = 1.4; + ctx.stroke(); + ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); - ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55); - ctx.lineWidth = 1.75; + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62); + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -310,10 +355,15 @@ function drawLaunchStage( 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.strokeStyle = hexWithAlpha('#ef4444', 0.72); + ctx.lineWidth = 2.4; ctx.lineCap = 'round'; ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x + ringR * 0.52, y - ringR * 0.5, 2.2, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#f87171', 0.92); + ctx.fill(); break; } } @@ -467,12 +517,8 @@ function drawBreathing( r: number, state: string, time: number, - spawnStatus?: GraphNode['spawnStatus'], - runtimeLabel?: string + spawnStatus?: GraphNode['spawnStatus'] ): void { - const hasRuntimeLabel = Boolean(runtimeLabel?.trim()); - const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14); - // Spawning: bright animated double ring + radial glow if (spawnStatus === 'spawning') { const ringR = r + AGENT_DRAW.orbitParticleOffset; @@ -505,12 +551,6 @@ function drawBreathing( ctx.stroke(); ctx.setLineDash([]); ctx.restore(); - - // "connecting" label below name - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2)); - ctx.fillText('connecting...', x, serviceLabelY); return; } @@ -532,12 +572,6 @@ function drawBreathing( ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse); ctx.lineWidth = 1.5; ctx.stroke(); - - // "waiting" label - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5)); - ctx.fillText('waiting...', x, serviceLabelY); return; } @@ -649,7 +683,9 @@ function drawLabel( r: number, label: string, color: string, - runtimeLabel?: string + runtimeLabel?: string, + launchStatusLabel?: string, + launchVisualState?: GraphNode['launchVisualState'] ): void { const labelY = y + r + AGENT_DRAW.labelYOffset; ctx.font = '9px monospace'; @@ -659,16 +695,27 @@ function drawLabel( ctx.fillText(label, x, labelY); const trimmedRuntimeLabel = runtimeLabel?.trim(); - if (!trimmedRuntimeLabel) { + const trimmedLaunchStatusLabel = launchStatusLabel?.trim(); + if (!trimmedRuntimeLabel && !trimmedLaunchStatusLabel) { return; } - ctx.font = '8px monospace'; - ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); - ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10); + let nextLineY = labelY + 10; + if (trimmedRuntimeLabel) { + ctx.font = '8px monospace'; + ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); + ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY); + nextLineY += 10; + } + + if (trimmedLaunchStatusLabel) { + ctx.font = '7px monospace'; + ctx.fillStyle = getLaunchStatusColor(launchVisualState); + ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY); + } } -function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string { +function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string { const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2); if (ctx.measureText(label).width <= maxWidth) return label; @@ -679,6 +726,23 @@ function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: n return `${out}…`; } +function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string { + switch (visualState) { + case 'waiting': + return hexWithAlpha('#d4d4d8', 0.8); + case 'spawning': + return hexWithAlpha('#f59e0b', 0.9); + case 'runtime_pending': + return hexWithAlpha('#67e8f9', 0.9); + case 'settling': + return hexWithAlpha('#22c55e', 0.9); + case 'error': + return hexWithAlpha('#ef4444', 0.92); + default: + return hexWithAlpha(COLORS.holoBright, 0.75); + } +} + /** * Draw context usage ring around lead node. */ diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 59d32dcc..30f6363f 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -1,3 +1,5 @@ +import { STABLE_SLOT_GEOMETRY } from '../layout/stableSlotGeometry'; + /** * Canvas rendering constants for the agent graph visualization. * Adapted from agent-flow's canvas-constants.ts (Apache 2.0). @@ -260,10 +262,12 @@ export const KANBAN_ZONE = { columnWidth: 180, /** Row height: pill (36) + gap (10) */ rowHeight: 46, + /** Space reserved for column header label */ + headerHeight: 20, /** Zone starts this far below member node center */ offsetY: 70, - /** Column order: todo → wip → done → review → approved */ + /** Column sequence: pending → wip → done → review → approved */ columns: ['todo', 'wip', 'done', 'review', 'approved'] as const, /** Max tasks shown per column (overflow hidden) */ - maxVisibleRows: 6, + maxVisibleRows: STABLE_SLOT_GEOMETRY.taskMaxVisibleRows, } as const; diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts index 81cdf5be..33862ef3 100644 --- a/packages/agent-graph/src/hooks/useGraphInteraction.ts +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -33,9 +33,9 @@ export function useGraphInteraction( clickedNodeId.current = hit; if (hit) { - // Only allow drag on member/lead nodes, not tasks or processes + // Stable-slot layout keeps lead fixed in the center. Only members can be dragged between slots. const hitNode = nodes.find((n) => n.id === hit); - if (hitNode && (hitNode.kind === 'member' || hitNode.kind === 'lead')) { + if (hitNode?.kind === 'member') { dragNodeId.current = hit; } } diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index bd6dd240..cd4d62ad 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -1,58 +1,22 @@ -/** - * Graph simulation hook using d3-force for MEMBER/LEAD nodes only. - * Task nodes are positioned by KanbanLayoutEngine (deterministic grid). - * - * CRITICAL: Animation state in useRef, NOT useState — no React re-renders at 60fps. - * This hook does NOT run its own RAF loop — the parent (GraphView) calls tick(). - */ +import { useCallback, useEffect, useRef } from 'react'; -import { useRef, useEffect, useCallback } from 'react'; -import { - forceSimulation, - forceCenter, - forceManyBody, - forceCollide, - forceLink, - type Simulation, - type SimulationNodeDatum, - type SimulationLinkDatum, -} from 'd3-force'; -import type { GraphNode, GraphEdge, GraphParticle, GraphNodeKind } from '../ports/types'; -import { FORCE, ANIM_SPEED, NODE } from '../constants/canvas-constants'; -import { getNodeStrategy } from '../strategies'; -import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects'; +import { ANIM_SPEED, NODE } from '../constants/canvas-constants'; 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'; + buildStableSlotLayoutSnapshot, + resolveNearestSlotAssignment, + snapshotToWorldBounds, + translateSlotFrame, + validateStableSlotLayout, + type StableSlotLayoutSnapshot, + type StableRect, + type SlotFrame, +} from '../layout/stableSlots'; +import { KanbanLayoutEngine } from '../layout/kanbanLayout'; -// ─── Force Node/Link types (properly typed, no loose `string`) ────────────── - -type InternalNodeKind = GraphNodeKind | 'launch-anchor' | 'activity-anchor'; - -interface ForceNode extends SimulationNodeDatum { - id: string; - kind: InternalNodeKind; - anchorForLeadId?: string; - anchorForNodeId?: string; -} - -interface ForceLink extends SimulationLinkDatum { - id: string; - edgeType: string; -} - -// ─── Simulation State (in ref, not useState) ──────────────────────────────── +import type { GraphEdge, GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment, GraphParticle } from '../ports/types'; +import type { WorldBounds } from '../layout/launchAnchor'; +import { createCompleteEffect, createSpawnEffect, type VisualEffect } from '../canvas/draw-effects'; export interface SimulationState { nodes: GraphNode[]; @@ -63,93 +27,34 @@ export interface SimulationState { } export interface UseGraphSimulationResult { - stateRef: React.MutableRefObject; - updateData: (nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => void; - /** Tick one simulation frame — called from parent's RAF loop */ + stateRef: { current: SimulationState }; + updateData: ( + nodes: GraphNode[], + edges: GraphEdge[], + particles: GraphParticle[], + teamName: string, + layout?: GraphLayoutPort + ) => void; tick: (dt: number) => void; setNodePosition: (nodeId: string, x: number, y: number) => void; + clearNodePosition: (nodeId: string) => void; + clearTransientOwnerPositions: () => void; + resolveNearestOwnerSlot: ( + nodeId: string, + x: number, + y: number + ) => { + assignment: GraphOwnerSlotAssignment; + displacedOwnerId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; + } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; - getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null; + getActivityWorldRect: (nodeId: string) => StableRect | null; getExtraWorldBounds: () => WorldBounds[]; } -// ─── Deterministic hash for stable initial positions ───────────────────────── - -/** Returns a value in [-0.5, 0.5] deterministically from string + seed */ -function deterministicPosition(id: string, seed: number): number { - let hash = seed * 2654435761; - for (let i = 0; i < id.length; i++) { - hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; - } - return ((hash & 0x7fffffff) % 1000) / 1000 - 0.5; -} - -function syncLaunchAnchors(forceNodes: ForceNode[]): void { - const forceNodeMap = new Map(); - for (const node of forceNodes) { - forceNodeMap.set(node.id, node); - } - const leadNode = forceNodes.find((node) => node.kind === 'lead'); - const leadX = leadNode?.x ?? leadNode?.fx ?? null; - - for (const node of forceNodes) { - let target: { x: number; y: number } | null = null; - if (node.kind === 'launch-anchor' && node.anchorForLeadId) { - const leadNode = forceNodeMap.get(node.anchorForLeadId); - if (!leadNode) continue; - target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); - } else if (node.kind === 'activity-anchor' && node.anchorForNodeId) { - const ownerNode = forceNodeMap.get(node.anchorForNodeId); - if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue; - target = getActivityAnchorTarget({ - nodeX: ownerNode.x ?? 0, - nodeY: ownerNode.y ?? 0, - nodeKind: ownerNode.kind, - leadX, - }); - } else { - continue; - } - if (!target) { - continue; - } - - node.fx = target.x; - node.fy = target.y; - node.x = target.x; - node.y = target.y; - node.vx = 0; - node.vy = 0; - } -} - -function updateLaunchAnchorCaches( - forceNodes: ForceNode[], - launchPositions: Map, - activityPositions: Map, - bounds: WorldBounds[] -): void { - launchPositions.clear(); - activityPositions.clear(); - bounds.length = 0; - - for (const node of forceNodes) { - const x = node.x ?? node.fx ?? 0; - const y = node.y ?? node.fy ?? 0; - if (node.kind === 'launch-anchor' && node.anchorForLeadId) { - launchPositions.set(node.anchorForLeadId, { x, y }); - bounds.push(getLaunchAnchorBounds(x, y)); - continue; - } - if (node.kind === 'activity-anchor' && node.anchorForNodeId) { - activityPositions.set(node.anchorForNodeId, { x, y }); - bounds.push(getHandoffAnchorBounds(x, y)); - } - } -} - -// ─── Hook ─────────────────────────────────────────────────────────────────── - export function useGraphSimulation(): UseGraphSimulationResult { const stateRef = useRef({ nodes: [], @@ -158,308 +63,484 @@ export function useGraphSimulation(): UseGraphSimulationResult { effects: [], time: 0, }); - - const simRef = useRef | null>(null); + const teamNameRef = useRef(''); + const layoutRef = useRef(undefined); + const layoutSnapshotRef = useRef(null); + const lastValidSnapshotByTeamRef = useRef(new Map()); + const dragOwnerPositionsRef = useRef(new Map()); const launchAnchorPositionsRef = useRef(new Map()); - const activityAnchorPositionsRef = useRef(new Map()); + const activityRectByNodeIdRef = useRef(new Map()); const extraWorldBoundsRef = useRef([]); - // Initialize d3-force simulation - const initSimulation = useCallback(() => { - if (simRef.current) simRef.current.stop(); - - const sim = forceSimulation([]) - .force('center', forceCenter(0, 0).strength(FORCE.centerStrength)) - .force('charge', forceManyBody().strength((d) => { - if (d.kind === 'launch-anchor' || d.kind === 'activity-anchor') { - return 0; - } - return getNodeStrategy(d.kind).getChargeStrength(); - })) - .force('collide', forceCollide().radius((d) => { - if (d.kind === 'launch-anchor') { - return LAUNCH_ANCHOR_LAYOUT.collisionRadius; - } - if (d.kind === 'activity-anchor') { - return ACTIVITY_ANCHOR_LAYOUT.collisionRadius; - } - return getNodeStrategy(d.kind).getCollisionRadius(); - })) - .force('link', forceLink([]).id((d) => d.id).distance((d) => { - return FORCE.linkDistance[d.edgeType as keyof typeof FORCE.linkDistance] ?? 200; - }).strength(FORCE.linkStrength)) - .alphaDecay(FORCE.alphaDecay) - .velocityDecay(FORCE.velocityDecay) - .stop(); // We tick manually - - simRef.current = sim; - return sim; - }, []); - - // Track node set identity to avoid re-running simulation when data reference changes but content is same - const lastNodeIdsHash = useRef(''); - - // Sync graph data to d3-force — ONLY when node set actually changes - const syncSimulation = useCallback((nodes: GraphNode[], edges: GraphEdge[]) => { - // Hash includes IDs + mutable fields (status, owner, review) to detect real changes - const hash = nodes.map((n) => `${n.id}:${n.state}:${n.ownerId ?? ''}:${n.taskStatus ?? ''}:${n.reviewState ?? ''}`).sort().join(','); - if (hash === lastNodeIdsHash.current) return; // same nodes — skip re-simulation - lastNodeIdsHash.current = hash; - - let sim = simRef.current; - if (!sim) sim = initSimulation(); - - const prevInternalPositions = new Map(); - for (const forceNode of sim.nodes()) { - if (!isLaunchAnchorId(forceNode.id) && !isActivityAnchorId(forceNode.id)) continue; - prevInternalPositions.set(forceNode.id, { - x: forceNode.x ?? forceNode.fx ?? 0, - y: forceNode.y ?? forceNode.fy ?? 0, - }); - } - - // Tasks excluded from d3-force — positioned by KanbanLayoutEngine - const forceNodes: ForceNode[] = nodes - .filter((n) => n.kind !== 'task') - .map((n) => ({ - id: n.id, - kind: n.kind, - // Deterministic initial positions from node ID hash — same layout every time - x: n.x ?? deterministicPosition(n.id, 0) * 500, - y: n.y ?? deterministicPosition(n.id, 1) * 500, - vx: n.vx ?? 0, - vy: n.vy ?? 0, - fx: n.fx, - 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 - .filter((e) => forceNodeIds.has(e.source) && forceNodeIds.has(e.target)) - .map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - edgeType: e.type, - })); - - sim.nodes(forceNodes); - (sim.force('link') as ReturnType)?.links(forceLinks); - sim.alpha(1); - - // Run simulation to near-completion so nodes are settled on first render - 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 - const simNodeMap = new Map(); - for (const sn of sim.nodes()) simNodeMap.set(sn.id, sn); - for (const node of nodes) { - const sn = simNodeMap.get(node.id); - if (sn) { - node.x = sn.x; - node.y = sn.y; - node.vx = sn.vx; - node.vy = sn.vy; - } - } - - // 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 const prevNodeIdsRef = useRef(new Set()); const prevNodeStatesRef = useRef(new Map()); - // All node IDs ever seen — never shrinks. Prevents spawn effects replaying - // when nodes reappear after being filtered out (e.g. Tasks toggle OFF→ON). const allKnownNodeIdsRef = useRef(new Set()); - // Update data from adapter - const updateData = useCallback((nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => { + const applyCurrentLayout = useCallback(() => { const state = stateRef.current; - const prevStates = prevNodeStatesRef.current; + const nextSnapshot = buildStableSlotLayoutSnapshot({ + teamName: teamNameRef.current, + nodes: state.nodes, + layout: layoutRef.current, + }); - // Preserve positions from previous frame - const prevPositions = new Map(); - for (const n of state.nodes) { - if (n.x != null && n.y != null) { - prevPositions.set(n.id, { x: n.x, y: n.y, vx: n.vx ?? 0, vy: n.vy ?? 0 }); + if (nextSnapshot) { + const validation = validateStableSlotLayout(nextSnapshot); + if (validation.valid) { + commitSnapshotGeometry({ + nodes: state.nodes, + snapshot: nextSnapshot, + teamName: teamNameRef.current, + layoutSnapshotRef, + lastValidSnapshotByTeamRef, + dragOwnerPositionsRef, + launchAnchorPositionsRef, + activityRectByNodeIdRef, + extraWorldBoundsRef, + }); + return; + } + + console.warn( + `[agent-graph] invalid stable slot layout for team=${teamNameRef.current}: ${validation.reason ?? 'unknown reason'}` + ); + + const lastValidSnapshot = lastValidSnapshotByTeamRef.current.get(teamNameRef.current); + if (lastValidSnapshot) { + commitSnapshotGeometry({ + nodes: state.nodes, + snapshot: lastValidSnapshot, + teamName: teamNameRef.current, + layoutSnapshotRef, + lastValidSnapshotByTeamRef, + dragOwnerPositionsRef, + launchAnchorPositionsRef, + activityRectByNodeIdRef, + extraWorldBoundsRef, + fillMissingFallbackPositions: true, + }); + return; } } - for (const n of nodes) { - const prev = prevPositions.get(n.id); - if (prev && n.x == null) { - n.x = prev.x; - n.y = prev.y; - n.vx = prev.vx; - n.vy = prev.vy; - } - } - - // Detect state transitions → spawn visual effects - const allKnown = allKnownNodeIdsRef.current; - for (const node of nodes) { - // New node appeared → spawn effect (only if truly new, never seen before). - // Nodes returning from filter (e.g. Tasks toggle OFF→ON) are already in allKnown. - if (!allKnown.has(node.id) && node.x != null && node.y != null) { - const nodeR = node.kind === 'lead' ? NODE.radiusLead : node.kind === 'member' ? NODE.radiusMember : undefined; - state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeR)); - } - - // Task completed → shatter effect - const prevState = prevStates.get(node.id); - if (prevState && prevState !== 'complete' && node.state === 'complete' && node.x != null && node.y != null) { - state.effects.push(createCompleteEffect(node.x, node.y, node.color ?? getStateColor(node.state))); - } - } - - // Update tracking refs — allKnown only grows, never shrinks - for (const n of nodes) allKnown.add(n.id); - prevNodeIdsRef.current = new Set(nodes.map((n) => n.id)); - prevNodeStatesRef.current = new Map(nodes.map((n) => [n.id, n.state])); - - state.nodes = nodes; - state.edges = edges; - state.particles = mergeParticles(state.particles, particles); - - syncSimulation(nodes, edges); - }, [syncSimulation]); - - // Tick one frame (called by parent's RAF loop) - const tick = useCallback((dt: number) => { - tickFrame( - stateRef.current, - simRef.current, - dt, - launchAnchorPositionsRef.current, - activityAnchorPositionsRef.current, - extraWorldBoundsRef.current - ); + resetToFallbackLayout({ + nodes: state.nodes, + layoutSnapshotRef, + launchAnchorPositionsRef, + activityRectByNodeIdRef, + extraWorldBoundsRef, + }); }, []); - 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 updateData = useCallback( + ( + nodes: GraphNode[], + edges: GraphEdge[], + particles: GraphParticle[], + teamName: string, + layout?: GraphLayoutPort + ) => { + const state = stateRef.current; + teamNameRef.current = teamName; + layoutRef.current = layout; - const sim = simRef.current; - if (!sim) { + preserveReusableNodePositions(nodes, state.nodes); + recordNodeLifecycleEffects(state.effects, nodes, prevNodeStatesRef.current, allKnownNodeIdsRef.current); + prevNodeIdsRef.current = new Set(nodes.map((node) => node.id)); + prevNodeStatesRef.current = new Map(nodes.map((node) => [node.id, node.state])); + + state.nodes = nodes; + state.edges = edges; + state.particles = mergeParticles(state.particles, particles); + applyCurrentLayout(); + }, + [applyCurrentLayout] + ); + + const tick = useCallback((dt: number) => { + const state = stateRef.current; + state.time += dt; + + const nextParticles: GraphParticle[] = []; + for (const particle of state.particles) { + particle.progress += dt * ANIM_SPEED.particleSpeed * 0.5; + if (particle.progress < 1) { + nextParticles.push(particle); + } + } + state.particles = nextParticles; + + const nextEffects: VisualEffect[] = []; + for (const effect of state.effects) { + effect.age += dt; + if (effect.age < effect.duration) { + nextEffects.push(effect); + } + } + state.effects = nextEffects; + }, []); + + const setNodePosition = useCallback( + (nodeId: string, x: number, y: number) => { + const node = stateRef.current.nodes.find((candidate) => candidate.id === nodeId); + if (node?.kind !== 'member') { + return; + } + dragOwnerPositionsRef.current.set(nodeId, { x, y }); + applyCurrentLayout(); + }, + [applyCurrentLayout] + ); + + const clearNodePosition = useCallback( + (nodeId: string) => { + if (!dragOwnerPositionsRef.current.delete(nodeId)) { + return; + } + applyCurrentLayout(); + }, + [applyCurrentLayout] + ); + + const clearTransientOwnerPositions = useCallback(() => { + if (dragOwnerPositionsRef.current.size === 0) { return; } + dragOwnerPositionsRef.current.clear(); + applyCurrentLayout(); + }, [applyCurrentLayout]); - 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; - } + const resolveNearestOwnerSlot = useCallback( + (nodeId: string, x: number, y: number) => { + const snapshot = layoutSnapshotRef.current; + if (!snapshot) { + return null; + } + return resolveNearestSlotAssignment({ + ownerId: nodeId, + ownerX: x, + ownerY: y, + nodes: stateRef.current.nodes, + snapshot, + layout: layoutRef.current, + }); + }, + [] + ); - syncLaunchAnchors(sim.nodes()); - updateLaunchAnchorCaches( - sim.nodes(), - launchAnchorPositionsRef.current, - activityAnchorPositionsRef.current, - extraWorldBoundsRef.current - ); - }, []); - - // Cleanup useEffect(() => { return () => { - simRef.current?.stop(); + dragOwnerPositionsRef.current.clear(); + launchAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); + extraWorldBoundsRef.current = []; + layoutSnapshotRef.current = null; + lastValidSnapshotByTeamRef.current.clear(); }; }, []); - 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, + clearNodePosition, + clearTransientOwnerPositions, + resolveNearestOwnerSlot, + getLaunchAnchorWorldPosition: (leadNodeId: string) => + launchAnchorPositionsRef.current.get(leadNodeId) ?? null, + getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, + getExtraWorldBounds: () => extraWorldBoundsRef.current, }; } -function mergeParticles( - existing: GraphParticle[], - incoming: GraphParticle[], -): GraphParticle[] { +function applySnapshotToNodes( + nodes: GraphNode[], + snapshot: StableSlotLayoutSnapshot, + dragOwnerPositions: ReadonlyMap +): void { + const translatedFrames = getTranslatedMemberFrames(snapshot, dragOwnerPositions); + const translatedFrameByOwnerId = new Map( + translatedFrames.map((frame) => [frame.ownerId, frame] as const) + ); + const leadFrame = snapshot.leadSlotFrame; + const leadId = snapshot.leadNodeId; + + for (const node of nodes) { + if (node.kind === 'lead' && node.id === leadId) { + node.x = leadFrame.ownerX; + node.y = leadFrame.ownerY; + node.fx = leadFrame.ownerX; + node.fy = leadFrame.ownerY; + node.vx = 0; + node.vy = 0; + continue; + } + + if (node.kind === 'member') { + const frame = translatedFrameByOwnerId.get(node.id); + if (!frame) { + continue; + } + node.x = frame.ownerX; + node.y = frame.ownerY; + node.fx = frame.ownerX; + node.fy = frame.ownerY; + node.vx = 0; + node.vy = 0; + } + } + + positionProcessNodes(nodes, [snapshot.leadSlotFrame, ...translatedFrames]); + KanbanLayoutEngine.layout(nodes, { + memberSlotFrames: translatedFrames, + leadSlotFrame: snapshot.leadSlotFrame, + unassignedTaskRect: snapshot.unassignedTaskRect, + }); + positionCrossTeamNodes(nodes, snapshot.fitBounds); +} + +function commitSnapshotGeometry(args: { + nodes: GraphNode[]; + snapshot: StableSlotLayoutSnapshot; + teamName: string; + layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; + lastValidSnapshotByTeamRef: { current: Map }; + dragOwnerPositionsRef: { current: ReadonlyMap }; + launchAnchorPositionsRef: { current: Map }; + activityRectByNodeIdRef: { current: Map }; + extraWorldBoundsRef: { current: WorldBounds[] }; + fillMissingFallbackPositions?: boolean; +}): void { + const { + nodes, + snapshot, + teamName, + layoutSnapshotRef, + lastValidSnapshotByTeamRef, + dragOwnerPositionsRef, + launchAnchorPositionsRef, + activityRectByNodeIdRef, + extraWorldBoundsRef, + fillMissingFallbackPositions = false, + } = args; + + layoutSnapshotRef.current = snapshot; + lastValidSnapshotByTeamRef.current.set(teamName, snapshot); + applySnapshotToNodes(nodes, snapshot, dragOwnerPositionsRef.current); + if (fillMissingFallbackPositions) { + fallbackPositionNodes(nodes); + } + + launchAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); + extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); + + for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { + activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); + } + + if (snapshot.leadNodeId) { + activityRectByNodeIdRef.current.set( + snapshot.leadNodeId, + snapshot.leadSlotFrame.activityColumnRect + ); + } +} + +function resetToFallbackLayout(args: { + nodes: GraphNode[]; + layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; + launchAnchorPositionsRef: { current: Map }; + activityRectByNodeIdRef: { current: Map }; + extraWorldBoundsRef: { current: WorldBounds[] }; +}): void { + const { + nodes, + layoutSnapshotRef, + launchAnchorPositionsRef, + activityRectByNodeIdRef, + extraWorldBoundsRef, + } = args; + + layoutSnapshotRef.current = null; + launchAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); + extraWorldBoundsRef.current = []; + fallbackPositionNodes(nodes); + KanbanLayoutEngine.layout(nodes); +} + +function preserveReusableNodePositions( + nodes: GraphNode[], + previousNodes: GraphNode[] +): void { + const previousPositionById = new Map( + previousNodes + .filter((node) => node.x != null && node.y != null) + .map((node) => [ + node.id, + { x: node.x!, y: node.y!, vx: node.vx ?? 0, vy: node.vy ?? 0 }, + ] as const) + ); + + for (const node of nodes) { + const previous = previousPositionById.get(node.id); + if ( + !previous || + node.kind === 'lead' || + node.kind === 'member' || + node.kind === 'task' || + node.kind === 'process' + ) { + continue; + } + node.x = previous.x; + node.y = previous.y; + node.vx = previous.vx; + node.vy = previous.vy; + } +} + +function recordNodeLifecycleEffects( + effects: VisualEffect[], + nodes: GraphNode[], + prevStates: ReadonlyMap, + allKnown: Set +): void { + for (const node of nodes) { + if (!allKnown.has(node.id) && node.x != null && node.y != null) { + const nodeRadius = resolveNodeEffectRadius(node); + effects.push( + createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeRadius) + ); + } + + const prevState = prevStates.get(node.id); + if ( + prevState && + prevState !== 'complete' && + node.state === 'complete' && + node.x != null && + node.y != null + ) { + effects.push(createCompleteEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + } + + allKnown.add(node.id); + } +} + +function resolveNodeEffectRadius(node: GraphNode): number | undefined { + if (node.kind === 'lead') { + return NODE.radiusLead; + } + if (node.kind === 'member') { + return NODE.radiusMember; + } + return undefined; +} + +function getTranslatedMemberFrames( + snapshot: StableSlotLayoutSnapshot, + dragOwnerPositions: ReadonlyMap +): SlotFrame[] { + return snapshot.memberSlotFrames.map((frame) => { + const dragPosition = dragOwnerPositions.get(frame.ownerId); + if (!dragPosition) { + return frame; + } + return translateSlotFrame(frame, dragPosition.x - frame.ownerX, dragPosition.y - frame.ownerY); + }); +} + +function positionProcessNodes(nodes: GraphNode[], frames: readonly SlotFrame[]): void { + const frameByOwnerId = new Map(frames.map((frame) => [frame.ownerId, frame] as const)); + const processNodesByOwnerId = new Map(); + + for (const node of nodes) { + if (node.kind !== 'process' || !node.ownerId) { + continue; + } + const existing = processNodesByOwnerId.get(node.ownerId) ?? []; + existing.push(node); + processNodesByOwnerId.set(node.ownerId, existing); + } + + for (const [ownerId, processNodes] of processNodesByOwnerId) { + const frame = frameByOwnerId.get(ownerId); + if (!frame) { + continue; + } + + const gap = 42; + const totalWidth = Math.max(0, (processNodes.length - 1) * gap); + for (const [index, node] of processNodes.entries()) { + const x = frame.ownerX - totalWidth / 2 + index * gap; + const y = frame.processBandRect.top + frame.processBandRect.height / 2; + node.x = x; + node.y = y; + node.fx = x; + node.fy = y; + node.vx = 0; + node.vy = 0; + } + } +} + +function positionCrossTeamNodes(nodes: GraphNode[], fitBounds: StableSlotLayoutSnapshot['fitBounds']): void { + const crossTeamNodes = nodes.filter((node) => node.kind === 'crossteam'); + if (crossTeamNodes.length === 0) { + return; + } + + const radius = + Math.max( + Math.abs(fitBounds.left), + Math.abs(fitBounds.right), + Math.abs(fitBounds.top), + Math.abs(fitBounds.bottom) + ) + 220; + const startAngle = (-150 * Math.PI) / 180; + const endAngle = (150 * Math.PI) / 180; + + crossTeamNodes.forEach((node, index) => { + const t = + crossTeamNodes.length === 1 ? 0.5 : index / Math.max(crossTeamNodes.length - 1, 1); + const angle = startAngle + (endAngle - startAngle) * t; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + node.x = x; + node.y = y; + node.fx = x; + node.fy = y; + node.vx = 0; + node.vy = 0; + }); +} + +function fallbackPositionNodes(nodes: GraphNode[]): void { + nodes.forEach((node, index) => { + if (node.kind === 'task') { + return; + } + if (node.x != null && node.y != null) { + return; + } + const row = Math.floor(index / 4); + const col = index % 4; + const x = (col - 1.5) * 220; + const y = (row - 1) * 220; + node.x = x; + node.y = y; + node.fx = x; + node.fy = y; + node.vx = 0; + node.vy = 0; + }); +} + +function mergeParticles(existing: GraphParticle[], incoming: GraphParticle[]): GraphParticle[] { if (existing.length === 0) return incoming; if (incoming.length === 0) return existing; @@ -472,66 +553,3 @@ function mergeParticles( } return merged; } - -// ─── Frame Tick (pure function) ───────────────────────────────────────────── - -function tickFrame( - state: SimulationState, - sim: Simulation | null, - dt: number, - launchAnchorPositions: Map, - activityAnchorPositions: Map, - extraWorldBounds: WorldBounds[], -): void { - state.time += dt; - - // Tick d3-force (only when simulation is still active) - if (sim && sim.alpha() > 0.001) { - syncLaunchAnchors(sim.nodes()); - sim.tick(1); - - const simNodes = sim.nodes(); - const simNodeMap = new Map(); - for (const sn of simNodes) simNodeMap.set(sn.id, sn); - - for (const node of state.nodes) { - const sn = simNodeMap.get(node.id); - if (sn) { - node.x = sn.x; - node.y = sn.y; - node.vx = sn.vx; - 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 - KanbanLayoutEngine.layout(state.nodes); - - // Update particle progress — in-place removal (no new array allocation) - let pw = 0; - for (let i = 0; i < state.particles.length; i++) { - const p = state.particles[i]; - p.progress += dt * ANIM_SPEED.particleSpeed * 0.5; - if (p.progress < 1) state.particles[pw++] = p; - } - state.particles.length = pw; - - // Update effects — in-place removal - let ew = 0; - for (let i = 0; i < state.effects.length; i++) { - const fx = state.effects[i]; - fx.age += dt; - if (fx.age < fx.duration) state.effects[ew++] = fx; - } - state.effects.length = ew; -} diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts index 27a12196..6eda9d4d 100644 --- a/packages/agent-graph/src/index.ts +++ b/packages/agent-graph/src/index.ts @@ -9,6 +9,7 @@ // ─── Components ────────────────────────────────────────────────────────────── export { GraphView } from './ui/GraphView'; export type { GraphViewProps } from './ui/GraphView'; +export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane'; // ─── Port Interfaces (for adapters in host project) ───────────────────────── export type { GraphDataPort } from './ports/GraphDataPort'; @@ -21,6 +22,9 @@ export type { GraphEdge, GraphParticle, GraphActivityItem, + GraphOwnerSlotAssignment, + GraphLayoutPort, + GraphLayoutVersion, GraphNodeKind, GraphNodeState, GraphLaunchVisualState, diff --git a/packages/agent-graph/src/layout/activityLane.ts b/packages/agent-graph/src/layout/activityLane.ts index a5766933..dfae2843 100644 --- a/packages/agent-graph/src/layout/activityLane.ts +++ b/packages/agent-graph/src/layout/activityLane.ts @@ -1,36 +1,20 @@ -import { KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants'; +import { CAMERA, NODE } from '../constants/canvas-constants'; import type { GraphActivityItem, GraphNode } from '../ports/types'; +import { createStableSlotActivityLane } from './stableSlotGeometry'; -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 STABLE_SLOT_ACTIVITY = createStableSlotActivityLane({ + nodeMetrics: { + radiusLead: NODE.radiusLead, + radiusMember: NODE.radiusMember, + }, + zoomRange: { + minZoom: CAMERA.minZoom, + maxZoom: CAMERA.maxZoom, + }, +}); -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 const ACTIVITY_LANE = STABLE_SLOT_ACTIVITY.lane; +export const ACTIVITY_ANCHOR_LAYOUT = STABLE_SLOT_ACTIVITY.anchor; export interface ActivityLaneWindow { items: GraphActivityItem[]; @@ -51,6 +35,32 @@ export interface ActivityLaneItemHit { export type ActivityLaneSide = 'left' | 'right'; +export interface ActivityLaneScreenRect { + id: string; + side: ActivityLaneSide; + x: number; + y: number; + width: number; + height: number; +} + +export interface ActivityLaneWorldRect { + id: string; + side: ActivityLaneSide; + x: number; + y: number; + width: number; + height: number; +} + +export interface ActivityLaneWorldBounds { + ownerId: string; + left: number; + top: number; + right: number; + bottom: number; +} + export function resolveActivityLaneSide(args: { nodeKind: 'lead' | 'member'; nodeX: number; @@ -72,18 +82,14 @@ export function getActivityAnchorTarget(args: { 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, - }; - } - + const { nodeX, nodeY, nodeKind } = args; return { - x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX, - y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY, + x: nodeX - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2, + y: + nodeY + + (nodeKind === 'lead' + ? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY + : ACTIVITY_ANCHOR_LAYOUT.memberOffsetY), }; } @@ -93,16 +99,42 @@ export function getActivityLaneBounds(anchorX: number, anchorY: 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, + left: anchorX, + top: anchorY, + right: anchorX + ACTIVITY_ANCHOR_LAYOUT.reservedWidth, + bottom: anchorY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight, }; } +export function buildVisibleActivityLaneBounds( + nodes: GraphNode[], + activityPositions: ReadonlyMap +): ActivityLaneWorldBounds[] { + const bounds: ActivityLaneWorldBounds[] = []; + + for (const node of nodes) { + if (node.kind !== 'lead' && node.kind !== 'member') { + continue; + } + const visibleCount = node.activityItems?.length ?? 0; + const overflowCount = node.activityOverflowCount ?? 0; + if (visibleCount <= 0 && overflowCount <= 0) { + continue; + } + const topLeft = activityPositions.get(node.id); + if (!topLeft) { + continue; + } + bounds.push({ + ownerId: node.id, + ...getActivityLaneBounds(topLeft.x, topLeft.y), + }); + } + + return bounds; +} + export function getActivityLaneScale(zoom: number): number { return Math.max(ACTIVITY_LANE.minScale, Math.min(ACTIVITY_LANE.maxScale, zoom)); } @@ -120,10 +152,8 @@ export function getActivityAnchorScreenPlacement(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 x = anchorX * zoom + cameraX; + const y = anchorY * zoom + cameraY; const right = x + scaledWidth; const bottom = y + scaledHeight; @@ -152,6 +182,49 @@ export function getVisibleActivityWindow( }; } +export function packActivityLaneScreenRects( + rects: ActivityLaneScreenRect[], + gap = 8 +): Map { + return packActivityLaneRects(rects, gap, true); +} + +export function packActivityLaneWorldRects( + rects: ActivityLaneWorldRect[], + gap = 8 +): Map { + return packActivityLaneRects(rects, gap, false); +} + +function packActivityLaneRects( + rects: T[], + gap = 8, + groupBySide = true +): Map { + const placements = new Map(); + for (const side of resolvePackedActivitySides(groupBySide)) { + const sideRects = rects + .filter((rect) => !groupBySide || rect.side === side) + .sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y)); + const placed: (T & { placedY: number })[] = []; + + for (const rect of sideRects) { + const placedY = resolvePackedActivityY(rect, placed, gap); + placed.push({ ...rect, placedY }); + placements.set(rect.id, { x: rect.x, y: placedY }); + } + } + + return placements; +} + export function findActivityItemAt( worldX: number, worldY: number, @@ -177,13 +250,17 @@ export function findActivityItemAt( for (let index = 0; index < items.length; index += 1) { const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight; + const item = items.at(index); + if (!item) { + continue; + } if ( worldX >= left && worldX <= left + ACTIVITY_LANE.width && worldY >= itemTop && worldY <= itemTop + ACTIVITY_LANE.itemHeight ) { - return { ownerNodeId: node.id, item: items[index] }; + return { ownerNodeId: node.id, item }; } } } @@ -194,3 +271,37 @@ export function findActivityItemAt( export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } { return node.kind === 'lead' || node.kind === 'member'; } + +function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { + return aStart < bEnd && aEnd > bStart; +} + +function resolvePackedActivitySides(groupBySide: boolean): readonly ActivityLaneSide[] { + return groupBySide ? ['left', 'right'] : ['left']; +} + +function resolvePackedActivityY( + rect: T, + placed: readonly (T & { placedY: number })[], + gap: number +): number { + let placedY = rect.y; + + for (const prev of placed) { + if (!rangesOverlap(rect.x, rect.x + rect.width, prev.x, prev.x + prev.width)) { + continue; + } + + const prevBottom = prev.placedY + prev.height; + if (placedY < prevBottom + gap && placedY + rect.height > prev.placedY - gap) { + placedY = prevBottom + gap; + } + } + + return placedY; +} diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index ccbb3be2..4c1475ad 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -8,9 +8,9 @@ */ import type { GraphNode } from '../ports/types'; -import { KANBAN_ZONE } from '../constants/canvas-constants'; +import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; -import { resolveActivityLaneSide } from './activityLane'; +import type { SlotFrame, StableRect } from './stableSlots'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -48,7 +48,7 @@ export function getOwnerKanbanBaseX(args: { columnWidth: number; leadX?: number | null; }): number { - const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args; + const { ownerX, ownerKind, activeColumnCount, columnWidth } = args; if (activeColumnCount <= 0) { return ownerX; } @@ -57,17 +57,7 @@ export function getOwnerKanbanBaseX(args: { return ownerX - (activeColumnCount * columnWidth) / 2; } - const side = resolveActivityLaneSide({ - nodeKind: ownerKind, - nodeX: ownerX, - leadX, - }); - - if (side === 'left') { - return ownerX; - } - - return ownerX - (activeColumnCount - 1) * columnWidth; + return ownerX - ((activeColumnCount - 1) * columnWidth) / 2; } export class KanbanLayoutEngine { @@ -78,26 +68,52 @@ export class KanbanLayoutEngine { static readonly #colTasks = new Map(); /** Zone info for rendering column headers — updated each layout() call */ - static zones: KanbanZoneInfo[] = []; + static readonly zones: KanbanZoneInfo[] = []; /** * Position all task nodes in kanban columns relative to their owner. * Call AFTER d3-force settles member positions, BEFORE drawing. */ - static layout(nodes: GraphNode[]): void { + static layout( + nodes: GraphNode[], + options?: { + memberSlotFrames?: readonly SlotFrame[]; + leadSlotFrame?: SlotFrame | null; + unassignedTaskRect?: StableRect | null; + } + ): void { 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 ownerSlotFrameByOwnerId = new Map( + (options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const) + ); + if (options?.leadSlotFrame) { + ownerSlotFrameByOwnerId.set(options.leadSlotFrame.ownerId, options.leadSlotFrame); + } const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); const unassigned = this.#unassigned; unassigned.length = 0; + const hasLayoutOwner = (ownerId: string): boolean => { + const owner = nodeMap.get(ownerId); + if (!owner) { + return false; + } + if (owner.kind === 'lead') { + return ownerSlotFrameByOwnerId.has(ownerId); + } + if (owner.kind === 'member') { + return ownerSlotFrameByOwnerId.has(ownerId); + } + return false; + }; for (const n of nodes) { if (n.kind !== 'task') continue; - if (n.ownerId) { + if (n.ownerId && hasLayoutOwner(n.ownerId)) { let group = tasksByOwner.get(n.ownerId); if (!group) { group = []; @@ -110,16 +126,22 @@ export class KanbanLayoutEngine { } // Reset zones - this.zones = []; + this.zones.length = 0; 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, ownerId, leadX); + if (owner?.x == null || owner?.y == null) continue; + const zoneInfo = KanbanLayoutEngine.#layoutZone( + tasks, + owner, + ownerId, + leadX, + ownerSlotFrameByOwnerId.get(ownerId) ?? null + ); if (zoneInfo) this.zones.push(zoneInfo); } - KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes); + KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes, options?.unassignedTaskRect ?? null); } // ─── Private ────────────────────────────────────────────────────────────── @@ -128,13 +150,12 @@ export class KanbanLayoutEngine { tasks: GraphNode[], owner: GraphNode, ownerId: string, - leadX: number | null + leadX: number | null, + slotFrame: SlotFrame | null ): KanbanZoneInfo | null { - const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; - const headerHeight = 20; // space for column header label + const { columnWidth, rowHeight, offsetY, columns, headerHeight } = KANBAN_ZONE; const ownerX = owner.x ?? 0; const ownerY = owner.y ?? 0; - const baseY = ownerY + offsetY; // Classify tasks into columns const colTasks = KanbanLayoutEngine.#colTasks; @@ -157,15 +178,21 @@ export class KanbanLayoutEngine { if (activeColumns.length === 0) return null; - // 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({ + let baseX = getOwnerKanbanBaseX({ ownerX, ownerKind: owner.kind, activeColumnCount: activeColumns.length, columnWidth, leadX, }); + let baseY: number; + + if (slotFrame) { + baseX = slotFrame.kanbanBandRect.left + TASK_PILL.width / 2; + baseY = slotFrame.kanbanBandRect.top; + } else { + baseY = ownerY + offsetY; + } // Build headers + position tasks const headers: KanbanColumnHeader[] = []; @@ -190,8 +217,8 @@ export class KanbanLayoutEngine { for (const [rowIdx, task] of col.tasks.entries()) { const targetX = colX; const targetY = baseY + headerHeight + rowIdx * rowHeight; - task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; - task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; + task.x = slotFrame ? targetX : task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; + task.y = slotFrame ? targetY : task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.fx = task.x; task.fy = task.y; task.vx = 0; @@ -215,11 +242,52 @@ export class KanbanLayoutEngine { } } - static #layoutUnassigned(tasks: GraphNode[], allNodes: GraphNode[]): void { + static #layoutUnassigned( + tasks: GraphNode[], + allNodes: GraphNode[], + unassignedTaskRect: StableRect | null + ): void { if (tasks.length === 0) return; const { columnWidth, rowHeight } = KANBAN_ZONE; + if (unassignedTaskRect) { + const cols = Math.min(Math.max(tasks.length, 1), 5); + const baseX = unassignedTaskRect.left + TASK_PILL.width / 2; + const baseY = unassignedTaskRect.top; + const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0); + + this.zones.push({ + ownerId: '__unassigned__', + ownerX: 0, + ownerY: baseY - 48, + headers: [ + { + label: 'Unassigned', + x: 0, + y: baseY, + color: COLORS.taskPending, + overflowCount, + overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, + }, + ], + }); + + for (const [idx, task] of tasks.entries()) { + const col = idx % cols; + const row = Math.floor(idx / cols); + const targetX = baseX + col * columnWidth; + const targetY = baseY + row * rowHeight; + task.x = targetX; + task.y = targetY; + task.fx = targetX; + task.fy = targetY; + task.vx = 0; + task.vy = 0; + } + return; + } + // Find the lowest Y of ALL positioned nodes (members + their owned tasks) let sumX = 0; let maxY = -Infinity; diff --git a/packages/agent-graph/src/layout/launchAnchor.ts b/packages/agent-graph/src/layout/launchAnchor.ts index 522c818d..01bd4436 100644 --- a/packages/agent-graph/src/layout/launchAnchor.ts +++ b/packages/agent-graph/src/layout/launchAnchor.ts @@ -1,9 +1,9 @@ import { NODE } from '../constants/canvas-constants'; import { ACTIVITY_ANCHOR_LAYOUT, - getActivityAnchorTarget, - getActivityLaneBounds, + resolveActivityLaneSide, } from './activityLane'; +import { createStableSlotLaunchAnchorLayout } from './stableSlotGeometry'; export interface WorldBounds { left: number; @@ -19,17 +19,9 @@ export interface LaunchAnchorScreenPlacement { 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; +export const LAUNCH_ANCHOR_LAYOUT = createStableSlotLaunchAnchorLayout({ + radiusLead: NODE.radiusLead, +}); const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:'; const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:'; @@ -76,9 +68,38 @@ 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 getHandoffAnchorTarget(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 getHandoffAnchorBounds(anchorX: number, anchorY: number): WorldBounds { + 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 getLaunchAnchorScreenPlacement(args: { anchorX: number; diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts new file mode 100644 index 00000000..d1e5265f --- /dev/null +++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts @@ -0,0 +1,158 @@ +export const STABLE_SLOT_GEOMETRY = { + slotVerticalGap: 24, + slotHorizontalGap: 32, + ringGap: 140, + centralSafetyPadding: 48, + memberSlotInnerPadding: 16, + centralBlockGap: 56, + ringPadding: 32, + unassignedGap: 72, + maxGeneratedRings: 12, + ownerCollisionPadding: 28, + ownerBandHeight: 96, + ownerMinWidth: 200, + processBandHeight: 32, + processRailWidth: 220, + taskMaxVisibleRows: 5, +} as const; + +export const STABLE_SLOT_SECTOR_VECTORS = [ + { x: 0, y: -1 }, + { x: 0.82, y: -0.57 }, + { x: 0.82, y: 0.57 }, + { x: 0, y: 1 }, + { x: -0.82, y: 0.57 }, + { x: -0.82, y: -0.57 }, +] as const; + +export interface StableSlotNodeMetrics { + radiusLead: number; + radiusMember: number; +} + +export interface StableSlotZoomRange { + minZoom: number; + maxZoom: number; +} + +export interface StableSlotActivityLane { + width: number; + itemHeight: number; + rowHeight: number; + maxVisibleItems: number; + headerHeight: number; + overflowHeight: number; + horizontalGapLead: number; + horizontalGapMember: number; + ownerClearanceLead: number; + ownerClearanceMember: number; + viewportPadding: number; + visiblePadding: number; + minScale: number; + maxScale: number; +} + +export interface StableSlotActivityAnchorLayout { + reservedWidth: number; + reservedHeight: number; + memberOffsetX: number; + memberOffsetY: number; + leadOffsetX: number; + leadOffsetY: number; + collisionRadius: number; +} + +export interface StableSlotLaunchAnchorLayout { + compactWidth: number; + compactHeight: number; + anchorCenterOffsetX: number; + anchorCenterOffsetY: number; + collisionRadius: number; + viewportPadding: number; + visiblePadding: number; + minScale: number; + maxScale: number; +} + +const ACTIVITY_LANE_BASE = { + width: 296, + itemHeight: 72, + rowHeight: 80, + maxVisibleItems: 3, + headerHeight: 20, + overflowHeight: 32, + horizontalGapLead: 76, + horizontalGapMember: 84, + ownerClearanceLead: 92, + ownerClearanceMember: 104, + viewportPadding: 12, + visiblePadding: 80, +} as const; + +const LAUNCH_HUD_BASE = { + compactWidth: 336, + compactHeight: 132, + horizontalGap: 40, + verticalClearance: 36, + viewportPadding: 12, + visiblePadding: 80, + minScale: 0, + maxScale: 1, +} as const; + +export function createStableSlotActivityLane(args: { + nodeMetrics: StableSlotNodeMetrics; + zoomRange: StableSlotZoomRange; +}): { + lane: StableSlotActivityLane; + anchor: StableSlotActivityAnchorLayout; +} { + const { nodeMetrics, zoomRange } = args; + const lane: StableSlotActivityLane = { + ...ACTIVITY_LANE_BASE, + minScale: zoomRange.minZoom, + maxScale: zoomRange.maxZoom, + }; + const reservedHeight = + lane.headerHeight + + lane.maxVisibleItems * lane.rowHeight + + lane.overflowHeight; + + return { + lane, + anchor: { + reservedWidth: lane.width, + reservedHeight, + memberOffsetX: lane.width / 2 + nodeMetrics.radiusMember + lane.horizontalGapMember, + memberOffsetY: -(reservedHeight + nodeMetrics.radiusMember + lane.ownerClearanceMember), + leadOffsetX: -(lane.width / 2 + nodeMetrics.radiusLead + lane.horizontalGapLead), + leadOffsetY: -(reservedHeight + nodeMetrics.radiusLead + lane.ownerClearanceLead), + collisionRadius: Math.ceil(Math.hypot(lane.width / 2, reservedHeight / 2)) + 56, + }, + }; +} + +export function createStableSlotLaunchAnchorLayout( + nodeMetrics: Pick +): StableSlotLaunchAnchorLayout { + const { radiusLead } = nodeMetrics; + return { + compactWidth: LAUNCH_HUD_BASE.compactWidth, + compactHeight: LAUNCH_HUD_BASE.compactHeight, + anchorCenterOffsetX: + LAUNCH_HUD_BASE.compactWidth / 2 + radiusLead + LAUNCH_HUD_BASE.horizontalGap, + anchorCenterOffsetY: + -(LAUNCH_HUD_BASE.compactHeight / 2 + radiusLead + LAUNCH_HUD_BASE.verticalClearance), + collisionRadius: + Math.ceil( + Math.hypot( + LAUNCH_HUD_BASE.compactWidth / 2, + LAUNCH_HUD_BASE.compactHeight / 2 + ) + ) + 14, + viewportPadding: LAUNCH_HUD_BASE.viewportPadding, + visiblePadding: LAUNCH_HUD_BASE.visiblePadding, + minScale: LAUNCH_HUD_BASE.minScale, + maxScale: LAUNCH_HUD_BASE.maxScale, + }; +} diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts new file mode 100644 index 00000000..070323ea --- /dev/null +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -0,0 +1,1814 @@ +import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; +import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; +import { ACTIVITY_LANE } from './activityLane'; +import type { WorldBounds } from './launchAnchor'; +import { + STABLE_SLOT_GEOMETRY, + STABLE_SLOT_SECTOR_VECTORS, +} from './stableSlotGeometry'; + +export type StableSlotWidthBucket = 'S' | 'M' | 'L'; + +export interface StableRect { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export interface OwnerFootprint { + ownerId: string; + slotWidth: number; + slotHeight: number; + widthBucket: StableSlotWidthBucket; + radialDepth: number; + activityColumnWidth: number; + activityColumnHeight: number; + processBandWidth: number; + kanbanBandWidth: number; + kanbanBandHeight: number; + boardBandWidth: number; + boardBandHeight: number; + taskColumnCount: number; + processCount: number; +} + +export interface SlotFrame { + ownerId: string; + ringIndex: number; + sectorIndex: number; + widthBucket: StableSlotWidthBucket; + bounds: StableRect; + ownerX: number; + ownerY: number; + boardBandRect: StableRect; + activityColumnRect: StableRect; + processBandRect: StableRect; + kanbanBandRect: StableRect; + taskColumnCount: number; +} + +export interface StableSlotLayoutSnapshot { + version: GraphLayoutPort['version']; + teamName: string; + leadNodeId: string | null; + leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; + leadActivityRect: StableRect; + launchHudRect: StableRect; + launchAnchor: { x: number; y: number } | null; + leadCentralReservedBlock: StableRect; + runtimeCentralExclusion: StableRect; + centralCollisionRects: StableRect[]; + memberSlotFrames: SlotFrame[]; + memberSlotFrameByOwnerId: Map; + unassignedTaskRect: StableRect | null; + fitBounds: StableRect; +} + +export interface StableSlotLayoutValidationResult { + valid: boolean; + reason?: string; +} + +interface NearestSlotAssignmentResult { + assignment: GraphOwnerSlotAssignment; + displacedOwnerId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; +} + +interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult { + distanceSquared: number; +} + +interface LayoutBuildArgs { + teamName: string; + nodes: GraphNode[]; + layout?: GraphLayoutPort; +} + +interface RingLayoutState { + radius: number; + outwardDepth: number; +} + +type RingLayoutStateMap = ReadonlyMap; + +const SLOT_GEOMETRY = { + ...STABLE_SLOT_GEOMETRY, + activityColumnHeight: + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight, + activityColumnWidth: ACTIVITY_LANE.width, + ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + boardColumnGap: 24, + processRailMinWidth: STABLE_SLOT_GEOMETRY.processRailWidth, + kanbanBandHeight: + KANBAN_ZONE.headerHeight + + STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight, + centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding, +} as const; + +const PROCESS_RAIL_NODE_GAP = 42; +const PROCESS_RAIL_NODE_FOOTPRINT = 28; +const GEOMETRY_EPSILON = 0.001; +const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24; + +const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; +const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray< + ReadonlyArray<{ + assignment: GraphOwnerSlotAssignment; + vector: { x: number; y: number }; + }> +> = [ + [], + [{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: -1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: 1, y: 0 } }, + ], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: -1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 1, y: 0 } }, + ], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: 1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } }, + { assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } }, + ], +]; + +const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray> = + SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment)); +const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map( + SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) => + layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const) + ) +); + +export function buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, +}: LayoutBuildArgs): StableSlotLayoutSnapshot | null { + const leadNode = nodes.find((node) => node.kind === 'lead') ?? null; + if (!leadNode) { + return null; + } + + const leadCoreRect = createCenteredRect(0, 0, 200, 96); + const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id); + const leadSlotFrame = buildSlotFrameAtRadius( + leadFootprint, + { ringIndex: 0, sectorIndex: 0 }, + 0 + ); + const leadActivityRect = leadSlotFrame.activityColumnRect; + const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0); + const leadCentralReservedBlock = leadSlotFrame.bounds; + + const ownerFootprints = computeOwnerFootprints(nodes, layout); + const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); + const centralCollisionRects = buildCentralCollisionRects({ + leadCentralReservedBlock, + unassignedTaskRect, + }); + const runtimeCentralExclusion = padRect( + unionRects(centralCollisionRects), + SLOT_GEOMETRY.centralPadding + ); + + const memberSlotFrames = planOwnerSlots( + ownerFootprints, + centralCollisionRects, + runtimeCentralExclusion, + layout + ); + const memberSlotFrameByOwnerId = new Map( + memberSlotFrames.map((frame) => [frame.ownerId, frame] as const) + ); + const fitBounds = unionRects( + [ + runtimeCentralExclusion, + ...memberSlotFrames.map((frame) => frame.bounds), + ].filter(Boolean) + ); + + return { + version: layout?.version ?? 'stable-slots-v1', + teamName, + leadNodeId: leadNode.id, + leadCoreRect, + leadSlotFrame, + leadActivityRect, + launchHudRect, + launchAnchor: null, + leadCentralReservedBlock, + runtimeCentralExclusion, + centralCollisionRects, + memberSlotFrames, + memberSlotFrameByOwnerId, + unassignedTaskRect, + fitBounds, + }; +} + +function buildCentralCollisionRects(args: { + leadCentralReservedBlock: StableRect; + unassignedTaskRect: StableRect | null; +}): StableRect[] { + const rects = [args.leadCentralReservedBlock]; + if (args.unassignedTaskRect) { + rects.push(args.unassignedTaskRect); + } + return rects; +} + +function padCentralCollisionRects( + rects: readonly StableRect[], + padding: number +): StableRect[] { + return rects.map((rect) => padRect(rect, padding)); +} + +function rectOverlapsAnyCentralRect( + rect: StableRect, + centralCollisionRects: readonly StableRect[] +): boolean { + return centralCollisionRects.some((centralRect) => rectsOverlap(rect, centralRect)); +} + +export function computeOwnerFootprints( + nodes: GraphNode[], + layout?: GraphLayoutPort +): OwnerFootprint[] { + const ownerNodes = nodes.filter((node) => node.kind === 'member'); + const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const)); + const taskColumnsByOwnerId = new Map>(); + const processCountByOwnerId = new Map(); + + for (const node of nodes) { + if (node.kind === 'task' && node.ownerId) { + const existing = taskColumnsByOwnerId.get(node.ownerId) ?? new Set(); + existing.add(resolveTaskColumnKey(node)); + taskColumnsByOwnerId.set(node.ownerId, existing); + } + if (node.kind === 'process' && node.ownerId) { + processCountByOwnerId.set(node.ownerId, (processCountByOwnerId.get(node.ownerId) ?? 0) + 1); + } + } + + const orderedOwnerIds = [ + ...(layout?.ownerOrder ?? ownerNodes.map((node) => node.id)), + ...ownerNodes + .map((node) => node.id) + .filter((ownerId) => !(layout?.ownerOrder ?? []).includes(ownerId)), + ].filter((ownerId, index, array) => array.indexOf(ownerId) === index); + + return orderedOwnerIds.flatMap((ownerId) => { + const ownerNode = ownerNodeById.get(ownerId); + if (!ownerNode) { + return []; + } + + return [ + buildOwnerFootprint({ + ownerId, + taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0, + processCount: processCountByOwnerId.get(ownerId) ?? 0, + }), + ]; + }); +} + +function computeOwnerFootprintForOwnerId( + nodes: readonly GraphNode[], + ownerId: string +): OwnerFootprint { + const taskColumns = new Set(); + let processCount = 0; + + for (const node of nodes) { + if (node.kind === 'task' && node.ownerId === ownerId) { + taskColumns.add(resolveTaskColumnKey(node)); + } + if (node.kind === 'process' && node.ownerId === ownerId) { + processCount += 1; + } + } + + return buildOwnerFootprint({ + ownerId, + taskColumnCount: taskColumns.size, + processCount, + }); +} + +function buildOwnerFootprint(args: { + ownerId: string; + taskColumnCount: number; + processCount: number; +}): OwnerFootprint { + const kanbanBandWidth = + args.taskColumnCount <= 1 + ? TASK_PILL.width + : TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth; + const processBandWidth = computeProcessBandWidth(args.processCount); + const boardBandWidth = + SLOT_GEOMETRY.activityColumnWidth + + SLOT_GEOMETRY.boardColumnGap + + kanbanBandWidth; + const boardBandHeight = Math.max( + SLOT_GEOMETRY.activityColumnHeight, + SLOT_GEOMETRY.kanbanBandHeight + ); + const innerContentWidth = Math.max( + SLOT_GEOMETRY.ownerMinWidth, + processBandWidth, + boardBandWidth + ); + const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; + const slotHeight = + SLOT_GEOMETRY.memberSlotInnerPadding * 2 + + SLOT_GEOMETRY.ownerBandHeight + + SLOT_GEOMETRY.ownerToProcessGap + + SLOT_GEOMETRY.processBandHeight + + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight; + const radialDepth = Math.max( + SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2, + SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.ownerBandHeight / 2 + + SLOT_GEOMETRY.ownerToProcessGap + + SLOT_GEOMETRY.processBandHeight + + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight + ); + + return { + ownerId: args.ownerId, + slotWidth, + slotHeight, + widthBucket: classifyWidthBucket(slotWidth), + radialDepth, + activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, + activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, + processBandWidth, + kanbanBandWidth, + kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, + boardBandWidth, + boardBandHeight, + taskColumnCount: args.taskColumnCount, + processCount: args.processCount, + } satisfies OwnerFootprint; +} + +export function classifyWidthBucket(width: number): StableSlotWidthBucket { + if (width <= 340) { + return 'S'; + } + if (width <= 560) { + return 'M'; + } + return 'L'; +} + +export function computeProcessBandWidth(processCount: number): number { + if (processCount <= 1) { + return SLOT_GEOMETRY.processRailMinWidth; + } + + const occupiedWidth = + (processCount - 1) * PROCESS_RAIL_NODE_GAP + PROCESS_RAIL_NODE_FOOTPRINT; + return Math.max(SLOT_GEOMETRY.processRailMinWidth, occupiedWidth); +} + +export function resolveNearestSlotAssignment(args: { + ownerId: string; + ownerX: number; + ownerY: number; + nodes: GraphNode[]; + snapshot: StableSlotLayoutSnapshot; + layout?: GraphLayoutPort; +}): NearestSlotAssignmentResult | null { + const allFootprints = computeOwnerFootprints(args.nodes, args.layout); + const footprintByOwnerId = new Map( + allFootprints.map((item) => [item.ownerId, item] as const) + ); + const footprint = footprintByOwnerId.get(args.ownerId); + if (!footprint) { + return null; + } + + const currentFrame = args.snapshot.memberSlotFrameByOwnerId.get(args.ownerId); + if (!currentFrame) { + return null; + } + + const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({ + ownerId: args.ownerId, + ownerX: args.ownerX, + ownerY: args.ownerY, + currentFrame, + snapshot: args.snapshot, + }); + if (strictSmallTeamCandidate) { + return strictSmallTeamCandidate; + } + + const existingFrames = args.snapshot.memberSlotFrames.filter((frame) => frame.ownerId !== args.ownerId); + const maxOccupiedRing = existingFrames.reduce((max, frame) => Math.max(max, frame.ringIndex), 0); + const candidateAssignments = buildCandidateAssignments( + Math.max(SLOT_GEOMETRY.maxGeneratedRings, maxOccupiedRing + allFootprints.length + 2) + ); + const ringStates = buildRingStatesFromFrames( + [...existingFrames, currentFrame], + footprintByOwnerId + ); + let best: RankedNearestSlotAssignmentResult | null = null; + + for (const assignment of candidateAssignments) { + const occupiedFrame = args.snapshot.memberSlotFrames.find( + (existing) => + existing.ownerId !== args.ownerId && + existing.ringIndex === assignment.ringIndex && + existing.sectorIndex === assignment.sectorIndex + ); + const rankedCandidate = rankNearestSlotAssignmentResult({ + assignment, + occupiedFrame, + footprint, + footprintByOwnerId, + currentFrame, + existingFrames, + centralCollisionRects: args.snapshot.centralCollisionRects, + runtimeCentralExclusion: args.snapshot.runtimeCentralExclusion, + ringStates, + pointerX: args.ownerX, + pointerY: args.ownerY, + }); + if (!rankedCandidate) { + continue; + } + + if (!best || rankedCandidate.distanceSquared < best.distanceSquared) { + best = rankedCandidate; + } + } + + return best + ? { + assignment: best.assignment, + displacedOwnerId: best.displacedOwnerId, + displacedAssignment: best.displacedAssignment, + previewOwnerX: best.previewOwnerX, + previewOwnerY: best.previewOwnerY, + } + : null; +} + +function resolveStrictSmallTeamNearestSlotAssignment(args: { + ownerId: string; + ownerX: number; + ownerY: number; + currentFrame: SlotFrame; + snapshot: StableSlotLayoutSnapshot; +}): NearestSlotAssignmentResult | null { + const strictFrames = getStrictSmallTeamFrames(args.snapshot.memberSlotFrames); + if (!strictFrames) { + return null; + } + + let best: + | { + frame: SlotFrame; + distanceSquared: number; + } + | null = null; + for (const frame of strictFrames) { + const dx = frame.ownerX - args.ownerX; + const dy = frame.ownerY - args.ownerY; + const distanceSquared = dx * dx + dy * dy; + if (!best || distanceSquared < best.distanceSquared) { + best = { frame, distanceSquared }; + } + } + + if (!best) { + return null; + } + + const targetFrame = best.frame; + if (targetFrame.ownerId === args.ownerId) { + return { + assignment: { + ringIndex: targetFrame.ringIndex, + sectorIndex: targetFrame.sectorIndex, + }, + previewOwnerX: targetFrame.ownerX, + previewOwnerY: targetFrame.ownerY, + }; + } + + return { + assignment: { + ringIndex: targetFrame.ringIndex, + sectorIndex: targetFrame.sectorIndex, + }, + displacedOwnerId: targetFrame.ownerId, + displacedAssignment: { + ringIndex: args.currentFrame.ringIndex, + sectorIndex: args.currentFrame.sectorIndex, + }, + previewOwnerX: targetFrame.ownerX, + previewOwnerY: targetFrame.ownerY, + }; +} + +function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null { + if (frames.length === 0 || frames.length > 4) { + return null; + } + const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length]; + if (!preset || preset.length !== frames.length) { + return null; + } + + const actualAssignmentKeys = frames + .map((frame) => buildAssignmentKey({ ringIndex: frame.ringIndex, sectorIndex: frame.sectorIndex })) + .sort(); + const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort(); + + for (let index = 0; index < presetAssignmentKeys.length; index += 1) { + if (actualAssignmentKeys[index] !== presetAssignmentKeys[index]) { + return null; + } + } + + return frames; +} + +export function validateStableSlotLayout( + snapshot: StableSlotLayoutSnapshot +): StableSlotLayoutValidationResult { + if (!snapshot.leadNodeId) { + return { valid: false, reason: 'missing leadNodeId' }; + } + const staticRectValidation = validateStaticSnapshotRects(snapshot); + if (staticRectValidation) { + return staticRectValidation; + } + + const leadRectValidation = validateLeadSnapshotRects(snapshot); + if (leadRectValidation) { + return leadRectValidation; + } + + const seenOwnerIds = new Set(); + const seenAssignments = new Set(); + for (const frame of snapshot.memberSlotFrames) { + const frameValidation = validateMemberSlotFrame( + frame, + snapshot, + seenOwnerIds, + seenAssignments + ); + if (frameValidation) { + return frameValidation; + } + } + + const overlapValidation = validateMemberFrameOverlaps(snapshot.memberSlotFrames); + if (overlapValidation) { + return overlapValidation; + } + + return { valid: true }; +} + +function validateStaticSnapshotRects( + snapshot: StableSlotLayoutSnapshot +): StableSlotLayoutValidationResult | null { + const staticRects: [string, StableRect][] = [ + ['leadCoreRect', snapshot.leadCoreRect], + ['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds], + ['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect], + ['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect], + ['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect], + ['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect], + ['leadActivityRect', snapshot.leadActivityRect], + ['launchHudRect', snapshot.launchHudRect], + ['leadCentralReservedBlock', snapshot.leadCentralReservedBlock], + ['runtimeCentralExclusion', snapshot.runtimeCentralExclusion], + ['fitBounds', snapshot.fitBounds], + ...snapshot.centralCollisionRects.map( + (rect, index) => [`centralCollisionRects[${index}]`, rect] as [string, StableRect] + ), + ]; + + if (snapshot.unassignedTaskRect) { + staticRects.push(['unassignedTaskRect', snapshot.unassignedTaskRect]); + } + + for (const [name, rect] of staticRects) { + if (!isFiniteRect(rect)) { + return { valid: false, reason: `${name} contains non-finite geometry` }; + } + } + + if (snapshot.fitBounds.width <= 0 || snapshot.fitBounds.height <= 0) { + return { valid: false, reason: 'fitBounds must be non-zero' }; + } + + return null; +} + +function validateLeadSnapshotRects( + snapshot: StableSlotLayoutSnapshot +): StableSlotLayoutValidationResult | null { + const leadFrameValidation = validateSlotFrameGeometry( + snapshot.leadSlotFrame, + snapshot.fitBounds, + `leadSlotFrame(${snapshot.leadSlotFrame.ownerId})` + ); + if (leadFrameValidation) { + return leadFrameValidation; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadCoreRect)) { + return { valid: false, reason: 'leadCoreRect must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { + return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; + } + if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) { + return { + valid: false, + reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', + }; + } + if (snapshot.leadActivityRect.top !== snapshot.leadSlotFrame.activityColumnRect.top) { + return { + valid: false, + reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', + }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) { + return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { + return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; + } + const paddedCentralCollisionRects = padCentralCollisionRects( + snapshot.centralCollisionRects, + SLOT_GEOMETRY.centralPadding + ); + if ( + paddedCentralCollisionRects.some( + (rect) => !rectContainsRect(snapshot.runtimeCentralExclusion, rect) + ) + ) { + return { + valid: false, + reason: 'runtimeCentralExclusion must contain all centralCollisionRects', + }; + } + + return null; +} + +function validateMemberSlotFrame( + frame: SlotFrame, + snapshot: StableSlotLayoutSnapshot, + seenOwnerIds: Set, + seenAssignments: Set +): StableSlotLayoutValidationResult | null { + const geometryValidation = validateSlotFrameGeometry( + frame, + snapshot.fitBounds, + `slot frame for ${frame.ownerId}` + ); + if (geometryValidation) { + return geometryValidation; + } + if (seenOwnerIds.has(frame.ownerId)) { + return { valid: false, reason: `duplicate owner frame for ${frame.ownerId}` }; + } + seenOwnerIds.add(frame.ownerId); + + const assignmentKey = `${frame.ringIndex}:${frame.sectorIndex}`; + if (seenAssignments.has(assignmentKey)) { + return { valid: false, reason: `duplicate slot assignment ${assignmentKey}` }; + } + seenAssignments.add(assignmentKey); + + if (rectOverlapsAnyCentralRect(frame.bounds, snapshot.centralCollisionRects)) { + return { + valid: false, + reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`, + }; + } + return null; +} + +function validateSlotFrameGeometry( + frame: SlotFrame, + fitBounds: StableRect, + label: string +): StableSlotLayoutValidationResult | null { + if (!isFiniteRect(frame.bounds)) { + return { valid: false, reason: `${label} contains non-finite bounds` }; + } + if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) { + return { valid: false, reason: `${label} contains non-finite anchor` }; + } + if (!rectContainsRect(frame.bounds, frame.boardBandRect)) { + return { valid: false, reason: `boardBandRect escapes ${label}` }; + } + if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { + return { valid: false, reason: `activityColumnRect escapes ${label}` }; + } + if (!rectContainsRect(frame.bounds, frame.processBandRect)) { + return { valid: false, reason: `processBandRect escapes ${label}` }; + } + if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) { + return { valid: false, reason: `kanbanBandRect escapes ${label}` }; + } + if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) { + return { + valid: false, + reason: `activityColumnRect escapes boardBandRect in ${label}`, + }; + } + if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `kanbanBandRect escapes boardBandRect in ${label}`, + }; + } + if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `activityColumnRect overlaps kanbanBandRect in ${label}`, + }; + } + if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { + return { valid: false, reason: `owner anchor escapes ${label}` }; + } + if (!rectContainsRect(fitBounds, frame.bounds)) { + return { valid: false, reason: `${label} escapes fitBounds` }; + } + + return null; +} + +function validateMemberFrameOverlaps( + frames: readonly SlotFrame[] +): StableSlotLayoutValidationResult | null { + for (const [index, left] of frames.entries()) { + for (const right of frames.slice(index + 1)) { + if (rectsOverlap(left.bounds, right.bounds)) { + return { + valid: false, + reason: `slot frames overlap: ${left.ownerId} <-> ${right.ownerId}`, + }; + } + } + } + return null; +} + +export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): SlotFrame { + return { + ...frame, + bounds: translateRect(frame.bounds, dx, dy), + ownerX: frame.ownerX + dx, + ownerY: frame.ownerY + dy, + boardBandRect: translateRect(frame.boardBandRect, dx, dy), + activityColumnRect: translateRect(frame.activityColumnRect, dx, dy), + processBandRect: translateRect(frame.processBandRect, dx, dy), + kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy), + }; +} + +export function snapshotToWorldBounds(snapshot: StableSlotLayoutSnapshot): WorldBounds[] { + const bounds: WorldBounds[] = [ + snapshot.fitBounds, + snapshot.leadCentralReservedBlock, + ...snapshot.memberSlotFrames.map((frame) => frame.bounds), + ].map((rect) => ({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + })); + + if (snapshot.unassignedTaskRect) { + bounds.push({ + left: snapshot.unassignedTaskRect.left, + top: snapshot.unassignedTaskRect.top, + right: snapshot.unassignedTaskRect.right, + bottom: snapshot.unassignedTaskRect.bottom, + }); + } + + return bounds; +} + +function buildUnassignedTaskRect( + nodes: GraphNode[], + leadCentralReservedBlock: StableRect +): StableRect | null { + const visibleOwnerIds = new Set( + nodes + .filter((node) => node.kind === 'lead' || node.kind === 'member') + .map((node) => node.id) + ); + const unassignedTasks = nodes.filter( + (node) => + node.kind === 'task' && (!node.ownerId || !visibleOwnerIds.has(node.ownerId)) + ); + if (unassignedTasks.length === 0) { + return null; + } + + const columnCount = new Set(unassignedTasks.map((node) => resolveTaskColumnKey(node))).size; + const width = + columnCount <= 1 + ? TASK_PILL.width + : TASK_PILL.width + (columnCount - 1) * KANBAN_ZONE.columnWidth; + const height = SLOT_GEOMETRY.kanbanBandHeight; + return createRect( + -width / 2, + leadCentralReservedBlock.bottom + SLOT_GEOMETRY.unassignedGap, + width, + height + ); +} + +function planOwnerSlots( + ownerFootprints: OwnerFootprint[], + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, + layout?: GraphLayoutPort +): SlotFrame[] { + const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout) + ? planStrictSmallTeamOwnerSlots( + ownerFootprints, + centralCollisionRects, + runtimeCentralExclusion, + layout + ) + : null; + if (strictSmallTeamFrames) { + return strictSmallTeamFrames; + } + + const placedFrames: SlotFrame[] = []; + const preferredAssignments = buildPreferredAssignmentsMap(layout?.slotAssignments); + const usedSlotKeys = new Set(); + const ringStates = new Map(); + const maxRingExclusive = computePlannerRingLimit(ownerFootprints, layout?.slotAssignments); + + for (const footprint of ownerFootprints) { + const resolvedFrame = resolveOwnerSlotFrame({ + footprint, + centralCollisionRects, + runtimeCentralExclusion, + ringStates, + preferredAssignment: preferredAssignments.get(footprint.ownerId), + usedSlotKeys, + placedFrames, + maxRingExclusive, + }); + placedFrames.push(resolvedFrame); + commitRingPlacement(ringStates, resolvedFrame, footprint); + } + + return placedFrames; +} + +function shouldUseStrictSmallTeamCardinalLayout( + ownerFootprints: readonly OwnerFootprint[], + layout?: GraphLayoutPort +): boolean { + if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { + return false; + } + + const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[ownerFootprints.length]; + if (!preset || preset.length !== ownerFootprints.length) { + return false; + } + + const actualAssignmentKeys = ownerFootprints + .map((footprint) => layout?.slotAssignments?.[footprint.ownerId]) + .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null) + .map((assignment) => buildAssignmentKey(assignment)) + .sort(); + const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort(); + + if (actualAssignmentKeys.length !== presetAssignmentKeys.length) { + return false; + } + + for (let index = 0; index < presetAssignmentKeys.length; index += 1) { + if (actualAssignmentKeys[index] !== presetAssignmentKeys[index]) { + return false; + } + } + + return true; +} + +function planStrictSmallTeamOwnerSlots( + ownerFootprints: readonly OwnerFootprint[], + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, + layout?: GraphLayoutPort +): SlotFrame[] | null { + if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { + return null; + } + + const preset = SMALL_TEAM_CARDINAL_LAYOUTS[ownerFootprints.length]; + if (!preset || preset.length !== ownerFootprints.length) { + return null; + } + + const slotConfigs = ownerFootprints.map((footprint) => { + const assignment = layout?.slotAssignments?.[footprint.ownerId]; + if (!assignment) { + return null; + } + const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment)); + if (!vector) { + return null; + } + return { + footprint, + assignment, + vector, + }; + }); + + if (slotConfigs.some((slot) => slot == null)) { + return null; + } + + let radius = Math.max( + ...slotConfigs.map((slot) => + resolveMinimumDirectionalRadiusForVector({ + vector: slot!.vector, + footprint: slot!.footprint, + centralCollisionRects, + runtimeCentralExclusion, + }) + ) + ); + + for (let iteration = 0; iteration < 48; iteration += 1) { + const frames = slotConfigs.map((slot) => + buildSlotFrameAtRadiusWithVector(slot!.footprint, slot!.assignment, radius, slot!.vector) + ); + const allValid = frames.every((frame, frameIndex) => + isSlotFramePlacementValid( + frame, + frames.filter((_, index) => index !== frameIndex), + centralCollisionRects + ) + ); + if (allValid) { + return frames; + } + radius += SMALL_TEAM_CARDINAL_RADIUS_STEP; + } + + return null; +} + +function buildPreferredAssignmentsMap( + assignments?: Record +): Map { + const preferredAssignments = new Map(); + const assignmentOwnersBySlotKey = new Map(); + + for (const [ownerId, assignment] of Object.entries(assignments ?? {})) { + preferredAssignments.set(ownerId, assignment); + const slotKey = buildAssignmentKey(assignment); + const existingOwners = assignmentOwnersBySlotKey.get(slotKey) ?? []; + existingOwners.push(ownerId); + assignmentOwnersBySlotKey.set(slotKey, existingOwners); + } + + for (const [slotKey, owners] of assignmentOwnersBySlotKey) { + if (owners.length > 1) { + console.warn( + `[agent-graph] duplicate saved slot assignment ${slotKey} for owners: ${owners.join(', ')}` + ); + } + } + + return preferredAssignments; +} + +function resolveOwnerSlotFrame(args: { + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; + preferredAssignment?: GraphOwnerSlotAssignment; + usedSlotKeys: Set; + placedFrames: readonly SlotFrame[]; + maxRingExclusive: number; +}): SlotFrame { + const { + footprint, + centralCollisionRects, + runtimeCentralExclusion, + ringStates, + preferredAssignment, + usedSlotKeys, + placedFrames, + maxRingExclusive, + } = args; + + const candidates = preferredAssignment + ? buildPreferredCandidateAssignments(preferredAssignment, maxRingExclusive) + : buildCandidateAssignments(maxRingExclusive); + const directMatch = findFirstValidSlotFrame({ + candidateAssignments: candidates, + footprint, + centralCollisionRects, + runtimeCentralExclusion, + ringStates, + usedSlotKeys, + placedFrames, + preferredAssignment, + }); + if (directMatch) { + return directMatch; + } + + const spilloverCandidates = buildCandidateAssignments( + maxRingExclusive + ownerFootprintsSpillBudget(placedFrames.length) + ).filter((assignment) => assignment.ringIndex >= maxRingExclusive); + const spilloverMatch = findFirstValidSlotFrame({ + candidateAssignments: spilloverCandidates, + footprint, + centralCollisionRects, + runtimeCentralExclusion, + ringStates, + usedSlotKeys, + placedFrames, + }); + if (spilloverMatch) { + return spilloverMatch; + } + + return buildEmergencyFallbackSlotFrame({ + footprint, + centralCollisionRects, + runtimeCentralExclusion, + ringStates, + usedSlotKeys, + placedOwnerCount: placedFrames.length, + baseRingIndex: maxRingExclusive + ownerFootprintsSpillBudget(placedFrames.length), + }); +} + +function buildSlotFrame( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, + options: { ringStates: RingLayoutStateMap } +): SlotFrame | null { + const radius = resolveRingRadiusForAssignment({ + assignment, + footprint, + centralCollisionRects, + runtimeCentralExclusion, + ringStates: options.ringStates, + }); + if (radius == null) { + return null; + } + return buildSlotFrameAtRadius(footprint, assignment, radius); +} + +function buildSlotFrameAtRadius( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + radius: number +): SlotFrame { + const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + return buildSlotFrameAtRadiusWithVector(footprint, assignment, radius, vector); +} + +function buildSlotFrameAtRadiusWithVector( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + radius: number, + vector: { x: number; y: number } +): SlotFrame { + const ownerX = vector.x * radius; + const ownerY = vector.y * radius; + const slotTop = + ownerY - (SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2); + const bounds = createRect( + ownerX - footprint.slotWidth / 2, + slotTop, + footprint.slotWidth, + footprint.slotHeight + ); + const processBandRect = createRect( + bounds.left + (bounds.width - footprint.processBandWidth) / 2, + ownerY + SLOT_GEOMETRY.ownerBandHeight / 2 + SLOT_GEOMETRY.ownerToProcessGap, + footprint.processBandWidth, + SLOT_GEOMETRY.processBandHeight + ); + const boardBandRect = createRect( + bounds.left + (bounds.width - footprint.boardBandWidth) / 2, + processBandRect.bottom + SLOT_GEOMETRY.processToBoardGap, + footprint.boardBandWidth, + footprint.boardBandHeight + ); + const activityColumnRect = createRect( + boardBandRect.left, + boardBandRect.top, + footprint.activityColumnWidth, + footprint.activityColumnHeight + ); + const kanbanBandRect = createRect( + activityColumnRect.right + SLOT_GEOMETRY.boardColumnGap, + boardBandRect.top, + footprint.kanbanBandWidth, + footprint.kanbanBandHeight + ); + + return { + ownerId: footprint.ownerId, + ringIndex: assignment.ringIndex, + sectorIndex: assignment.sectorIndex, + widthBucket: footprint.widthBucket, + bounds, + ownerX, + ownerY, + boardBandRect, + activityColumnRect, + processBandRect, + kanbanBandRect, + taskColumnCount: footprint.taskColumnCount, + }; +} + +function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] { + const candidates: GraphOwnerSlotAssignment[] = []; + for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) { + for (let sectorIndex = 0; sectorIndex < SECTOR_VECTORS.length; sectorIndex += 1) { + candidates.push({ ringIndex, sectorIndex }); + } + } + return candidates; +} + +function buildPreferredCandidateAssignments( + preferred: GraphOwnerSlotAssignment, + maxRingExclusive: number +): GraphOwnerSlotAssignment[] { + const ordered: GraphOwnerSlotAssignment[] = [preferred]; + const seen = new Set([`${preferred.ringIndex}:${preferred.sectorIndex}`]); + const sectorOrder = buildSectorPreferenceOrder(preferred.sectorIndex); + + appendSameSectorOuterRingCandidates(ordered, seen, preferred, maxRingExclusive); + appendRingSectorCandidates(ordered, seen, preferred.ringIndex, sectorOrder); + + for (let ringIndex = preferred.ringIndex + 1; ringIndex < maxRingExclusive; ringIndex += 1) { + appendRingSectorCandidates(ordered, seen, ringIndex, sectorOrder); + } + + for (let ringIndex = 0; ringIndex < preferred.ringIndex; ringIndex += 1) { + appendRingSectorCandidates(ordered, seen, ringIndex, sectorOrder); + } + + return ordered; +} + +function computePlannerRingLimit( + ownerFootprints: readonly OwnerFootprint[], + assignments?: Record +): number { + const maxAssignedRing = Object.values(assignments ?? {}).reduce( + (max, assignment) => Math.max(max, assignment.ringIndex), + 0 + ); + return Math.max( + SLOT_GEOMETRY.maxGeneratedRings, + maxAssignedRing + ownerFootprints.length + 2 + ); +} + +function ownerFootprintsSpillBudget(placedOwnerCount: number): number { + return Math.max(6, placedOwnerCount + 2); +} + +function buildEmergencyFallbackSlotFrame(args: { + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; + usedSlotKeys: Set; + placedOwnerCount: number; + baseRingIndex: number; +}): SlotFrame { + const assignment = { + ringIndex: args.baseRingIndex + args.placedOwnerCount, + sectorIndex: 0, + }; + args.usedSlotKeys.add(buildAssignmentKey(assignment)); + const frame = buildSlotFrame( + args.footprint, + assignment, + args.centralCollisionRects, + args.runtimeCentralExclusion, + { + ringStates: args.ringStates, + } + ); + if (!frame) { + throw new Error(`failed to build emergency fallback slot frame for ${args.footprint.ownerId}`); + } + return frame; +} + +function rankNearestSlotAssignmentResult(args: { + assignment: GraphOwnerSlotAssignment; + occupiedFrame: SlotFrame | undefined; + footprint: OwnerFootprint; + footprintByOwnerId: ReadonlyMap; + currentFrame: SlotFrame; + existingFrames: readonly SlotFrame[]; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; + pointerX: number; + pointerY: number; +}): RankedNearestSlotAssignmentResult | null { + const { + assignment, + occupiedFrame, + footprint, + footprintByOwnerId, + currentFrame, + existingFrames, + centralCollisionRects, + runtimeCentralExclusion, + ringStates, + pointerX, + pointerY, + } = args; + const frame = buildSlotFrame( + footprint, + assignment, + centralCollisionRects, + runtimeCentralExclusion, + { + ringStates, + } + ); + if (!frame) { + return null; + } + + if (occupiedFrame) { + const displacedFrame = buildDisplacedFrameForNearestAssignment({ + occupiedFrame, + footprintByOwnerId, + currentFrame, + centralCollisionRects, + runtimeCentralExclusion, + ringStates, + }); + if (!displacedFrame) { + return null; + } + const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId); + if ( + !isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) || + !isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) || + rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding) + ) { + return null; + } + return buildRankedNearestSlotAssignmentResult({ + assignment, + frame, + pointerX, + pointerY, + displacedOwnerId: occupiedFrame.ownerId, + displacedAssignment: { + ringIndex: currentFrame.ringIndex, + sectorIndex: currentFrame.sectorIndex, + }, + }); + } + + if (!isSlotFramePlacementValid(frame, existingFrames, centralCollisionRects)) { + return null; + } + + return buildRankedNearestSlotAssignmentResult({ + assignment, + frame, + pointerX, + pointerY, + }); +} + +function buildDisplacedFrameForNearestAssignment(args: { + occupiedFrame: SlotFrame; + footprintByOwnerId: ReadonlyMap; + currentFrame: SlotFrame; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; +}): SlotFrame | null { + const displacedFootprint = args.footprintByOwnerId.get(args.occupiedFrame.ownerId); + if (!displacedFootprint) { + return null; + } + return buildSlotFrame( + displacedFootprint, + { + ringIndex: args.currentFrame.ringIndex, + sectorIndex: args.currentFrame.sectorIndex, + }, + args.centralCollisionRects, + args.runtimeCentralExclusion, + { ringStates: args.ringStates } + ); +} + +function buildRankedNearestSlotAssignmentResult(args: { + assignment: GraphOwnerSlotAssignment; + frame: SlotFrame; + pointerX: number; + pointerY: number; + displacedOwnerId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; +}): RankedNearestSlotAssignmentResult { + const dx = args.frame.ownerX - args.pointerX; + const dy = args.frame.ownerY - args.pointerY; + return { + assignment: args.assignment, + displacedOwnerId: args.displacedOwnerId, + displacedAssignment: args.displacedAssignment, + previewOwnerX: args.frame.ownerX, + previewOwnerY: args.frame.ownerY, + distanceSquared: dx * dx + dy * dy, + }; +} + +function findFirstValidSlotFrame(args: { + candidateAssignments: readonly GraphOwnerSlotAssignment[]; + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; + usedSlotKeys: Set; + placedFrames: readonly SlotFrame[]; + preferredAssignment?: GraphOwnerSlotAssignment; +}): SlotFrame | null { + for (const assignment of args.candidateAssignments) { + const frame = tryBuildValidSlotFrame(args, assignment); + if (frame) { + return frame; + } + } + return null; +} + +function tryBuildValidSlotFrame( + args: { + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; + usedSlotKeys: Set; + placedFrames: readonly SlotFrame[]; + preferredAssignment?: GraphOwnerSlotAssignment; + }, + assignment: GraphOwnerSlotAssignment +): SlotFrame | null { + const slotKey = buildAssignmentKey(assignment); + if (args.usedSlotKeys.has(slotKey) && !isSameAssignment(args.preferredAssignment, assignment)) { + return null; + } + const frame = buildSlotFrame( + args.footprint, + assignment, + args.centralCollisionRects, + args.runtimeCentralExclusion, + { + ringStates: args.ringStates, + } + ); + if (!frame) { + return null; + } + if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralCollisionRects)) { + return null; + } + args.usedSlotKeys.add(slotKey); + return frame; +} + +function appendSameSectorOuterRingCandidates( + ordered: GraphOwnerSlotAssignment[], + seen: Set, + preferred: GraphOwnerSlotAssignment, + maxRingExclusive: number +): void { + for (let ringIndex = preferred.ringIndex + 1; ringIndex < maxRingExclusive; ringIndex += 1) { + appendUniqueCandidate(ordered, seen, { ringIndex, sectorIndex: preferred.sectorIndex }); + } +} + +function appendRingSectorCandidates( + ordered: GraphOwnerSlotAssignment[], + seen: Set, + ringIndex: number, + sectorOrder: readonly number[] +): void { + for (const sectorIndex of sectorOrder) { + appendUniqueCandidate(ordered, seen, { ringIndex, sectorIndex }); + } +} + +function appendUniqueCandidate( + ordered: GraphOwnerSlotAssignment[], + seen: Set, + assignment: GraphOwnerSlotAssignment +): void { + const key = `${assignment.ringIndex}:${assignment.sectorIndex}`; + if (seen.has(key)) { + return; + } + ordered.push(assignment); + seen.add(key); +} + +function buildSectorPreferenceOrder(preferredSectorIndex: number): number[] { + const ordered = [preferredSectorIndex]; + for (let distance = 1; distance < SECTOR_VECTORS.length; distance += 1) { + const left = (preferredSectorIndex - distance + SECTOR_VECTORS.length) % SECTOR_VECTORS.length; + const right = (preferredSectorIndex + distance) % SECTOR_VECTORS.length; + if (!ordered.includes(left)) { + ordered.push(left); + } + if (!ordered.includes(right)) { + ordered.push(right); + } + } + return ordered; +} + +function buildRingStatesFromFrames( + frames: readonly SlotFrame[], + footprintByOwnerId: ReadonlyMap +): Map { + const ringStates = new Map(); + for (const frame of frames) { + const footprint = footprintByOwnerId.get(frame.ownerId); + if (!footprint) { + continue; + } + commitRingPlacement(ringStates, frame, footprint); + } + return ringStates; +} + +function commitRingPlacement( + ringStates: Map, + frame: SlotFrame, + footprint: OwnerFootprint +): void { + const radius = resolveFrameRingRadius(frame); + const vector = SECTOR_VECTORS[frame.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + const { outwardDepth } = computeSlotDirectionalDepths(footprint, vector); + const key = buildSectorRingStateKey(frame.sectorIndex, frame.ringIndex); + const existing = ringStates.get(key); + if (!existing) { + ringStates.set(key, { + radius, + outwardDepth, + }); + return; + } + + ringStates.set(key, { + radius: Math.max(existing.radius, radius), + outwardDepth: Math.max(existing.outwardDepth, outwardDepth), + }); +} + +function resolveFrameRingRadius(frame: SlotFrame): number { + const vector = SECTOR_VECTORS[frame.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + if (Math.abs(vector.x) >= Math.abs(vector.y) && Math.abs(vector.x) > 0.001) { + return Math.abs(frame.ownerX / vector.x); + } + if (Math.abs(vector.y) > 0.001) { + return Math.abs(frame.ownerY / vector.y); + } + return Math.hypot(frame.ownerX, frame.ownerY); +} + +function computeSlotDirectionalDepths( + footprint: OwnerFootprint, + vector: { x: number; y: number } +): { outwardDepth: number; inwardDepth: number } { + const ownerLocalY = SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2; + const topOffset = -ownerLocalY; + const bottomOffset = footprint.slotHeight - ownerLocalY; + const halfWidth = footprint.slotWidth / 2; + const vectorLength = Math.hypot(vector.x, vector.y) || 1; + const unitX = vector.x / vectorLength; + const unitY = vector.y / vectorLength; + const cornerProjections = [ + { x: -halfWidth, y: topOffset }, + { x: halfWidth, y: topOffset }, + { x: halfWidth, y: bottomOffset }, + { x: -halfWidth, y: bottomOffset }, + ].map((corner) => corner.x * unitX + corner.y * unitY); + + return { + outwardDepth: Math.max(...cornerProjections), + inwardDepth: Math.max(...cornerProjections.map((projection) => -projection)), + }; +} + +function resolveRingRadiusForAssignment(args: { + assignment: GraphOwnerSlotAssignment; + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; +}): number | null { + const vector = + SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + const minRadius = resolveMinimumDirectionalRadius({ + assignment: args.assignment, + footprint: args.footprint, + centralCollisionRects: args.centralCollisionRects, + runtimeCentralExclusion: args.runtimeCentralExclusion, + }); + const directionalDepths = computeSlotDirectionalDepths(args.footprint, vector); + const ringState = resolveVirtualRingState( + args.assignment.sectorIndex, + args.assignment.ringIndex, + minRadius, + directionalDepths, + args.ringStates + ); + + return minRadius <= ringState.radius + 0.001 ? ringState.radius : null; +} + +function resolveVirtualRingState( + sectorIndex: number, + ringIndex: number, + minRadius: number, + directionalDepths: { outwardDepth: number; inwardDepth: number }, + ringStates: RingLayoutStateMap +): RingLayoutState { + const existing = ringStates.get(buildSectorRingStateKey(sectorIndex, ringIndex)); + if (existing) { + return existing; + } + if (ringIndex === 0) { + return { + radius: minRadius, + outwardDepth: directionalDepths.outwardDepth, + }; + } + + const previous = resolveVirtualRingState( + sectorIndex, + ringIndex - 1, + minRadius, + directionalDepths, + ringStates + ); + return { + radius: Math.max( + minRadius, + previous.radius + + previous.outwardDepth + + directionalDepths.inwardDepth + + SLOT_GEOMETRY.ringGap + ), + outwardDepth: directionalDepths.outwardDepth, + }; +} + +function buildSectorRingStateKey(sectorIndex: number, ringIndex: number): string { + return `${sectorIndex}:${ringIndex}`; +} + +function resolveMinimumDirectionalRadius(args: { + assignment: GraphOwnerSlotAssignment; + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; +}): number { + return resolveMinimumDirectionalRadiusForVector({ + vector: SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0], + footprint: args.footprint, + centralCollisionRects: args.centralCollisionRects, + runtimeCentralExclusion: args.runtimeCentralExclusion, + }); +} + +function resolveMinimumDirectionalRadiusForVector(args: { + vector: { x: number; y: number }; + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; +}): number { + const legacyRadiusHint = computeLegacyMinimumRingRadius( + args.vector, + args.footprint, + args.runtimeCentralExclusion + ); + const overlapsCentralCollision = (radius: number): boolean => { + const frame = buildSlotFrameAtRadiusWithVector( + args.footprint, + { ringIndex: 0, sectorIndex: 0 }, + radius, + args.vector + ); + return rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects); + }; + + if (!overlapsCentralCollision(0)) { + return 0; + } + + let low = 0; + let high = Math.max(legacyRadiusHint, SLOT_GEOMETRY.ringGap); + let expansionCount = 0; + while (overlapsCentralCollision(high) && expansionCount < 24) { + low = high; + high = Math.max(high * 2, high + SLOT_GEOMETRY.ringGap); + expansionCount += 1; + } + + for (let iteration = 0; iteration < 24; iteration += 1) { + const mid = (low + high) / 2; + if (overlapsCentralCollision(mid)) { + low = mid; + } else { + high = mid; + } + } + + return Math.ceil(high); +} + +function computeLegacyMinimumRingRadius( + vector: { x: number; y: number }, + footprint: OwnerFootprint, + centralExclusion: StableRect +): number { + const horizontalExtent = + vector.x >= 0 ? centralExclusion.right : Math.abs(centralExclusion.left); + const verticalExtent = vector.y >= 0 ? centralExclusion.bottom : Math.abs(centralExclusion.top); + const requiredX = + Math.abs(vector.x) > 0.001 + ? (horizontalExtent + footprint.slotWidth / 2 + SLOT_GEOMETRY.ringPadding) / Math.abs(vector.x) + : 0; + const requiredY = + Math.abs(vector.y) > 0.001 + ? (verticalExtent + footprint.slotHeight / 2 + SLOT_GEOMETRY.ringPadding) / Math.abs(vector.y) + : 0; + return Math.max(requiredX, requiredY, 0); +} + +function resolveTaskColumnKey(task: GraphNode): string { + if (task.reviewState === 'approved') return 'approved'; + if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; + if (task.taskStatus === 'completed') return 'done'; + if (task.taskStatus === 'in_progress') return 'wip'; + return 'todo'; +} + +function rectsOverlapWithGap(a: StableRect, b: StableRect, gap: number): boolean { + return ( + a.left - gap < b.right && + a.right + gap > b.left && + a.top - gap < b.bottom && + a.bottom + gap > b.top + ); +} + +function rectsOverlap(a: StableRect, b: StableRect): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +function rectContainsRect(outer: StableRect, inner: StableRect): boolean { + return ( + inner.left >= outer.left - GEOMETRY_EPSILON && + inner.right <= outer.right + GEOMETRY_EPSILON && + inner.top >= outer.top - GEOMETRY_EPSILON && + inner.bottom <= outer.bottom + GEOMETRY_EPSILON + ); +} + +function pointInRect(x: number, y: number, rect: StableRect): boolean { + return ( + x >= rect.left - GEOMETRY_EPSILON && + x <= rect.right + GEOMETRY_EPSILON && + y >= rect.top - GEOMETRY_EPSILON && + y <= rect.bottom + GEOMETRY_EPSILON + ); +} + +function isFiniteRect(rect: StableRect): boolean { + return ( + Number.isFinite(rect.left) && + Number.isFinite(rect.top) && + Number.isFinite(rect.right) && + Number.isFinite(rect.bottom) && + Number.isFinite(rect.width) && + Number.isFinite(rect.height) + ); +} + +function isSlotFramePlacementValid( + frame: SlotFrame, + existingFrames: readonly SlotFrame[], + centralCollisionRects: readonly StableRect[] +): boolean { + if (!isFiniteRect(frame.bounds)) { + return false; + } + if (rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)) { + return false; + } + return !existingFrames.some((existing) => + rectsOverlapWithGap(frame.bounds, existing.bounds, SLOT_GEOMETRY.ringPadding) + ); +} + +function buildAssignmentKey(assignment: GraphOwnerSlotAssignment): string { + return `${assignment.ringIndex}:${assignment.sectorIndex}`; +} + +function isSameAssignment( + left: GraphOwnerSlotAssignment | undefined, + right: GraphOwnerSlotAssignment +): boolean { + return ( + left?.ringIndex === right.ringIndex && + left?.sectorIndex === right.sectorIndex + ); +} + +function createRect(left: number, top: number, width: number, height: number): StableRect { + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + }; +} + +function createCenteredRect(centerX: number, centerY: number, width: number, height: number): StableRect { + return createRect(centerX - width / 2, centerY - height / 2, width, height); +} + +function padRect(rect: StableRect, padding: number): StableRect { + return createRect(rect.left - padding, rect.top - padding, rect.width + padding * 2, rect.height + padding * 2); +} + +function translateRect(rect: StableRect, dx: number, dy: number): StableRect { + return createRect(rect.left + dx, rect.top + dy, rect.width, rect.height); +} + +function unionRects(rects: StableRect[]): StableRect { + const left = Math.min(...rects.map((rect) => rect.left)); + const top = Math.min(...rects.map((rect) => rect.top)); + const right = Math.max(...rects.map((rect) => rect.right)); + const bottom = Math.max(...rects.map((rect) => rect.bottom)); + return createRect(left, top, right - left, bottom - top); +} diff --git a/packages/agent-graph/src/ports/GraphDataPort.ts b/packages/agent-graph/src/ports/GraphDataPort.ts index ec4c5ce0..965cd8a1 100644 --- a/packages/agent-graph/src/ports/GraphDataPort.ts +++ b/packages/agent-graph/src/ports/GraphDataPort.ts @@ -1,4 +1,4 @@ -import type { GraphNode, GraphEdge, GraphParticle } from './types'; +import type { GraphNode, GraphEdge, GraphParticle, GraphLayoutPort } from './types'; /** * Data provider port — supplies graph state to the visualization. @@ -17,4 +17,6 @@ export interface GraphDataPort { teamColor?: string; /** Whether the team lead process is alive */ isAlive?: boolean; + /** Stable owner-slot layout hints supplied by the host app */ + layout?: GraphLayoutPort; } diff --git a/packages/agent-graph/src/ports/index.ts b/packages/agent-graph/src/ports/index.ts index 532bc497..8bdc01dd 100644 --- a/packages/agent-graph/src/ports/index.ts +++ b/packages/agent-graph/src/ports/index.ts @@ -5,9 +5,13 @@ export type { GraphNode, GraphEdge, GraphParticle, + GraphActivityItem, GraphNodeKind, GraphNodeState, GraphEdgeType, GraphParticleKind, GraphDomainRef, + GraphOwnerSlotAssignment, + GraphLayoutPort, + GraphLayoutVersion, } from './types'; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 931f38e2..af439ae4 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -48,6 +48,19 @@ export interface GraphActivityItem { authorLabel?: string; } +export type GraphLayoutVersion = 'stable-slots-v1'; + +export interface GraphOwnerSlotAssignment { + ringIndex: number; + sectorIndex: number; +} + +export interface GraphLayoutPort { + version: GraphLayoutVersion; + ownerOrder: string[]; + slotAssignments: Record; +} + // ─── Graph Node ────────────────────────────────────────────────────────────── export interface GraphNode { @@ -71,6 +84,8 @@ export interface GraphNode { spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; /** Shared launch-stage visual derived by the host app */ launchVisualState?: GraphLaunchVisualState; + /** Shared launch-stage text shown beside the node during launch only */ + launchStatusLabel?: string; /** Context window usage ratio (0..1), available for lead only */ contextUsage?: number; /** Current task ID this member is working on */ diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 1e8c893d..516e3a5f 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -24,6 +24,7 @@ import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; +import { drawHexagon } from '../canvas/draw-misc'; import { BloomRenderer } from '../canvas/bloom-renderer'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { @@ -36,6 +37,7 @@ import { updateTransientHandoffState, } from './transientHandoffs'; import type { CameraTransform } from '../hooks/useGraphCamera'; +import { NODE } from '../constants/canvas-constants'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── @@ -53,6 +55,14 @@ export interface GraphDrawState { hoveredEdgeId: string | null; focusNodeIds: ReadonlySet | null; focusEdgeIds: ReadonlySet | null; + dragPreview: + | { + nodeId: string; + x: number; + y: number; + color?: string | null; + } + | null; } export interface GraphCanvasHandle { @@ -341,6 +351,9 @@ export const GraphCanvas = forwardRef(funct state.focusNodeIds, zoom ); + if (state.dragPreview) { + drawOwnerSlotPreview(ctx, state.dragPreview, state.time); + } // 2d. Effects drawEffects(ctx, state.effects); @@ -437,3 +450,47 @@ export const GraphCanvas = forwardRef(funct ); }); + +function drawOwnerSlotPreview( + ctx: CanvasRenderingContext2D, + preview: NonNullable, + time: number +): void { + const radius = NODE.radiusMember; + const outerRadius = radius + 18; + const innerRadius = radius + 8; + const glowRadius = radius + 34; + const color = preview.color ?? '#8bd3ff'; + const pulse = 0.35 + 0.15 * Math.sin(time * 6); + + ctx.save(); + ctx.globalAlpha = 0.7 + pulse; + ctx.setLineDash([8, 6]); + ctx.lineDashOffset = -time * 48; + ctx.lineWidth = 2.5; + + drawHexagon(ctx, preview.x, preview.y, outerRadius); + ctx.strokeStyle = color; + ctx.stroke(); + + ctx.setLineDash([]); + drawHexagon(ctx, preview.x, preview.y, innerRadius); + ctx.fillStyle = 'rgba(120, 190, 255, 0.08)'; + ctx.fill(); + + const glow = ctx.createRadialGradient( + preview.x, + preview.y, + radius * 0.45, + preview.x, + preview.y, + glowRadius + ); + glow.addColorStop(0, 'rgba(120, 190, 255, 0.12)'); + glow.addColorStop(1, 'rgba(120, 190, 255, 0)'); + ctx.beginPath(); + ctx.arc(preview.x, preview.y, glowRadius, 0, Math.PI * 2); + ctx.fillStyle = glow; + ctx.fill(); + ctx.restore(); +} diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 415b13fd..801b4a8b 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -13,6 +13,8 @@ import { EyeOff, Maximize2, Pause, + PanelLeftClose, + PanelLeftOpen, Pin, Play, Plus, @@ -41,9 +43,13 @@ export interface GraphControlsProps { onRequestFullscreen?: () => void; onOpenTeamPage?: () => void; onCreateTask?: () => void; + onToggleSidebar?: () => void; + isSidebarVisible?: boolean; teamName: string; teamColor?: string; isAlive?: boolean; + topToolbarContent?: React.ReactNode; + interactionLocked?: boolean; } const TOPBAR_BUTTON_SIZE = 25; @@ -60,7 +66,11 @@ export function GraphControls({ onRequestFullscreen, onOpenTeamPage, onCreateTask, + onToggleSidebar, + isSidebarVisible = true, teamColor, + topToolbarContent, + interactionLocked = false, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -96,141 +106,176 @@ export function GraphControls({ }, [isSettingsOpen]); const nameColor = teamColor ?? '#aaeeff'; + const chromeInteractivityClass = interactionLocked + ? 'pointer-events-none select-none' + : 'pointer-events-auto'; return ( <> -
- {onOpenTeamPage ? ( -
- } - toolbar - title="Open team page" - /> -
- ) : null} - {onCreateTask ? ( -
- } - toolbar - title="Create task" - /> -
- ) : null} -
- -
-
- toggle('paused')} - icon={filters.paused ? : } - toolbar - title={filters.paused ? 'Resume animation' : 'Pause animation'} - /> +
+
+ {onToggleSidebar ? ( +
+ + ) : ( + + ) + } + toolbar + title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'} + /> +
+ ) : null} + {onOpenTeamPage ? ( +
+ } + toolbar + title="Open team page" + /> +
+ ) : null} + {onCreateTask ? ( +
+ } + toolbar + title="Create task" + /> +
+ ) : null}
-
+
+ {topToolbarContent ? ( +
+ {topToolbarContent} +
+ ) : null} +
+ +
setIsSettingsOpen((value) => !value)} - icon={} - active={isSettingsOpen} + onClick={() => toggle('paused')} + icon={filters.paused ? : } toolbar - title="Graph settings" + title={filters.paused ? 'Resume animation' : 'Pause animation'} />
- {isSettingsOpen && ( +
- toggle('showTasks')} - icon={} - label="Tasks" - block - /> - toggle('showProcesses')} - icon={} - label="Processes" - block - /> - toggle('showEdges')} - icon={filters.showEdges ? : } - label="Edges" - block + setIsSettingsOpen((value) => !value)} + icon={} + active={isSettingsOpen} + toolbar + title="Graph settings" />
- )} -
-
- {onRequestPinAsTab && ( - } - toolbar - title="Pin as tab" - /> - )} - {onRequestFullscreen && ( - } - toolbar - title="Fullscreen" - /> - )} - {onRequestClose && ( - } - toolbar - title="Close graph" - /> - )} + {isSettingsOpen && ( +
+ toggle('showTasks')} + icon={} + label="Tasks" + block + /> + toggle('showProcesses')} + icon={} + label="Processes" + block + /> + toggle('showEdges')} + icon={filters.showEdges ? : } + label="Edges" + block + /> +
+ )} +
+ +
+ {onRequestPinAsTab && ( + } + toolbar + title="Pin as tab" + /> + )} + {onRequestFullscreen && ( + } + toolbar + title="Fullscreen" + /> + )} + {onRequestClose && ( + } + toolbar + title="Close graph" + /> + )} +
diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 90279acb..c20ed598 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -15,8 +15,9 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; -import type { GraphEdge, GraphNode } from '../ports/types'; -import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; +import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; +import type { StableRect } from '../layout/stableSlots'; +import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas'; import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; import { GraphEdgeOverlay } from './GraphEdgeOverlay'; @@ -31,7 +32,6 @@ 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 { @@ -43,8 +43,18 @@ export interface GraphViewProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + isSurfaceActive?: boolean; onOpenTeamPage?: () => void; onCreateTask?: () => void; + onToggleSidebar?: () => void; + isSidebarVisible?: boolean; + renderTopToolbarContent?: () => React.ReactNode; + onOwnerSlotDrop?: (payload: { + nodeId: string; + assignment: GraphOwnerSlotAssignment; + displacedNodeId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + }) => void; /** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */ renderOverlay?: (props: { node: GraphNode; @@ -62,12 +72,11 @@ export interface GraphViewProps { getLaunchAnchorScreenPlacement: ( leadNodeId: string, ) => { x: number; y: number; scale: number; visible: boolean } | null; - getActivityAnchorScreenPlacement: ( - ownerNodeId: string, - ) => { x: number; y: number; scale: number; visible: boolean } | null; - getNodeScreenPosition: ( - nodeId: string, - ) => { x: number; y: number; visible: boolean } | null; + getActivityWorldRect: (ownerNodeId: string) => StableRect | null; + getCameraZoom: () => number; + worldToScreen: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null; + getViewportSize: () => { width: number; height: number }; focusNodeIds: ReadonlySet | null; }) => React.ReactNode; } @@ -81,8 +90,13 @@ export function GraphView({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + isSurfaceActive = true, onOpenTeamPage, onCreateTask, + onToggleSidebar, + isSidebarVisible = true, + renderTopToolbarContent, + onOwnerSlotDrop, renderOverlay, renderEdgeOverlay, renderHud, @@ -90,6 +104,7 @@ export function GraphView({ // ─── React state (user-facing only) ───────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [interactionLocked, setInteractionLocked] = useState(false); const [filters, setFilters] = useState({ showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, @@ -115,6 +130,14 @@ export function GraphView({ const allowAutoFitRef = useRef(true); const nodeMapRef = useRef(new Map()); const nodeMapNodesRef = useRef(null); + const dragPreviewRef = useRef<{ + nodeId: string; + x: number; + y: number; + color?: string | null; + } | null>(null); + const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); + const activePrimaryInteractionRef = useRef(false); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -135,18 +158,31 @@ export function GraphView({ ) ); + const getVisibleNodes = useCallback( + (nodes: GraphNode[]): GraphNode[] => + nodes.filter((node) => { + if (node.kind === 'task' && !filters.showTasks) return false; + if (node.kind === 'process' && !filters.showProcesses) return false; + return true; + }), + [filters.showProcesses, filters.showTasks] + ); + + const getVisibleEdges = useCallback( + (edges: GraphEdge[], visibleNodeIds: ReadonlySet): GraphEdge[] => + edges.filter((edge) => { + if (!filters.showEdges && edge.type !== 'parent-child') { + return false; + } + return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target); + }), + [filters.showEdges] + ); + // ─── Sync data from adapter → simulation ──────────────────────────────── useEffect(() => { - const filteredNodes = data.nodes.filter((n) => { - if (n.kind === 'task' && !filters.showTasks) return false; - if (n.kind === 'process' && !filters.showProcesses) return false; - return true; - }); - const filteredEdges = filters.showEdges - ? data.edges - : data.edges.filter((e) => e.type === 'parent-child'); - simulation.updateData(filteredNodes, filteredEdges, data.particles); - }, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]); + simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout); + }, [data, simulation]); // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── const focusState = useMemo( @@ -209,44 +245,51 @@ export function GraphView({ viewportHeight: viewport.height, }); }, [getViewportSize]); - const getActivityAnchorScreenPlacement = useCallback((ownerNodeId: string) => { - const anchor = simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId); - if (!anchor) { - return null; - } - const viewport = getViewportSize(); - if (viewport.width <= 0 || viewport.height <= 0) { - return null; - } - const transform = cameraRef.current.transformRef.current; - return buildActivityAnchorScreenPlacement({ - anchorX: anchor.x, - anchorY: anchor.y, - cameraX: transform.x, - cameraY: transform.y, - zoom: transform.zoom, - viewportWidth: viewport.width, - viewportHeight: viewport.height, - }); - }, [getViewportSize]); - const getNodeScreenPosition = useCallback((nodeId: string) => { - const viewport = getViewportSize(); - if (viewport.width <= 0 || viewport.height <= 0) { - return null; - } + const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []); + const getActivityWorldRect = useCallback( + (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), + [] + ); + const getNodeWorldPosition = useCallback((nodeId: string) => { const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); - if (!node || node.x == null || node.y == null) { + if (node?.x == null || node?.y == null) { return null; } - const transform = cameraRef.current.transformRef.current; - const x = node.x * transform.zoom + transform.x; - const y = node.y * transform.zoom + transform.y; - return { - x, - y, - visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80, - }; - }, [getViewportSize]); + return { x: node.x, y: node.y }; + }, []); + + const setInteractionSelectionDisabled = useCallback((disabled: boolean) => { + if (typeof document === 'undefined') { + return; + } + const bodyStyle = document.body.style; + if (disabled) { + if (!selectionLockRef.current) { + selectionLockRef.current = { + userSelect: bodyStyle.userSelect, + webkitUserSelect: bodyStyle.webkitUserSelect, + }; + } + bodyStyle.userSelect = 'none'; + bodyStyle.webkitUserSelect = 'none'; + return; + } + if (!selectionLockRef.current) { + return; + } + bodyStyle.userSelect = selectionLockRef.current.userSelect; + bodyStyle.webkitUserSelect = selectionLockRef.current.webkitUserSelect; + selectionLockRef.current = null; + }, []); + + const setInteractionGuards = useCallback( + (active: boolean) => { + activePrimaryInteractionRef.current = active; + setInteractionLocked(active); + setInteractionSelectionDisabled(active); + }, + [setInteractionSelectionDisabled] + ); const animate = useCallback(() => { if (!runningRef.current) return; @@ -266,12 +309,15 @@ export function GraphView({ // 3. Draw every frame: background stars and shooting stars need continuous motion. const state = simulationRef.current.stateRef.current; + const visibleNodes = getVisibleNodes(state.nodes); + const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); + const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds); // 4. Draw canvas imperatively (NO React re-render) canvasHandle.current?.draw({ teamName: data.teamName, - nodes: state.nodes, - edges: state.edges, + nodes: visibleNodes, + edges: visibleEdges, particles: state.particles, effects: state.effects, time: state.time, @@ -282,11 +328,18 @@ export function GraphView({ hoveredEdgeId: hoveredEdgeIdRef.current, focusNodeIds: focusState.focusNodeIds, focusEdgeIds: focusState.focusEdgeIds, + dragPreview: dragPreviewRef.current, }); rafRef.current = requestAnimationFrame(animate); - // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs - }, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]); + }, [ + data.teamName, + focusState.focusEdgeIds, + focusState.focusNodeIds, + getVisibleEdges, + getVisibleNodes, + interaction.hoveredNodeId, + ]); // Start/stop RAF useEffect(() => { @@ -362,6 +415,18 @@ export function GraphView({ allowAutoFitRef.current = false; }, []); + useLayoutEffect(() => { + if (!isSurfaceActive) { + return; + } + interaction.handleMouseUp(); + simulation.clearTransientOwnerPositions(); + dragPreviewRef.current = null; + isPanningRef.current = false; + edgeMouseDownRef.current = null; + setInteractionGuards(false); + }, [interaction, isSurfaceActive, simulation]); + const handleWheel = useCallback( (e: WheelEvent) => { markUserInteracted(); @@ -377,13 +442,20 @@ export function GraphView({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click + e.preventDefault(); + dragPreviewRef.current = null; + setInteractionGuards(true); const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; + if (!canvas) { + setInteractionGuards(false); + 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 nodes = getVisibleNodes(simulation.stateRef.current.nodes); + const visibleNodeIds = new Set(nodes.map((node) => node.id)); + const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds); const nodeMap = getNodeMap(nodes); const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); @@ -414,62 +486,119 @@ export function GraphView({ } } }, - [camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef] + [ + camera, + getInteractiveEdges, + getNodeMap, + getVisibleEdges, + getVisibleNodes, + interaction, + markUserInteracted, + setInteractionGuards, + 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 processActivePointerMove = useCallback( + (clientX: number, clientY: number) => { + if (!activePrimaryInteractionRef.current) { + dragPreviewRef.current = null; + return false; + } + + if (isPanningRef.current) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); } - 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; + camera.handlePanMove(clientX, clientY); + return true; } - // 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; + if (!canvas) { + dragPreviewRef.current = null; + return false; } - 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'; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); + interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); + + const draggedNodeId = interaction.dragNodeId.current; + if (interaction.isDragging.current && draggedNodeId) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } + const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); + if (draggedNode?.kind === 'member') { + const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y); + if (nearest) { + dragPreviewRef.current = { + nodeId: draggedNodeId, + x: nearest.previewOwnerX, + y: nearest.previewOwnerY, + color: draggedNode.color, + }; + return true; + } + } + } + + dragPreviewRef.current = null; + return true; }, - [camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef] + [camera, getVisibleNodes, interaction, simulation] ); - const handleMouseUp = useCallback( - (e: React.MouseEvent) => { + const completePointerInteraction = useCallback( + (clientX: number, clientY: number) => { + const draggedNodeId = interaction.dragNodeId.current; + const wasDragging = interaction.isDragging.current; + if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; - setSelectedNodeId(null); // hide popover after pan + setInteractionGuards(false); + dragPreviewRef.current = null; + setSelectedNodeId(null); setSelectedEdgeId(null); edgeMouseDownRef.current = null; + interaction.handleMouseUp(); return; } const clickedId = interaction.handleMouseUp(); + if (wasDragging && draggedNodeId) { + setInteractionGuards(false); + const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); + if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) { + const nearest = simulation.resolveNearestOwnerSlot( + draggedNodeId, + draggedNode.x, + draggedNode.y + ); + if (nearest) { + onOwnerSlotDrop?.({ + nodeId: draggedNodeId, + assignment: nearest.assignment, + displacedNodeId: nearest.displacedOwnerId, + displacedAssignment: nearest.displacedAssignment, + }); + requestAnimationFrame(() => { + simulation.clearNodePosition(draggedNodeId); + }); + dragPreviewRef.current = null; + edgeMouseDownRef.current = null; + return; + } + } + simulation.clearNodePosition(draggedNodeId); + dragPreviewRef.current = null; + edgeMouseDownRef.current = null; + return; + } + + setInteractionGuards(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -480,7 +609,7 @@ export function GraphView({ 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 world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); const dx = world.x - edgeMouseDownRef.current.x; const dy = world.y - edgeMouseDownRef.current.y; if (dx * dx + dy * dy <= 25) { @@ -499,17 +628,121 @@ export function GraphView({ events?.onEdgeClick?.(edge); } } else { - setSelectedNodeId(null); // click on empty space — hide popover + setSelectedNodeId(null); setSelectedEdgeId(null); } if (!interaction.isDragging.current && !clickedEdgeId) { events?.onBackgroundClick?.(); } } + dragPreviewRef.current = null; }, - [interaction, simulation.stateRef, events, camera, data.teamName] + [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (processActivePointerMove(e.clientX, e.clientY)) { + return; + } + + dragPreviewRef.current = null; + + 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 = getVisibleNodes(simulation.stateRef.current.nodes); + const visibleNodeIds = new Set(nodes.map((node) => node.id)); + const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds); + + 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, + getVisibleEdges, + getVisibleNodes, + interaction, + processActivePointerMove, + simulation.stateRef, + ] + ); + + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + completePointerInteraction(e.clientX, e.clientY); + }, + [completePointerInteraction] + ); + + useEffect(() => { + const handleWindowMouseMove = (event: MouseEvent): void => { + if ( + !activePrimaryInteractionRef.current && + !isPanningRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current && + !edgeMouseDownRef.current + ) { + return; + } + event.preventDefault(); + processActivePointerMove(event.clientX, event.clientY); + }; + + const handleWindowMouseUp = (event: MouseEvent): void => { + if ( + !activePrimaryInteractionRef.current && + !isPanningRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current && + !edgeMouseDownRef.current + ) { + setInteractionGuards(false); + return; + } + completePointerInteraction(event.clientX, event.clientY); + }; + + const clearInteraction = (): void => { + if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { + return; + } + interaction.handleMouseUp(); + camera.handlePanEnd(); + isPanningRef.current = false; + edgeMouseDownRef.current = null; + dragPreviewRef.current = null; + setInteractionGuards(false); + }; + + window.addEventListener('mousemove', handleWindowMouseMove); + window.addEventListener('mouseup', handleWindowMouseUp); + window.addEventListener('blur', clearInteraction); + window.addEventListener('dragstart', clearInteraction); + return () => { + window.removeEventListener('mousemove', handleWindowMouseMove); + window.removeEventListener('mouseup', handleWindowMouseUp); + window.removeEventListener('blur', clearInteraction); + window.removeEventListener('dragstart', clearInteraction); + setInteractionGuards(false); + }; + }, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); + const handleDoubleClick = useCallback( (e: React.MouseEvent) => { const canvas = canvasHandle.current?.getCanvas(); @@ -519,7 +752,7 @@ export function GraphView({ const nodeId = interaction.handleDoubleClick( world.x, world.y, - simulation.stateRef.current.nodes + getVisibleNodes(simulation.stateRef.current.nodes) ); if (nodeId) { setSelectedEdgeId(null); @@ -534,7 +767,7 @@ export function GraphView({ } } }, - [camera, interaction, simulation.stateRef, events] + [camera, events, getVisibleNodes, interaction, simulation.stateRef] ); // ─── Keyboard ─────────────────────────────────────────────────────────── @@ -579,10 +812,6 @@ export function GraphView({ const selectedEdge: GraphEdge | null = selectedEdgeId ? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null) : null; - const hasBlockingEdges = useMemo( - () => data.edges.some((edge) => edge.type === 'blocking'), - [data.edges] - ); const selectedEdgeNodeMap = useMemo( () => getNodeMap(simulation.stateRef.current.nodes), [data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef] @@ -660,7 +889,10 @@ export function GraphView({ // ─── Render ───────────────────────────────────────────────────────────── return ( -
+
{renderHud ? (
{renderHud({ getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getNodeScreenPosition, + getActivityWorldRect, + getCameraZoom, + worldToScreen: camera.worldToScreen, + getNodeWorldPosition, + getViewportSize, focusNodeIds: focusState.focusNodeIds, })}
diff --git a/resources/pricing.json b/resources/pricing.json index 85e94069..c8e27349 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -311,7 +311,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, @@ -338,7 +339,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -365,7 +367,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -392,7 +395,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -419,6 +423,147 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true + }, + "anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "global.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "us.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "eu.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "au.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "anthropic.claude-sonnet-4-6": { @@ -854,6 +999,35 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true + }, + "azure_ai/claude-opus-4-7": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 159 }, "azure_ai/claude-opus-4-1": { @@ -1687,7 +1861,8 @@ "provider_specific_entry": { "us": 1.1, "fast": 6 - } + }, + "supports_max_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 0.00000625, @@ -1715,6 +1890,71 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6 + }, + "supports_max_reasoning_effort": true + }, + "claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6 + } + }, + "claude-opus-4-7-20260416": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 @@ -4148,7 +4388,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 0.00000625, @@ -4174,6 +4415,61 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346 + }, + "vertex_ai/claude-opus-4-7@default": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346 }, "vertex_ai/claude-sonnet-4-5": { diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs new file mode 100644 index 00000000..159415ef --- /dev/null +++ b/scripts/dev-web.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const standalonePort = process.env.STANDALONE_PORT?.trim() || '3456'; +const webPort = process.env.WEB_PORT?.trim() || '5174'; +const corsOrigin = + process.env.CORS_ORIGIN?.trim() || + `http://127.0.0.1:${webPort},http://localhost:${webPort}`; + +const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); + +function shouldUseWindowsShell(cmd) { + if (process.platform !== 'win32') { + return false; + } + + return WINDOWS_SHELL_COMMANDS.has(path.basename(cmd).toLowerCase()); +} + +function spawnProcess(cmd, args, env) { + return spawn(cmd, args, { + cwd: repoRoot, + env: { ...process.env, ...env }, + stdio: 'inherit', + shell: shouldUseWindowsShell(cmd), + }); +} + +const backend = spawnProcess('pnpm', ['exec', 'tsx', 'src/main/standalone.ts'], { + HOST: process.env.HOST?.trim() || '127.0.0.1', + PORT: standalonePort, + CORS_ORIGIN: corsOrigin, +}); + +const frontend = spawnProcess( + 'pnpm', + ['exec', 'vite', '--config', 'vite.web.config.ts', '--host', '127.0.0.1', '--port', webPort], + { + VITE_STANDALONE_PORT: standalonePort, + VITE_WEB_PORT: webPort, + } +); + +let shuttingDown = false; + +function terminateChildren(signal = 'SIGTERM') { + if (shuttingDown) { + return; + } + + shuttingDown = true; + backend.kill(signal); + frontend.kill(signal); +} + +backend.on('exit', (code, signal) => { + terminateChildren(signal ?? undefined); + process.exitCode = code ?? (signal ? 1 : 0); +}); + +frontend.on('exit', (code, signal) => { + terminateChildren(signal ?? undefined); + process.exitCode = code ?? (signal ? 1 : 0); +}); + +process.on('SIGINT', () => terminateChildren('SIGINT')); +process.on('SIGTERM', () => terminateChildren('SIGTERM')); + +console.log(`Starting standalone backend on http://127.0.0.1:${standalonePort}`); +console.log(`Starting browser dev server on http://127.0.0.1:${webPort}`); diff --git a/src/features/README.md b/src/features/README.md new file mode 100644 index 00000000..4e9851ff --- /dev/null +++ b/src/features/README.md @@ -0,0 +1,24 @@ +# Features + +This directory contains the canonical home for medium and large feature slices. + +Before creating or refactoring a feature, read: +- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) +- [Feature-local agent guidance](./CLAUDE.md) + +Reference implementation: +- `src/features/recent-projects` +- `src/features/agent-graph` + +Use `src/features//` by default when the work introduces: +- a new use case or business policy +- transport wiring +- more than one process boundary +- more than one adapter or provider + +Do not duplicate architecture rules in feature folders. +Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md). + +Rule of thumb: +- `recent-projects` is the full slice example with process-aware outer layers +- `agent-graph` is the thin slice example built around `core/` plus `renderer/` diff --git a/src/features/agent-graph/README.md b/src/features/agent-graph/README.md new file mode 100644 index 00000000..0c3a4729 --- /dev/null +++ b/src/features/agent-graph/README.md @@ -0,0 +1,21 @@ +# Agent Graph Feature + +This feature is a thin renderer slice over the reusable graph engine in `packages/agent-graph`. + +Read first: +- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) +- [Feature root guidance](../CLAUDE.md) +- [Stable Slot Layout Plan](./STABLE_SLOT_LAYOUT_PLAN.md) + +Public entrypoint: +- `@features/agent-graph/renderer` + +Responsibilities: +- `packages/agent-graph` owns reusable graph rendering and low-level graph mechanics +- `src/features/agent-graph/core/domain` owns project-specific graph semantics and pure projection helpers +- `src/features/agent-graph/renderer` owns the renderer integration layer, hooks, adapters, and UI + +Use this feature as the thin-slice example when a feature: +- has no dedicated `main` or `preload` transport boundary +- integrates an existing reusable package into the app shell +- still needs its own feature boundary and public entrypoint diff --git a/src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md b/src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md new file mode 100644 index 00000000..04402f6c --- /dev/null +++ b/src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md @@ -0,0 +1,2846 @@ +# Agent Graph - Stable Slot Layout Plan + +## Статус + +- Дата фиксации: `2026-04-15` +- Выбранный подход: `Variant 3 - stable sectors around lead` +- Оценка выбранного подхода: `🎯 8 🛡️ 9 🧠 8` +- Примерный объём: `500-1100 строк + тесты + cleanup` +- Тип документа: `implementation spec` + +Этот документ считается **нормативным** для следующей большой переделки layout графа. + +Если в документе что-то сформулировано как "фиксируем", "обязательно", "не делаем", это уже не brainstorming, а зафиксированное решение. Переоткрывать такие решения в процессе реализации не нужно, если только не найден новый критичный риск. + +## Нормативность разделов + +Чтобы не было путаницы между spec и advisory-текстом, фиксируем это явно. + +### Нормативные разделы + +Нормативными считаются все разделы, которые описывают: + +- model / topology +- stable identity +- storage/source of truth +- geometry contracts +- planner rules +- validation / commit behavior +- drag / fit / filter semantics +- acceptance criteria +- test plan + +Именно они определяют, как feature должна работать. + +### Advisory разделы + +Advisory-разделами считаются: + +- `Recommended PR split` +- `Что ещё можно тюнить без изменения архитектуры` +- примеры псевдокода, если они не противоречат более строгим правилам выше + +Они помогают выполнять refactor, но не имеют права переопределять core rules. + +### Правило при конфликте + +Если пример, псевдокод или PR-split визуально расходятся с более строгим invariant/rule/acceptance пунктом, побеждает более строгий invariant/rule/acceptance пункт. + +## TL;DR + +Мы уходим от текущей полусвободной схемы, где owner-зоны лечатся локальными packer-ами и overlay-хитростями, и переходим к более стабильной модели: + +- `lead` остаётся в центре +- каждый `member` получает **свой slot** +- slots распределяются **по стабильным секторам вокруг lead** +- если места не хватает, используется **second ring**, а при необходимости и следующие outer rings +- внутри каждого slot layout всегда один и тот же: + - `Activity` сверху + - `Member` в центре + - `Process` как attached sub-rail + - `Tasks` снизу +- drag сохраняет не `x/y`, а `slot assignment` +- `tasks` bounded по высоте до `5` visible rows +- `activity` bounded до `3` items +- все active non-empty kanban columns показываются, а `slot width` увеличивается, чтобы их вместить + +Главный результат, который должен почувствовать пользователь: + +- у каждого участника есть своё место +- `Activity` и `Tasks` больше не налезают на чужие зоны +- zoom/pan больше не создают ощущение, что owner-local UI "плавает" отдельно от диаграммы +- dense teams читаются стабильнее и предсказуемее + +## Quick decision table + +Чтобы implementer не листал весь документ ради очевидного ответа, фиксируем самые важные развилки прямо здесь. + +- `lead` - не обычный member slot, а `central reserved block` +- `member` - получает `member sector slot` +- `unassigned tasks` - получают `special slot` под lead только если такие задачи реально есть в dataset +- `showTasks/showProcesses/showEdges` - скрывают presentation, но не меняют layout topology +- `graph tab/fullscreen` - шарят layout state, но могут иметь разный camera state +- `slotAssignmentsByTeam` - хранит только member sector assignments +- `teamName` в `v1` - storage scope key для layout state +- `member.name -> agentId` - one-time migration, если version не сбрасывался +- `team rename` - автоматическая миграция layout state не входит в этот refactor +- `no lead in dataset` - новый planner не должен пытаться строить stable sectors без lead + +## State transition matrix + +Это краткая operational-шпаргалка: какое событие что именно имеет право менять. + +| Событие | Меняется owner set | Меняется slot assignment | Меняется camera state | Нужен planner run | Примечание | +|---|---|---:|---:|---:|---| +| `zoom / pan` | нет | нет | да | нет | только camera transform | +| `showTasks / showProcesses / showEdges` | нет | нет | нет | нет | меняется только presentation | +| новый `message/comment` без роста footprint | нет | нет | нет | нет | activity content update only | +| process content update без роста reserved band | нет | нет | нет | нет | process presentation only | +| growth owner footprint | нет | возможно | нет | да | partial replanning only | +| `member add` | да | да | нет | да | новый owner ищет первый valid slot | +| `member remove/hide` | да | нет для остальных | нет | возможно | без global compaction | +| hidden member reappears | да | обычно нет | нет | возможно | сначала пробуем старый assignment | +| drag/drop owner | нет | да | нет | да | snap/swap path | +| `member.name -> agentId` | нет | возможно | нет | возможно | one-time migration before planner | +| team switch | да, scoped | нет | да | нет | просто переключаем team-scoped state | +| team rename | зависит от storage key | как новый scope в `v1` | нет | возможно | auto-migration не входит | +| no lead transient state | dataset invalid для planner | нет | нет | нет | safe fallback only | + +## Coder Start Here + +Если начинать реализацию прямо сейчас, безопасный порядок такой: + +1. Сначала протянуть `agentId` и перевести owner identity на `stableOwnerId` +2. Потом вынести pure planner helpers и покрыть их unit tests +3. Потом добавить `slotAssignmentsByTeam` как новый source of truth +4. Потом интегрировать planner в owner placement +5. Потом привязать `Tasks`, `Process`, `Activity` к `slot frame` +6. Потом сделать drag/snap/swap +7. Потом удалить старые geometry paths + +Нельзя начинать с UI и рисования activity/task zones поверх старой geometry - это почти гарантированно снова создаст двусмысленность и временные баги. + +## Responsibility split by layer + +Чтобы реализация не размазала layout-логику по разным React paths, фиксируем ownership заранее. + +### Data / adapter layer отвечает только за content model + +Сюда относится: + +- `stableOwnerId` +- grouping задач, activity и process-данных по owner-у +- вычисление active non-empty kanban columns +- подготовка content metadata для task/activity/process bands + +Сюда **не** относится: + +- вычисление world positions +- slot assignment +- screen-space correction +- DOM measurement + +### Layout / planner layer отвечает только за topology и geometry + +Сюда относится: + +- `OwnerFootprint` +- `slotAssignmentsByTeam` +- `SlotFrame` +- ring / sector planning +- collision / exclusion validation +- fit bounds + +Сюда **не** относится: + +- отрисовка текста +- hover state +- tooltip placement +- post-render visual "подталкивание" lane-ов + +### Renderer / interaction layer отвечает только за presentation + +Сюда относится: + +- drawing canvas / DOM content внутри уже готового `SlotFrame` +- hit testing +- hover / selection / popover +- drag preview и commit нового assignment + +Сюда **не** относится: + +- решение cross-owner overlap +- самостоятельный пересчёт slot width/height +- отдельный layout path для fullscreen vs tab + +## Persistent vs derived state + +Одна из главных вещей, которую нельзя оставить "по ощущениям" - что именно мы храним, а что каждый раз честно пересчитываем. + +### Persistent state + +Храним только то, что реально должно переживать refresh и reopening: + +- `slotAssignmentsByTeam[teamName][stableOwnerId]` +- `slotLayoutVersion` + +Опционально можно хранить технические timestamp/debug markers, но они не должны становиться источником истины для geometry. + +### Derived state + +Каждый planner run заново считает: + +- `visible owner set` +- `OwnerFootprint` +- `lead central reserved block` +- `runtimeCentralExclusion` +- `SlotFrame[]` +- `fit bounds` +- band-local anchors + +### Session-local last valid snapshot cache + +Для `v1` дополнительно допускается **только session-local** cache последнего валидного `StableSlotLayoutSnapshot` по `teamName`. + +Это не persistent source of truth для placement, а технический safety/cache layer. + +#### Что это значит + +- assignments остаются source of truth для member placement +- snapshot cache не заменяет assignments +- snapshot cache не пишется в config/main/storage как обязательная часть этого refactor +- snapshot cache используется только для safe handoff между planner runs и для fail-closed поведения + +#### Что нельзя делать + +- использовать snapshot cache как новую альтернативу `slotAssignmentsByTeam` +- восстанавливать из него layout identity независимо от текущих assignments +- silently коммитить stale snapshot как будто это новый валидный planner result + +### Что принципиально не должно быть persistent + +- raw `x/y` +- screen-space coordinates +- post-render measured widths/heights +- activity/process/task band positions +- `lead central reserved block` +- `unassigned task slot` + +Иначе layout снова станет зависеть от случайного порядка рендера и старых геометрических хвостов. + +## Hard invariants + +Ниже набор правил, которые нельзя нарушать в реализации даже временно, если только это не изолированный промежуточный локальный WIP. + +- owner placement source of truth - это `slot assignment`, не raw `x/y` +- `lead` всегда центральный anchor +- slot contents всегда upright и не ротируются по sector angle +- `activity band`, `process band`, `task band` bounded по высоте +- все `active non-empty` kanban columns показываются +- slot width может расти, slot height по bands остаётся bounded +- вся slot geometry считается в `world coordinates`, а не в screen-space +- planner не читает размеры из DOM и не зависит от post-render measurement +- zoom/pan меняют только camera transform +- planner сохраняет existing placements whenever still valid +- старый pack/force/pinning path не должен параллельно переопределять новый planner + +## Что не входит в этот план + +Этот документ описывает именно **layout refactor для owner-local zones**. + +В этот pass не нужно заново переоткрывать или смешивать сюда: + +- redesign review semantics +- новые edge interactions +- minimap +- timeline +- particles redesign +- отдельные эксперименты с process content semantics +- отдельные refactor-ы не связанные с owner slot geometry + +Если что-то из этого потребуется, это должен быть отдельный spec, а не тихое расширение этого плана. + +## Зачем нужен новый layout + +Текущая схема уже лучше, чем старый overlay-path, но всё ещё имеет системную проблему: + +- `Activity` и `Tasks` конкурируют за одно и то же пространство +- owner-local зоны удерживаются постфактум через packer и визуальные коррекции +- при большом числе участников диаграмма становится слишком зависимой от текущей геометрии и порядка обновлений +- поведение при refresh, zoom, плотном графе и смене количества задач остаётся недостаточно предсказуемым + +Пользовательский приоритет в этой фиче уже явно выбран: + +- меньше "живой физики" +- больше стабильности +- больше читаемости +- больше правдивой резервации места под owner-local UI + +## Что зафиксировано окончательно + +Ниже список решений, которые уже приняты. + +## 1. Общая модель layout + +- Используем `stable sectors around lead` +- Не возвращаемся к полностью свободной раскладке owners +- Не делаем "одна жёсткая колонка строго вниз" +- `lead` остаётся в центре как отдельный central reserved block +- `members` располагаются вокруг `lead`, и каждый `member` живёт внутри собственного вертикального slot +- `unassigned tasks`, если они есть, живут в отдельном нижнем bounded slot под `lead` + +## 1.1. Кто получает layout-reserved место + +Чтобы не было разной трактовки состава layout actors, фиксируем это явно. + +### Central reserved block получает + +- `lead` + +`Lead` получает не обычный `member slot`, а свой отдельный центральный reserved block. + +### Sector slot получают + +- каждый **видимый активный member**, который участвует в текущем graph owner set + +### Conditional special slot получает + +- `unassigned tasks`, если в текущем visible graph dataset есть хотя бы одна такая задача + +### Slot не получают как самостоятельные owners + +- tasks +- processes +- particles +- edges +- removed members, если они не должны быть видимы по текущему graph visibility rule + +То есть: + +- у `member` есть sector slot +- у `lead` есть central reserved block +- у `unassigned tasks` есть отдельный special slot только когда он реально нужен +- `tasks/process/activity` - это внутренние band-ы slot-а или central block-а, а не самостоятельные owners + +## 2. Lead + +- `lead` остаётся центральным anchor +- `lead` не является обычным `member slot` +- `lead` не участвует в обычном drag/snap flow +- `lead` всегда остаётся в центре layout +- `launch HUD` для `lead` остаётся отдельной reserved zone +- `lead activity` тоже остаётся отдельной reserved zone, но входит в central exclusion +- `lead` не участвует в member ring planner как ещё один slot candidate +- `lead` не получает обычный member-style `task band` +- Для `v1` фиксируем default стороны: + - `lead activity` - слева от lead + - `launch HUD` - справа от lead + +### Важное уточнение + +Для `v1` `lead activity` использует те же bounded activity rules: + +- `3` visible items +- `+N more` +- `newest first` + +## 3. Activity + +- `Activity` является частью owner slot, а не отдельным свободным overlay-режимом +- Видимых элементов: `3` +- Порядок: **newest first** +- После них показывается `+N more` +- `+N more` открывает профиль участника сразу на вкладке `Activity` +- В activity feed попадают и `messages`, и `comments` +- У комментария target - это `task`, а не другой участник +- Для activity нужно переиспользовать уже существующий compact UI сообщений, а не придумывать новый визуальный язык + +## 4. Tasks + +- Task area живёт внутри owner slot +- Сохраняем current multi-column kanban semantics +- Видимая высота task band ограничена `5` rows +- Все **активные non-empty** kanban columns показываются +- Если колонок больше, `slot width` увеличивается +- Не вводим scroll внутри slot как основную механику +- Не скрываем колонки только ради того, чтобы "поместилось" +- Внутри колонки сохраняем текущую каноническую task order semantics, а не придумываем новый sort-rule в рамках этого refactor + +## 4.1. Unassigned tasks + +Так как в системе могут быть задачи без owner-а, это тоже нужно зафиксировать. + +### Правило для v1 + +- задачи без owner-а не теряются +- они не распределяются случайно по member slots +- для них используется отдельный `unassigned task slot` + +### Где он живёт + +- `unassigned task slot` располагается в нижней части central area, под lead +- он не участвует в member ring placement +- он не draggable +- он создаётся только если есть хотя бы одна видимая unassigned task + +### Что происходит при его появлении или исчезновении + +- это может расширить нижнюю central exclusion зону +- planner может локально вытолкнуть конфликтующие нижние member slots дальше +- это не должно вызывать глобальный reshuffle всех owners + +### Что внутри + +- только `task band` +- без `activity band` +- без `process band` +- bounded по тем же правилам: + - `5 rows` + - все active non-empty columns + - overflow stack per column + +Это отдельный pseudo-owner case, чтобы planner не терял нераспределённые задачи и не пытался притвориться, что их не существует. + +## 5. Process + +- Process остаётся в диаграмме +- Process становится owner-local +- Process отображается как маленький attached sub-rail участника +- Текущий визуальный стиль process хорош и должен быть переиспользован +- Process должен учитываться в slot footprint + +## 6. Drag + +- Drag owner разрешён +- После отпускания owner снапается в ближайший slot +- Если slot занят, по умолчанию делаем `swap` +- Raw `x/y` не сохраняем как источник истины +- Сохраняем именно `slot assignment` + +## 7. Stable identity + +- Основной стабильный идентификатор участника: `config.members[].agentId` +- Fallback: `member.name` +- `ResolvedTeamMember` должен получить `agentId?: string` +- Все layout-решения должны жить на stable owner id, а не на display name + +### Контракт уникальности + +План исходит из того, что внутри одной команды: + +- `agentId` уникален, если он присутствует +- `member.name` уникален в fallback-режиме + +Если это нарушено, реализация не должна тихо склеивать двух owners в один. + +Обязательное поведение: + +- в dev/test - явный assert или error +- в runtime - явный warning/error path без silent merge + +## 8. Width buckets + +- Используем `S / M / L` +- Buckets нужны для packing/planning +- Buckets не имеют права обрезать контент +- Buckets не подменяют реальную ширину выдуманной константой + +## 9. Second ring + +- `Second ring` нужен +- Ring capacity считаем по footprint / budget, а не по тупому member count +- Нельзя строить правило вида "до 10 участников один круг, потом второй" +- В `v1` у каждого ring есть `6` canonical sector anchors, то есть номинально один ring не может вместить больше `6` owners +- Реальная usable capacity ring-а может быть **меньше**, если какие-то slot frames не проходят по footprint / exclusion / gap constraints +- То есть spill на outer ring определяется не условием `memberCount > N`, а реальной валидностью candidate frames +- `Second ring` здесь - это shorthand для outer-ring model. Planner не должен останавливаться ровно на двух rings, если команда реально крупнее + +## 10. Сектора + +- Для `v1` фиксируем `6` секторных направлений вокруг lead +- Порядок секторов - по часовой стрелке, начиная сверху: + - `top` + - `upper-right` + - `lower-right` + - `bottom` + - `lower-left` + - `upper-left` +- Следующий ring использует те же sector ids + +## Уточнения, которые снимают двусмысленность + +Ниже решения, которые нужно понимать **однозначно**, чтобы не было разных трактовок при реализации. + +## 1. Что значит "видно 5 задач" + +Это **не** "5 задач суммарно на весь slot". + +Это значит: + +- task band имеет высоту `5 rows` +- каждая kanban column внутри этого band может использовать максимум `5 visible rows` +- если колонка переполнена, последний visible row превращается в overflow stack + +То есть для overflowing column действует правило: + +- `4 реальных pills + overflow stack` + +Высота task band остаётся постоянной. + +## 2. Что значит "видно 3 activity" + +Это значит: + +- в activity band показываются `3` реальных activity items +- ниже может быть отдельный footer `+N more` +- footer не считается "четвёртым activity item" + +## 3. Что значит "все колонки показываем" + +Это значит: + +- не ограничиваем число видимых **active non-empty** kanban columns +- не вводим horizontal scroll +- не делаем "покажи первые 3, остальные спрячь" +- если активных колонок стало больше, `slot width` должен вырасти + +Это **не** значит, что нужно внезапно начать показывать пустые canonical columns, которых раньше на доске не было видно. Текущее правило "показываем только active non-empty columns" сохраняется. + +### Что считается active non-empty column + +Для этого плана колонка считается `active non-empty`, если в ней есть хотя бы одна task, которая должна быть видима в owner task zone до применения overflow-stack подрезки. + +Проще говоря: + +- пустые канонические колонки не показываем +- колонка с задачами показывается, даже если часть задач ушла в overflow stack + +## 4. Что именно bounded + +Bounded должны быть: + +- высота activity band +- высота process band +- высота task band + +Не bounded: + +- число kanban columns +- общая slot width + +## 5. Когда layout может двигаться + +Layout **может** менять slot placement только в понятных случаях: + +- добавился новый участник +- удалился участник +- пользователь вручную перетащил участника +- owner slot реально вырос по footprint настолько, что текущий ring больше не может его честно вместить +- пользователь явно нажал reset layout + +Во всех остальных случаях layout placement меняться не должен. + +Во всех остальных случаях layout **не должен** перескакивать. + +## 6. Когда layout не должен двигаться + +Layout не должен менять slot placement из-за: + +- zoom +- pan +- нового message/comment +- смены unread count +- появления или исчезновения process rail, если footprint band остаётся в пределах резерва +- rename, если `agentId` тот же +- изменений данных внутри уже существующих visible rows, если slot footprint не меняется + +## Негативные решения - что сознательно не делаем + +Эти варианты сознательно отклонены и не должны "случайно вернуться" в код под видом быстрых фиксов. + +- Не продолжаем лечить owner overlap screen-space packer-ом как основным механизмом +- Не сохраняем raw `x/y` для owner layout +- Не возвращаем owner placement под свободный d3-force +- Не вводим local scroll внутри slot как первую механику +- Не скрываем kanban columns ради fit +- Не оставляем process floating отдельно от owner +- Не делаем агрессивный auto-compact всех owners при каждом member remove + +## Канонические термины + +Чтобы не путаться в названиях при реализации: + +- `owner` - `lead` или `member`, вокруг которого строится локальная зона +- `stableOwnerId` - стабильный id участника, построенный из `agentId` или fallback на `name` +- `slot` - общий разговорный термин для layout-reserved зоны, но в коде лучше не использовать его без уточнения +- `member sector slot` - обычный slot участника, который реально планируется ring/sector planner-ом +- `special slot` - специальная зарезервированная зона, не живущая в member assignments, например `unassigned task slot` +- `central reserved block` - специальная центральная зона lead-а +- `sector` - одно из 6 направлений вокруг lead +- `ring` - круг уровнем дальше от lead +- `slot assignment` - привязка **member sector slot** к `(ringIndex, sectorIndex)` +- `slot frame` - прямоугольник **member sector slot** в world coordinates, если отдельно не сказано иное +- `central exclusion` - запрещённая зона вокруг lead, учитывающая lead, launch HUD и lead activity +- `task band` - нижняя часть slot, где живёт kanban +- `activity band` - верхняя часть slot +- `process band` - узкая зона между owner и task band + +### Runtime central exclusion + +Чтобы не было разной трактовки в коде, под итоговым `runtime central exclusion` в этом документе понимается: + +```ts +runtimeCentralExclusion = + union( + leadCentralReservedBlock, + optionalUnassignedTaskSlot + ) + centralSafetyPadding +``` + +То есть нижний `unassigned task slot`, если он существует, становится частью итоговой центральной запрещённой зоны для member slot planner-а. + +## Целевая визуальная схема + +```text + [ Activity x3 ] + [ Member A ] + [ Process ] + [ Tasks x5 ] + + + [ Activity x3 ] [ Activity x3 ] + [ Member B ] [ Lead ] [ Member C ] + [ Process ] [ Launch HUD ] [ Process ] + [ Tasks x5 ] [ Tasks x5 ] + + + [ Activity x3 ] + [ Member D ] + [ Process ] + [ Tasks x5 ] +``` + +Если owner slots не помещаются в ring 1 честно, следующий owner уходит в ring 2, а не пытается "додавиться" между соседями. + +## Точная структура slot + +Каждый member slot строится по одинаковой схеме. + +### Политика резервации места + +Slot резервирует геометрию под все свои bands **всегда**, даже если в конкретный момент band визуально пустой. + +Это значит: + +- пустой `Activity` band может не рисовать карточки, но его высота зарезервирована +- пустой `Process` band может не рисовать rail, но его высота зарезервирована +- пустой `Task` band может не рисовать tasks, но его высота зарезервирована + +Это осознанный tradeoff ради того, чтобы slot не прыгал по высоте при каждом изменении данных. + +### Slot local anatomy + +1. `Activity band` +2. `Owner band` +3. `Process band` +4. `Task band` + +### Activity band + +- расположен сверху slot +- имеет фиксированную высоту под: + - `3 activity items` + - `+N more footer` +- footer height резервируется всегда, чтобы не было layout jump +- connector к owner рисуется короткий и локальный +- если activity entries сейчас нет, band остаётся пустым визуально, но не схлопывается геометрически + +### Owner band + +- содержит сам `member node` +- owner node является точкой привязки локального layout +- label / role / runtime status остаются на owner node + +### Process band + +- живёт между owner и tasks +- имеет фиксированную высоту +- резервируется всегда, даже если сейчас process не отображается +- это сознательный tradeoff ради стабильности + +### Task band + +- расположен внизу slot +- имеет фиксированную высоту `5 rows` +- содержит все активные non-empty kanban columns +- каждая колонка рендерится внутри общей высоты band +- если task-ов сейчас нет, band остаётся пустым визуально, но не схлопывается геометрически + +## Поведение task overflow + +Так как мы сохраняем multi-column kanban, overflow нужно трактовать строго. + +### Правило + +Для каждой колонки отдельно: + +- если задач `<= 5`, показываются реальные tasks +- если задач `> 5`, показывается: + - `4` реальных tasks + - `1` overflow stack в последнем row + +### Что это даёт + +- высота slot стабильна +- все columns видимы +- overflow честно обозначен +- не нужен scroll + +### Порядок задач внутри колонки + +Этот refactor **не меняет** текущую каноническую задачу порядка внутри колонки. + +Значит: + +- если сейчас в колонке есть уже существующий deterministic order, его сохраняем +- stable slot layout не должен попутно вводить новый sort по времени, title или id +- overflow stack строится поверх уже существующего column order + +## Поведение activity overflow + +Для activity lane: + +- показываем `3` реальных items +- потом показываем `+N more` +- `+N more` открывает профиль участника на вкладке `Activity` + +Внутри профиля участника уже должны продолжать работать: + +- `All` +- `Messages` +- `Comments` + +Это не часть новой layout-логики, но поведение нельзя потерять. + +## Activity item semantics + +### Messages + +- переиспользуют существующий compact message widget +- клики и hover-поведение должны остаться совместимыми с уже существующим UI +- reuse boundary здесь - существующий visual/component behavior, а не обязательная привязка к текущему screen-space positioning path + +### Comments + +- в visual target показывают `task display id` +- не маскируются под message между двумя участниками +- должны явно читаться как `comment on task` + +### Ordering + +- в slot: newest first +- в полном activity tab: текущий существующий порядок не должен деградировать + +### Tie-break для activity ordering + +Чтобы `newest first` не реализовали по-разному в двух местах, фиксируем: + +1. сначала сортируем по `timestamp desc` +2. если timestamps совпали, используем стабильный secondary key: + - existing source order, если он уже есть в данных + - иначе `id` + +Это нужно, чтобы activity lane не дрожала при одинаковых timestamps и повторных rebuild-ах. + +## Process semantics + +Для `v1` process rail показывает **один самый релевантный process**: + +1. сначала running process +2. если running нет, последний недавно завершённый visible process +3. если релевантного process нет, band остаётся пустым, но место под него остаётся зарезервированным + +## Lead-specific geometry + +Lead обрабатывается отдельно. + +### Lead central exclusion включает + +- сам `lead node` +- `launch HUD` +- `lead activity band` +- минимальный safety padding вокруг этого блока + +`launch HUD` reserved zone должна сохраняться анти-jump образом, даже если compact HUD сейчас скрыт или dismissed. Для planner-а это persistent часть central exclusion. + +### Важный инвариант + +Ни один member slot не должен строиться так, будто этих central zones не существует. + +Иначе первый ring снова залезет в центр. + +### Recommended central block anatomy + +Чтобы `lead central reserved block` не трактовали по-разному в разных местах, фиксируем рекомендованную структуру: + +1. `lead activity frame` слева +2. `lead core frame` по центру +3. `launch HUD frame` справа + +Где: + +- `lead activity frame` использует те же bounded rules, что и member activity lane +- `lead core frame` содержит сам `lead node` и минимальную label/status area +- `launch HUD frame` резервируется даже если compact HUD сейчас hidden/dismissed + +Итоговый `leadCentralReservedBlock` считается как union этих трёх частей плюс обязательные внутренние gap-ы и внешний safety padding. + +### Что важно для v1 + +- `lead activity frame` не схлопывается только потому, что сейчас у lead мало activity +- `launch HUD frame` не схлопывается только потому, что compact HUD сейчас не показан +- member planner не знает про внутренние части central block-а по отдельности, он видит уже собранный итоговый `leadCentralReservedBlock` + +Это нужно, чтобы центр оставался стабильным и не пересобирался от мелких UI-состояний. + +## Stable identity - обязательная часть + +Это самая критичная инфраструктурная часть плана. + +Без неё slot assignment будет хрупким и будет ломаться при rename и refresh. + +### Что меняем + +`ResolvedTeamMember` должен получить: + +```ts +agentId?: string; +``` + +### Правило stable owner id + +```ts +stableOwnerId = member.agentId ?? member.name +``` + +### Правило node id + +Member node id в графе должен строиться на `stableOwnerId`, а не на display name. + +То есть вместо старой name-based схемы нужен stable id path: + +```ts +memberNodeId = `member:${teamName}:${stableOwnerId}` +``` + +Display label при этом остаётся `member.name`. + +### Где stableOwnerId обязателен + +- member node id +- task.ownerId reference +- process ownership +- activity ownership +- slot assignment storage +- drag/snap +- ordering + +### Где name можно оставить только как display field + +- label на node +- human-readable popover title +- callbacks, где UI дальше открывает профиль по имени + +## Visibility / removed member policy + +Чтобы новый layout не начал внезапно показывать другой состав owners, фиксируем: + +- этот refactor не переопределяет сам по себе graph visibility policy для removed members +- если removed member сейчас не должен быть owner node в графе, он не получает slot +- если задача ссылается на owner, которого нет в текущем visible owner set, такая задача должна попадать в `unassigned task slot`, а не ломать planner + +### Важное уточнение + +`visible owner set` для planner-а строится из owner/node visibility policy, а не из presentation filters вида: + +- `showTasks` +- `showProcesses` +- `showEdges` + +То есть filters не должны внезапно менять состав member owners, которых planner пытается раскладывать. + +## Stable ordering - обязательное правило + +Initial placement должен быть детерминированным. + +Фиксируем такой порядок: + +1. Сначала уже сохранённые `slot assignments` +2. Затем `teamData.config.members[]`, сматченные по `stableOwnerId` +3. Затем owners, которых нет в config order +4. Final tie-break - `stableOwnerId` + +Это важно, чтобы: + +- layout не дёргался при refresh +- порядок был связан с конфигом команды +- ordering не ломался при rename + +## Owner set resolution - before planner + +Чтобы planner не собирал layout actors "по пути" из разных источников, фиксируем один этап до планирования. + +### До запуска planner-а должен быть построен + +- `lead central reserved block` +- ordered visible member owners +- условный `unassigned task slot`, если он нужен + +### Чего там быть не должно + +- tasks как самостоятельных owners +- process nodes как самостоятельных owners +- activity items как самостоятельных owners +- зависимостей от hover, selection, unread highlight или camera state + +То есть сначала строим **layout actors set**, и только потом планируем геометрию. + +## Source of truth by concern + +Чтобы в коде не появилось несколько "почти главных" источников истины, фиксируем это явно. + +### Identity + +- source of truth: `stableOwnerId = agentId ?? member.name` + +### Owner placement + +- source of truth для **member sector slots**: `slotAssignmentsByTeam[teamName][stableOwnerId]` +- `lead central reserved block` не хранится в `slotAssignmentsByTeam` +- `unassigned task slot` не хранится в `slotAssignmentsByTeam` и строится derivation-логикой от текущего dataset + +### Geometry + +- source of truth: planner helpers, которые из assignments и footprints строят `SlotFrame` + +### Active render geometry + +- source of truth: последний **валидный и текущий** `StableSlotLayoutSnapshot`, собранный из текущих inputs +- invalid candidate snapshot не становится active render geometry +- stale snapshot cache не должен подменять current render geometry, если для текущего pass активирован fallback path + +### Activity data + +- source of truth: существующий messages/comments data path +- activity lane не строится из particles и не строится из transient overlay state + +### Task ordering + +- source of truth: текущая каноническая kanban/column order semantics +- этот refactor её не переизобретает + +### Process selection + +- source of truth: текущий process data path +- новый layout меняет positioning, а не выдумывает новый параллельный источник process state + +## Runtime precedence matrix + +Чтобы во время интеграции не появилось несколько конкурирующих "почти верных" состояний, фиксируем runtime precedence явно. + +### 1. Placement identity precedence + +Для `member sector slots` порядок такой: + +1. `slotAssignmentsByTeam[teamName][stableOwnerId]` +2. planner default placement для owner без assignment + +Больше никаких placement identity sources у `v1` нет. + +### 2. Geometry build precedence + +Для текущего render-pass порядок такой: + +1. текущие inputs +2. current assignments +3. planner builds candidate snapshot +4. validator либо подтверждает snapshot, либо отклоняет его + +То есть geometry нельзя брать: + +- из старого DOM layout +- из raw pinning +- из screen-space overlay коррекций + +### 3. Active render precedence + +Для renderer порядок такой: + +1. если есть валидный current snapshot для этого pass - рендерим его +2. если current snapshot invalid - не делаем его active render geometry +3. если текущий path в `no-lead fallback`, рендерим fallback presentation path +4. session-local last valid snapshot cache не становится active render geometry автоматически + +### 4. Persistence precedence + +Для store/state: + +1. валидный committed assignment update пишет `slotAssignmentsByTeam` +2. invalid candidate geometry ничего не пишет в assignments +3. snapshot cache не пишет assignments сам по себе + +### Ключевая мысль + +`slotAssignmentsByTeam` отвечает за identity placement, `StableSlotLayoutSnapshot` отвечает за валидную текущую geometry-картину, fallback path отвечает за безопасный transient rendering, и эти роли нельзя смешивать. + +## Где хранить slot assignment + +Источник истины для slot placement должен жить **в renderer-side team UI state**, а не в graph package. + +Graph package должен получать уже готовые assignments / placement inputs. + +Нормативная структура `v1`: + +```ts +type OwnerSlotAssignment = { + ownerStableId: string; + ringIndex: number; + sectorIndex: number; +}; + +type TeamSlotAssignments = Record; +type TeamSlotAssignmentsByTeam = Record; +``` + +Где key верхнего уровня - `teamName`. + +### Почему верхний key именно `teamName` в `v1` + +Текущий team data / IPC / graph integration path в проекте в основном уже team-scoped через `teamName`. + +Поэтому для `v1` фиксируем: + +- layout state scoped по `teamName` +- team switch просто переключает текущий scoped layout state +- assignments одной команды не должны протекать в другую + +Если в будущем появится first-class stable `teamId`, это может стать отдельным улучшением, но не является обязательной частью этого refactor. + +### Что это значит для team rename + +Так как в `v1` верхний key именно `teamName`, фиксируем явный tradeoff: + +- если меняется именно storage identity команды, то layout state для неё считается новым scoped state +- автоматическая миграция layout state между старым и новым `teamName` в этот refactor не входит + +Важно не путать это с: + +- rename участника при том же `agentId` +- display-only label change, которая не меняет team storage key + +### Что именно хранится здесь + +Только assignments для **draggable member sector slots**. + +Здесь **не** храним: + +- `lead central reserved block` +- `launch HUD` +- `lead activity` +- `unassigned task slot` + +Они должны строиться derivation-логикой, а не жить как псевдо-persisted assignments. + +### Что важно + +- Не хранить здесь raw `x/y` +- Не смешивать это с existing pinning model +- Если в коде уже есть pinned positions, для owner layout их нужно: + - либо мигрировать в ближайший slot один раз + - либо игнорировать для lead/member и постепенно удалить owner-specific path + +## Snapshot lifecycle and precedence + +Это отдельный обязательный contract, чтобы renderer не оказался между "старой" и "новой" геометрией. + +### Normal path + +Если inputs валидны и planner собрал валидный snapshot: + +1. собираем новый `StableSlotLayoutSnapshot` +2. валидируем его +3. делаем его active render geometry +4. обновляем session-local last valid snapshot cache + +### Validation failure path + +Если inputs есть, но candidate snapshot невалиден: + +1. candidate snapshot не коммитится +2. active render geometry не обновляется этим candidate snapshot +3. `slotAssignmentsByTeam` не перезаписывается частично +4. session-local last valid snapshot cache остаётся прежним +5. пишется diagnostic warning + +### No-lead path + +Если текущий dataset не содержит `lead`: + +1. stable-slot planner path не строит новый snapshot +2. active render geometry для stable-slot path не обновляется +3. session-local last valid snapshot cache не очищается автоматически +4. renderer для этого pass использует fallback presentation path, а не stale stable-slot snapshot как активную картинку + +### Главное различие + +- last valid snapshot cache нужен для safety и continuity логики +- active render geometry должна соответствовать текущему валидному render path + +Нельзя подменять второе первым. + +### Persistence scope для v1 + +Для первой реализации достаточно renderer-side persistence в team UI state. + +В этом pass **не нужно**: + +- писать slot assignments в team config +- тащить их через main process +- делать cross-session durable migration как обязательную часть layout refactor + +Сначала нужен стабильный рабочий layout внутри текущего renderer state path. + +### Legacy pin migration policy + +Чтобы не оставлять это на усмотрение implementer-а, фиксируем стартовую политику: + +- для `lead/member` legacy raw pinned positions в новом режиме **не являются** источником истины +- если такие данные существуют, на первом входе в `stable-slots-v1` их нужно один раз: + - сматчить в ближайший валидный slot assignment + - после этого дальше жить уже только через slot assignment + +Не нужно бесконечно поддерживать два параллельных источника истины: + +- raw owner pinning +- slot assignment + +### Migration from fallback name to agentId + +Это отдельный переходный случай, который нельзя оставлять "как-нибудь само". + +Сценарий: + +- раньше member жил на fallback key `member.name` +- позже для него стал доступен `agentId` + +Для `v1` фиксируем такое правило: + +- если у текущего visible member появился `agentId` +- и assignment под новым `stableOwnerId` ещё не существует +- но есть старый assignment под его прежним fallback `member.name` + +то этот assignment нужно **один раз** перенести на новый `stableOwnerId` до запуска planner-а. + +Это нужно, чтобы член команды не "терял место" только потому, что identity стала более качественной. + +### Migration precedence - чтобы не было двойной трактовки + +Порядок для `v1` фиксируем такой: + +1. если `slotLayoutVersion` не совпадает - старые member assignments сбрасываем целиком +2. если version совпадает и assignment уже есть под новым `stableOwnerId` - используем его как source of truth +3. если assignment под новым `stableOwnerId` ещё нет, но есть старый assignment под fallback `member.name` - переносим его один раз +4. если существуют и старый fallback assignment, и новый stable assignment одновременно - побеждает новый stable assignment, fallback alias удаляется + +Это правило нужно, чтобы миграция не создавала две конкурирующие записи для одного и того же member-а. + +## Slot frame model + +Для planner-а нужен явный rectangular model. + +Нормативная внутренняя структура `v1`: + +```ts +type SlotFrame = { + ownerStableId: string; + ringIndex: number; + sectorIndex: number; + x: number; + y: number; + width: number; + height: number; +}; +``` + +`x/y` здесь - top-left slot frame в world coordinates. + +### Важное уточнение + +`SlotFrame` в этом документе означает именно frame для **member sector slot**. + +Для special geometry используем отдельные понятия: + +- `lead central reserved block` +- `UnassignedTaskSlotFrame` + +Они могут быть AABB-похожими по форме, но не должны участвовать как обычные member slot assignments. + +### Почему прямоугольник, а не только radius + +Потому что пользовательская проблема именно в прямоугольных зонах: + +- activity cards +- process rail +- task columns + +Значит planner должен работать не только с радиусом, а с **реальными AABB bounds**. + +## World-space geometry contract + +Это один из самых критичных пунктов плана, потому что прошлые баги были именно из-за смешивания world-space и screen-space. + +### Обязательное правило + +- planner, slot frames и local anchors живут в `world coordinates` +- camera zoom/pan применяется только как визуальный transform поверх уже готовой world geometry +- `GraphActivityHud` и похожие UI-слои не должны повторно "спасать" layout через screen-space repositioning + +### Что запрещено + +- post-render screen-space packing соседних owners +- DOM-based reflow logic, которая двигает slot zones независимо от planner-а +- вычисление layout из текущего zoom level + +Если какой-то элемент выглядит налезающим, исправлять нужно planner / footprint / slot bounds, а не screen-space коррекцией. + +## Owner anchor inside slot + +Чтобы implementer не трактовал slot как угодно, фиксируем owner anchor rule. + +### Правило + +- `(ringIndex, sectorIndex)` сначала определяют `ownerAnchor` на соответствующем sector ray +- `slot frame` задаёт outer bounds всей owner-local зоны +- `slot frame` строится вокруг `ownerAnchor`, а не выбирается произвольно постфактум +- сам `member node` располагается по горизонтальному центру slot +- по вертикали `member node` располагается в `owner band`, между `activity band` и `process band` +- `activity band` всегда выше owner node +- `task band` всегда ниже owner node + +### Каноническая формула для `SlotFrame` + +Чтобы не было двух разных трактовок top-left координаты, фиксируем canonical build rule: + +```ts +slotFrame.x = ownerAnchor.x - slotWidth / 2 +slotFrame.y = ownerAnchor.y - (activityBandHeight + ownerBandHeight / 2) +slotFrame.width = slotWidth +slotFrame.height = slotHeight +``` + +То есть: + +- `ownerAnchor.x` всегда совпадает с horizontal centerline slot-а +- `ownerAnchor.y` всегда совпадает с вертикальным центром `owner band` +- верхняя граница slot-а определяется от activity band, а не "как получится" + +Это делает geometry детерминированной и одинаковой для planner-а, hit testing и renderer-а. + +### Канонические локальные origins + +Локальные точки считаем только от `slotFrame`, а не от DOM/layout side effects: + +```ts +activityOrigin = { + x: slotFrame.x, + y: slotFrame.y, +} + +ownerBandOrigin = { + x: slotFrame.x, + y: slotFrame.y + activityBandHeight, +} + +processOrigin = { + x: slotFrame.x, + y: slotFrame.y + activityBandHeight + ownerBandHeight, +} + +taskOrigin = { + x: slotFrame.x, + y: slotFrame.y + activityBandHeight + ownerBandHeight + processBandHeight, +} +``` + +Если какой-то renderer-pathу нужен другой anchor, он должен вычисляться как производный от этих канонических origins, а не как отдельная независимая правда. + +### Практически + +Нужен один helper уровня domain/layout, который из `SlotFrame` возвращает локальные anchor points: + +- `ownerAnchor` +- `activityOrigin` +- `processOrigin` +- `taskOrigin` + +UI и canvas не должны заново высчитывать эти точки каждый по-своему. + +## Owner footprint contract + +`OwnerFootprint` должен считаться детерминированно из layout rules и данных, а не из уже отрендеренного DOM. + +### В `OwnerFootprint` входят + +- итоговый `slotWidth` +- итоговый `slotHeight` +- bucket `S / M / L` +- optional flags, влияющие на layout validity: + - есть ли `activity items` + - есть ли релевантный `process` + +### Важно + +- `OwnerFootprint` считается до рендера +- он должен быть pure и testable +- разные React paths не должны считать footprint по-разному + +## Slot orientation rule + +Чтобы не возникло двух разных реализаций "stable sectors", фиксируем это явно: + +- slot contents **не ротируются** по углу сектора +- text и UI внутри slot всегда остаются upright +- `Activity / Member / Process / Tasks` всегда идут в одном и том же вертикальном порядке сверху вниз + +То есть sector влияет на положение slot вокруг lead, но не на внутреннюю ориентацию карточек и текста. + +## Horizontal alignment inside slot + +Чтобы band-ы не выравнивались хаотично по-разному, фиксируем правило: + +- у slot есть общий горизонтальный centerline +- `member node` центрируется по этой линии +- `activity band` центрируется по этой линии +- `process rail` центрируется по этой линии +- `task band` центрируется по этой линии + +Если у band своя фактическая ширина меньше `slotWidth`, он не липнет к левому краю slot, а центрируется внутри slot frame. + +## Slot width rules + +`slotWidth` считается честно, от контента. + +### Формула + +```ts +slotWidth = max( + activityWidth, + ownerMinWidth, + processRailWidth, + kanbanWidth +) +``` + +### Где + +- `activityWidth` - ширина compact activity lane +- `ownerMinWidth` - минимальная ширина под owner node + label area +- `processRailWidth` - ширина attached process rail +- `kanbanWidth` - суммарная ширина всех активных columns с gutter-ами + +### Важно + +- bucket не определяет итоговую ширину +- bucket только классифицирует уже посчитанную ширину + +### Что влияет на slot width + +- число `active non-empty` kanban columns +- фиксированная ширина compact activity lane +- фиксированная ширина process rail +- минимальная owner label area + +### Что не должно влиять на slot width + +- случайная длина message preview текста +- случайная длина task subject, если pill уже имеет фиксированную ширину и truncation +- unread badge count +- hover / selection state + +Иными словами: slot width должен расти из-за реальной структурной ширины owner-local зон, а не из-за случайного текстового контента. + +## Slot height rules + +`slotHeight` bounded и предсказуем. + +### Формула + +```ts +slotHeight = + activityBandHeight + + ownerBandHeight + + processBandHeight + + taskBandHeight + + verticalGaps +``` + +### Важно + +- activity band height фиксирован +- process band height фиксирован +- task band height фиксирован (`5 rows`) +- значит рост задач меняет в первую очередь `slotWidth`, а не `slotHeight` + +Это ключевой выбор ради стабильности. + +## Width buckets - как трактовать правильно + +`S / M / L` нужны planner-у как packing heuristic, но не как UI limit. + +### Правильное правило + +1. Сначала считаем **реальный** `slotWidth` +2. Потом присваиваем bucket +3. Потом planner использует bucket и точный width + +### Что не делаем + +- не считаем bucket по display name +- не считаем bucket только по числу задач +- не используем bucket как замену реальной ширины + +### Стартовая bucket policy для v1 + +Чтобы первая реализация не разошлась в трактовках, фиксируем стартовое правило: + +- `S` - `1` active non-empty kanban column +- `M` - `2-3` active non-empty kanban columns +- `L` - `4+` active non-empty kanban columns + +Если `activityWidth` или `processRailWidth` делает slot шире ожидаемого bucket-а, planner всё равно должен опираться на **реальный `slotWidth`**, а bucket использовать только как coarse hint. + +## Геометрические defaults для v1 + +Чтобы implementer не подбирал layout "на глаз" каждый по-своему, фиксируем стартовые значения: + +- `slotVerticalGap = 24` +- `slotHorizontalGap = 32` +- `ringGap = 140` +- `centralSafetyPadding = 48` +- `memberSlotInnerPadding = 16` + +Это именно стартовые defaults. Их можно тюнить, но они должны жить в одном месте и изменяться осознанно, а не расползаться по коду. + +### Source of geometry constants + +Все такие значения должны жить в одном domain-level constants module для stable-slot layout. + +Нельзя: + +- дублировать их отдельно в planner +- отдельно в renderer +- отдельно в hit testing +- отдельно в fit helpers + +Иначе визуально одинаковый slot начнёт иметь разные размеры в разных частях системы. + +## Ring radius rule + +Радиус следующего ring нельзя считать "по ощущению". + +Для `v1` фиксируем стартовое правило: + +```ts +nextRingRadius = + previousRingRadius + + maxSlotDepthOnPreviousRing + + ringGap +``` + +Где `maxSlotDepthOnPreviousRing` - максимальный размер slot по радиальному направлению среди owners этого ring. + +## Ring planner - строгая логика + +Planner должен быть детерминированным и минимально разрушительным. + +### Что именно planner планирует + +Этот planner планирует только **member sector slots**. + +Он не должен пытаться раскладывать: + +- `lead central reserved block` +- `launch HUD` +- `lead activity` +- `unassigned task slot` + +Эти части должны быть построены заранее как fixed/special geometry, влияющая на exclusion и bounds. + +### Inputs + +- central exclusion bounds +- ordered visible member owners +- saved slot assignments +- slot footprints +- ring / sector constants + +### Output + +- `SlotFrame[]` для member sector slots + +### Базовый алгоритм + +```ts +for owner in orderedOwners: + if savedAssignment(owner) still valid: + keep it + reserve frame + continue + + place owner into first valid candidate: + by preferred ring + then by preferred sector + then by next available ring/sector + where candidate frame: + does not intersect central exclusion + does not intersect occupied slot frames + respects min gap +``` + +### Правило минимального разрушения + +Если owner уже был привязан к сектору, planner должен сначала попытаться: + +1. оставить тот же `sectorIndex` +2. если не помещается - оставить тот же сектор, но увести owner на внешний ring +3. только потом искать новый сектор + +Это даёт более стабильное поведение, чем немедленный полный reshuffle по всем секторам. + +### Порядок выбора candidate slots + +Чтобы planner не получился "похожим, но разным" в двух местах кода, фиксируем порядок выбора явно. + +#### Для owner с уже существующим assignment + +1. тот же `(ringIndex, sectorIndex)`, если он всё ещё valid +2. тот же `sectorIndex` на следующем внешнем ring +3. ближайшие соседние sectors на том же ring +4. ближайшие соседние sectors на внешних rings + +#### Для нового owner без assignment + +1. ring 1, sectors в фиксированном canonical order +2. если ничего не влезло - ring 2, тот же canonical order +3. и так далее + +#### Canonical sector order для planner-а + +Используем тот же порядок, что уже зафиксирован выше: + +1. `top` +2. `upper-right` +3. `lower-right` +4. `bottom` +5. `lower-left` +6. `upper-left` + +Это правило важно, чтобы initial placement, replanning и drag-target selection не расходились между собой. + +### Что значит nearest valid slot при drag + +Чтобы drag/snap не реализовали двумя разными способами, фиксируем: + +- nearest candidate считается по расстоянию от текущего dragged `ownerAnchor` до candidate `ownerAnchor` +- metric - обычное Euclidean distance в world coordinates +- если расстояние одинаковое, tie-break идёт по canonical sector order + +Это даёт детерминированное поведение и не зависит от текущего zoom/pan. + +### Что значит "still valid" + +Saved assignment считается valid, если: + +- slot frame для него можно построить +- frame не пересекает central exclusion +- frame не пересекает уже занятые slot frames +- current owner footprint всё ещё помещается + +Если assignment invalid, owner перепланируется, но **не весь layout с нуля**, а только конфликтующие части. + +## Reflow policy - когда planner имеет право перестраивать схему + +Чтобы не было хаотических reshuffle, вводим чёткое правило. + +### Layout нельзя перестраивать полностью из-за + +- новых activity items +- комментариев +- изменения unread badges +- process content update без роста footprint +- zoom / pan +- rename при том же `agentId` + +### Layout может перестраивать часть owners из-за + +- появления нового owner +- удаления owner +- drag/swap +- роста slot footprint, который делает current assignment invalid +- появления `unassigned task slot`, если он расширил lower central exclusion и создал реальный конфликт +- исчезновения `unassigned task slot`, только если пользователь явно сделал `reset layout` + +### Предпочтительный принцип + +`keep existing placements whenever still valid` + +Это обязательный инвариант для стабильности. + +## Post-plan validation and fail-closed behavior + +Даже хороший planner иногда можно сломать интеграцией. Поэтому после каждого planner run нужна отдельная deterministic validation pass. + +### Нужен отдельный pure helper + +Рекомендуемая форма: + +```ts +validateStableSlotLayout({ + slotFrames, + runtimeCentralExclusion, + ownerFootprints, + assignments, +}) +``` + +### Validator обязан проверять + +- все `SlotFrame` конечные и не содержат `NaN/Infinity` +- нет двух owners с одним и тем же assignment +- ни один `member sector slot` не пересекает `runtimeCentralExclusion` +- `member sector slots` не пересекаются между собой +- полный frame slot-а включает `Activity`, `Owner`, `Process` и `Task` bands +- локальные anchors лежат внутри своего `SlotFrame` +- итоговые fit bounds конечные и ненулевые + +### Fail-closed правило + +Если validation не прошла: + +- не рендерим "полусломанный" новый layout как будто он валиден +- оставляем предыдущий последний валидный layout snapshot для этой команды, если он есть +- если валидного snapshot нет, используем безопасный fallback без persistent overwrite +- пишем diagnostic warning в лог, чтобы баг можно было воспроизвести + +### Что нельзя делать при validation failure + +- silently чинить layout screen-space коррекцией +- частично коммитить невалидные assignments в store +- двигать только Activity/Process/Tasks отдельно от slot frame, пытаясь "спасти картинку" + +Это критично: broken layout должен ломаться явно и безопасно, а не превращаться в новый источник хаоса. + +## Conflict resolution order - when one slot stops fitting + +Это один из самых важных практических пунктов. Именно здесь чаще всего implementer случайно делает hidden global reshuffle. + +### Если один owner стал невалиден из-за роста footprint + +Planner должен идти по такому порядку: + +1. сохранить все unaffected owners на месте +2. попробовать оставить problem owner в том же `sectorIndex`, но увести на следующий outer ring +3. если этого недостаточно, попробовать минимальный локальный spill конфликтующего подмножества owners +4. не делать полный global reshuffle, если пользователь явно не вызвал `reset layout` + +### Что считается preferred behavior + +- cheapest valid local fix wins +- количество затронутых owners должно быть минимальным +- owner, который инициировал конфликт ростом footprint, должен двигаться первым, если это решает проблему + +Это правило важно, чтобы граф оставался предсказуемым и не "переезжал весь" из-за одной widened kanban zone. + +## Canonical layout build pipeline + +Чтобы новый path не был реализован в разных местах по-разному, фиксируем канонический порядок сборки layout. + +### Шаги + +1. Построить visible graph dataset +2. Разрешить `stableOwnerId` для members +3. Построить `lead central reserved block` +4. Если есть unassigned tasks - построить `unassigned task slot` +5. Из пунктов `3-4` собрать итоговый `central exclusion` +6. Построить ordered visible member owners +7. Для каждого member owner посчитать `OwnerFootprint` +8. Запустить member slot planner +9. Получить `SlotFrame[]` для member slots +10. Из `SlotFrame` построить local anchors: + - `ownerAnchor` + - `activityOrigin` + - `processOrigin` + - `taskOrigin` +11. Собрать цельный `StableSlotLayoutSnapshot` +12. Прогнать validation на полном snapshot +13. Только после этого передать world-space geometry в renderer / graph package +14. Только после этого применять camera zoom/pan + +### Что нельзя делать + +- сначала отрендерить Activity/Tasks, а потом ими "уточнить" layout +- сначала запустить screen-space pack, а потом пытаться сохранить это как source of truth +- считать planner output неполным и дозаполнять его DOM-side reposition логикой + +## Atomic layout transaction rule + +Это важный anti-bug contract. Layout update должен коммититься как одна транзакция, а не серией мелких частичных записей. + +### Правило + +Один graph update делает только такой путь: + +1. derive inputs +2. build full snapshot +3. validate snapshot +4. commit whole snapshot +5. render from committed snapshot + +### Что запрещено + +- сначала записать новые assignments, а `SlotFrame` пересчитать позже +- сначала обновить `memberSlotFrames`, а `fitBounds` и `runtimeCentralExclusion` дотянуть в следующем tick +- отдельно коммитить `Activity` geometry и отдельно `Task` geometry +- держать в renderer одновременно старые `fitBounds` и новые `SlotFrame` + +### Почему это обязательно + +Большая часть "странных" overlap и jump-багов рождается не из формулы planner-а, а из того, что разные части UI в течение одного render-cycle смотрят на разные поколения layout state. + +Новый path должен быть transaction-like: либо весь snapshot валиден и коммитнут, либо остаётся предыдущий валидный snapshot. + +## Debug / observability requirements + +Этот refactor слишком геометрический, чтобы оставлять отладку на `console.log` по месту. + +### Минимум, который нужен в `v1` + +- dev-only возможность вывести для owner: + - `stableOwnerId` + - `ringIndex` + - `sectorIndex` + - `slotWidth` + - `slotHeight` + - bucket `S/M/L` +- dev-only warning при validation failure с причиной +- dev-only возможность понять, какой owner был локально перепланирован и почему + +### Чего не нужно делать + +- тащить это в публичный product UI +- делать отдельный user-facing debug mode + +Это purely implementation aid, но он сильно снижает риск, что спорные overlap-cases будут разбираться "на глаз". + +## View modes - tab and fullscreen + +Новый layout не должен иметь две разные правды для разных способов открытия графа. + +### Правило + +- graph tab и fullscreen overlay используют один и тот же `slotAssignmentsByTeam` +- graph tab и fullscreen overlay используют один и тот же `slotLayoutVersion` +- открытие fullscreen не должно заново seed-ить owner placement +- drag в одном режиме должен сразу отражаться в другом режиме +- camera state при этом может быть разным и не обязан шариться между режимами + +Иными словами: разные view modes - это разные камеры и контейнеры, но не разные layout models. + +## Team switch behavior + +Layout state должен быть жёстко team-scoped. + +### Правило + +- переключение на другую команду читает только её `slotAssignmentsByTeam[teamName]` +- возврат назад использует ранее сохранённый scoped layout этой команды +- assignments и camera state одной команды не должны случайно применяться к другой +- если одна и та же команда открыта в нескольких pane-ах, layout state у неё общий +- при конкурентном обновлении layout state для одной команды действует обычное shared-state правило `last write wins` + +Это особенно важно для случаев, когда несколько graph tabs / panes открыты параллельно. + +## Hidden member -> reappear behavior + +Это полезно зафиксировать отдельно, чтобы реализация не делала лишний churn layout-а. + +### Правило + +- если member временно исчез из `visible owner set`, его slot не участвует в текущем planner run +- при этом его сохранённый member assignment может оставаться в `slotAssignmentsByTeam` +- если тот же member потом возвращается с тем же `stableOwnerId`, planner сначала пытается переиспользовать прежний assignment +- если прежний assignment больше не валиден, только тогда делается локальный replanning + +Это даёт более стабильное поведение, чем каждый раз забывать slot при любом временном исчезновении owner-а. + +## Graph filters - влияние на layout + +Это место обязательно нужно зафиксировать, иначе после рефактора легко вернуть layout jumps через UI toggles. + +### Для `v1` правило такое + +- `showTasks` +- `showProcesses` +- `showEdges` + +не являются входом для planner-а и не должны менять slot assignments. + +### Что они делают + +- `showTasks = false` скрывает task rendering, но не перестраивает member slots и не уничтожает reserved task band geometry +- `showProcesses = false` скрывает process rail rendering, но не перестраивает member slots и не убирает reserved process band geometry +- `showEdges = false` влияет только на edges + +### Отдельно про `unassigned task slot` + +Так как это special slot, состоящий только из task band-а, фиксируем отдельно: + +- если в dataset есть unassigned tasks, сам `unassigned task slot` продолжает учитываться в topology +- `showTasks = false` может скрыть его presentation, но не должен убирать его reserved topology footprint +- исчезновение `unassigned task slot` как layout actor происходит только когда unassigned tasks реально исчезли из dataset, а не из-за UI filter toggle + +Это осознанный tradeoff ради стабильности. Filters в `v1` скрывают presentation, а не перестраивают топологию layout. + +## No-lead fallback + +Stable sector planner в этом плане опирается на наличие `lead`. + +### Для `v1` фиксируем точное поведение + +- если в текущем visible dataset временно нет `lead` +- новый stable-slot planner не должен пытаться строить сектора "вокруг пустоты" +- новый `StableSlotLayoutSnapshot` в таком состоянии не строится и не коммитится +- persistent assignments не перезаписываются +- если для этой команды уже есть последний валидный stable-slot snapshot текущей сессии, он остаётся последним валидным snapshot и не заменяется случайной геометрией +- если валидного snapshot ещё не было, stable-slot presentation path для этого render-pass не активируется до возвращения lead + +### Чего нельзя делать + +- сохранять случайные placements как будто это валидный stable layout +- создавать fake lead только ради того, чтобы planner "отработал" + +Это transient safety rule, чтобы неполные данные не портили persistent layout state. + +## Drag and snap semantics + +Drag нужен, но он не должен возвращать нас к свободной физике. + +### Правила + +- Drag доступен только для `member slots` +- `lead` не draggable +- Во время drag можно подсвечивать ближайший candidate slot +- При drop owner снапается в nearest valid slot +- Если target занят: + - делаем `swap` + - не делаем overlap + - не делаем silent fallback в произвольную соседнюю точку + +### Что сохраняем после drop + +Только: + +- `ringIndex` +- `sectorIndex` + +Не сохраняем: + +- абсолютные координаты + +### Важное уточнение про swap + +Swap допустим только если обе итоговые позиции валидны после обмена. + +Если ближайший занятый slot приводит к невалидной паре placement-ов, нужно брать следующий nearest valid candidate, а не насильно выполнять swap. + +### Поведение при drop вне валидного target + +Если пользователь отпускает owner там, где нет валидного slot candidate: + +- owner возвращается в свой предыдущий assignment +- промежуточная невалидная world position не сохраняется + +Это обязательно, чтобы drag не оставлял граф в полусломанном состоянии. + +## Почему swap выбран по умолчанию + +Потому что это самое понятное поведение для пользователя: + +- место уже занято +- я кладу owner туда +- значит владельцы меняются местами + +Любая "умная" скрытая перестановка менее предсказуема. + +## Activity connector - как трактовать + +Connector нужен, но он должен быть локальным и cheap. + +### Правило + +- connector рисуется между activity band и owner band внутри одного slot +- connector не участвует в глобальном packing +- connector не должен тянуться через пол-графа + +Это просто визуальная связь, а не самостоятельный layout actor. + +## Process rendering - важное уточнение + +Для `v1` безопаснее **не выкидывать process nodes из графовой модели полностью**. + +Лучший путь: + +- оставить process data в graph domain +- но перестать раскладывать process nodes как независимые свободные entities +- вместо этого позиционировать process presentation внутри owner slot + +То есть меняем **геометрию и ownership**, а не обязательно весь data contract в один проход. + +## Activity rendering - важное уточнение + +`GraphActivityHud` в новой модели не должен сам быть planner-ом. + +Его роль после переделки: + +- взять уже посчитанные slot-local coordinates +- отрендерить compact activity UI +- отрендерить локальный connector + +Чего он делать больше не должен: + +- сам pack-ить owner lanes друг относительно друга +- сам спасать cross-owner overlap +- сам решать world geometry + +## Zoom / pan / fit - обязательные инварианты + +### Zoom и pan + +Zoom и pan должны менять только camera transform. + +Они не имеют права: + +- перераскладывать slots +- менять relative positions внутри slot +- менять размер slot в screen-space независимо от world model + +### Fit + +`zoomToFit` и initial fit обязаны учитывать: + +- member slot frames +- lead central exclusion +- launch HUD +- unassigned task slot +- activity bands +- task bands +- process rails + +Не только центры owner nodes. + +### Важное уточнение про filters + +Даже если `showTasks = false` или `showProcesses = false`, fit в `v1` должен учитывать **reserved topology bounds**, а не только текущие видимые DOM/canvas элементы. + +Иначе переключение filters снова будет вызывать визуальный jump layout-а и нарушит главный инвариант стабильности. + +## Member add/remove behavior + +### Add + +- existing valid assignments сохраняются +- новый owner получает первый валидный slot по planner rules +- если ring 1 full по footprint, новый owner идёт в ring 2 + +### Remove + +- slot освобождается +- остальные owners не auto-compact-ятся только из-за самого факта remove/hide + +Фиксируем правило: + +- без explicit reset-layout не делаем агрессивный global compaction + +Это уменьшает визуальные скачки. + +### Reset layout behavior + +Нужен явный reset path: + +- очистить `slotAssignmentsByTeam[teamName]` +- заново построить placements по planner rules + +Это нужно и для пользователя, и для отладки, и для быстрого выхода из редких неудачных layout-состояний. + +## Rename behavior + +Если: + +- `agentId` тот же +- изменился только `member.name` + +то layout обязан сохранить: + +- slot assignment +- drag placement +- activity ownership +- process ownership +- task ownership references + +Если `agentId` нет и используется fallback на имя, это known-weaker mode, и это нужно явно покрыть тестом. + +## Поведение при росте задач + +Это отдельный важный edge case, который уже обсуждался. + +### Сценарий + +У owner сначала мало задач, потом их становится много. + +### Что не делаем + +- не включаем scroll внутри slot +- не скрываем часть columns +- не даём tasks раздавить activity соседнего owner + +### Что делаем + +- task band остаётся высотой `5 rows` +- все active non-empty columns продолжают показываться +- slot width растёт +- если текущий ring больше не вмещает выросший slot, owner может уйти на более дальний ring + +### Ключевой инвариант + +Даже если owner уходит во внешний ring, это должно быть: + +- детерминированно +- минимально разрушительно +- без общего хаотичного reshuffle + +## Public / shared contract changes + +Ниже то, что нужно явно зафиксировать, чтобы не появлялись "невидимые" зависимости. + +## Shared types + +Обязательно: + +- `ResolvedTeamMember.agentId?: string` + +## Graph node identity + +Обязательно: + +- member node ids переходят на `stableOwnerId` + +Нормативный шаблон `v1`: + +```ts +member:${teamName}:${stableOwnerId} +``` + +### Что сознательно не меняем в этом refactor + +Существующие UI/event ports могут по-прежнему использовать: + +- `teamName` +- `memberName` +- `taskId` + +если это нужно для открытия профиля, task detail или других UI-paths. + +То есть stable ids обязательны для layout identity и planner storage, но не требуют в этом pass перепридумывать весь пользовательский navigation/event contract. + +## Renderer-side layout state + +Нужно добавить owner slot assignment storage по team. + +Дополнительно нужен технический marker текущего layout path, например: + +```ts +slotLayoutVersion = 'stable-slots-v1' +``` + +Он поможет безопасно отличать новый planner path от старого во время миграции и cleanup. + +### Versioning contract + +- `slotLayoutVersion` хранится рядом с member slot assignments +- если сохранённая версия не совпадает с текущей, старые member assignments нужно сбросить и пересчитать по planner defaults +- не нужно пытаться поддерживать неявную backwards-совместимость между разными planner semantics + +Лучше один явный reset assignments path, чем тихое использование устаревшей geometry-модели. + +## Internal planner types + +Нужны internal-only helper types: + +- `SlotFrame` +- `UnassignedTaskSlotFrame` +- `OwnerSlotAssignment` +- `OwnerFootprint` +- `RingPlanCandidate` +- `StableSlotLayoutSnapshot` + +Эти типы лучше держать в feature domain / graph package internals, а не тянуть в публичный API без необходимости. + +### Нормативный `StableSlotLayoutSnapshot` для `v1` + +Чтобы renderer и simulation не собирали layout из полусырых кусков, фиксируем aggregated result type: + +```ts +type StableSlotLayoutSnapshot = { + teamName: string; + slotLayoutVersion: string; + memberSlotFrames: SlotFrame[]; + leadCentralReservedBlock: Rect; + unassignedTaskSlot?: UnassignedTaskSlotFrame; + runtimeCentralExclusion: Rect; + fitBounds: Rect; +}; +``` + +### Зачем нужен snapshot + +- один planner run должен выдавать один цельный результат +- renderer не должен сам дособирать geometry из отдельных store-полей +- validation должна проверять именно полный snapshot, а не куски по отдельности + +Это уменьшает риск, что `Activity`, `Tasks`, `Process`, fit bounds и exclusion будут жить в слегка разных версиях одного и того же layout pass. + +## Слои и ответственности + +## Shared / main + +Точки внимания: + +- `src/shared/types/team.ts` +- `src/main/services/team/TeamMemberResolver.ts` + +Ответственность: + +- протащить `agentId` в `ResolvedTeamMember` +- сделать stable identity доступной renderer-слою + +## Feature domain + +Точки внимания: + +- `src/features/agent-graph/core/domain/` + +Ответственность: + +- `stableOwnerId` +- `slotWidth/slotHeight` +- width buckets +- ring planner +- slot validity checks +- drag/snap helpers + +## Adapter layer + +Точки внимания: + +- `src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts` + +Ответственность: + +- строить member node ids на stable owner id +- привязать tasks / processes / activity к stable owner identity +- не заниматься спасением geometry post-facto + +## Graph package layout + +Точки внимания: + +- `packages/agent-graph/src/hooks/useGraphSimulation.ts` +- `packages/agent-graph/src/layout/kanbanLayout.ts` +- `packages/agent-graph/src/layout/activityLane.ts` + +Ответственность: + +- owner placement идёт от slot planner, а не от free-force +- task layout получает `slot frame` +- activity получает `slot-local origin` +- process rail позиционируется owner-local + +## Renderer UI + +Точки внимания: + +- `src/features/agent-graph/renderer/ui/GraphActivityHud.tsx` +- `src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx` +- `src/features/agent-graph/renderer/ui/GraphNodePopover.tsx` + +Ответственность: + +- быть consumer-слоем готовой geometry +- не быть главным solver-ом layout +- сохранить существующие полезные interactions + +## Подробный implementation plan + +Ниже порядок, который уменьшает риск наломать багов. + +## Phase gates - когда можно идти дальше + +Это не просто список задач по порядку. Между фазами есть обязательные quality gates. + +### Нельзя переходить к slot UI integration, пока не выполнено + +- stable identity уже протянута до graph layer +- planner helpers уже pure и покрыты базовыми unit tests +- `slotAssignmentsByTeam` уже стал source of truth +- planner уже выдаёт валидный `StableSlotLayoutSnapshot` +- validation pass уже работает +- snapshot lifecycle и fail-closed commit behavior уже определены и подключены + +### Нельзя считать activity/process/tasks phase завершённой, если + +- для позиционирования всё ещё используется screen-space self-packing +- renderer сам высчитывает geometry вместо consumption `slot frame` и local anchors +- overlap "чинится" постфактум DOM-side коррекцией + +### Нельзя удалять старые geometry paths, пока не доказано + +- `Activity` +- `Process` +- `Tasks` +- fit / camera +- drag / snap / swap + +уже работают на новом planner path без регрессии ключевых UX-сценариев. + +## Phase 0 - audit and kill-switch cleanup + +### Цель + +Перед тем как писать новый planner, зафиксировать, какие текущие механизмы нужно убрать или перестать считать source of truth. + +### Сделать + +1. Найти текущие owner-related packers и manual reposition paths +2. Отдельно отметить: + - screen-space activity pack + - owner free-force assumptions + - raw pinning paths +3. Зафиксировать, что после migration source of truth будет slot planner + +### Результат + +Список устаревающих механизмов и мест, которые должны стать no-op или быть удалены на финальной фазе. + +## Phase 0.5 - temporary rollout guard + +### Цель + +Внедрять новый planner безопасно и иметь быстрый способ локально сравнить новое поведение со старым. + +### Сделать + +1. Добавить временный internal switch / feature flag для нового planner path +2. Оставить возможность локально переключать: + - current layout + - stable slot layout +3. После достижения parity удалить переключатель или сделать его технически неактивным + +### Rollout contract + +Feature flag должен переключать **целый layout mode**, а не отдельные куски. + +Если включён новый path: + +- owner placement идёт только от stable-slot planner +- activity/process/tasks берут geometry только из нового slot path +- старые owner pack/reposition механизмы не должны параллельно влиять на те же owner-local зоны + +Если включён старый path: + +- новый planner может жить только как dev/test path +- его geometry не должна подмешиваться в production rendering случайно + +### Что запрещено + +- mixed mode, где tasks уже от нового slot frame, а activity всё ещё пакуется старым screen-space способом +- mixed mode, где новый planner строит assignments, но старый renderer потом "допихивает" geometry +- включение feature flag только для одной owner-local подсистемы без согласованного переключения всей owner-local topology модели + +### Done when + +- новый planner можно изолированно проверять во время разработки, не ломая возможность сравнения + +## Phase 1 - stable identity plumbing + +### Цель + +Убрать хрупкую зависимость layout от display name. + +### Сделать + +1. Добавить `agentId?: string` в `ResolvedTeamMember` +2. Протащить `agentId` через `TeamMemberResolver` +3. Добавить helper `getStableOwnerId(member)` +4. Перевести member node ids на stable owner id +5. Проверить все ссылки `task.ownerId`, `process owner`, `activity owner` + +### Done when + +- rename при том же `agentId` больше не меняет graph identity owner node + +## Phase 2 - slot state and planner helpers + +### Цель + +Вынести геометрию и planner из UI-слоя в чистые helper-и. + +### Сделать + +1. Добавить типы: + - `OwnerSlotAssignment` + - `SlotFrame` + - `OwnerFootprint` + - `StableSlotLayoutSnapshot` +2. Реализовать pure helpers: + - `computeOwnerFootprint` + - `classifyWidthBucket` + - `buildCentralExclusion` + - `buildOwnerAnchor` + - `buildSlotFrameFromOwnerAnchor` + - `buildSlotLocalOrigins` + - `buildUnassignedTaskSlotFrame` + - `planOwnerSlots` + - `resolveNearestSlot` + - `isSlotAssignmentValid` + - `computeRingRadius` +3. Добавить min-gap / ring-gap constants в одном месте +4. Собрать planner result в единый snapshot и валидировать его до render/commit + +### Done when + +- planner можно покрыть unit tests без React и без canvas + +## Phase 3 - renderer-side slot assignment storage + +### Цель + +Сделать persistent source of truth для owner placement. + +### Сделать + +1. Добавить store-state `slotAssignmentsByTeam` +2. Хранить assignments по `stableOwnerId` +3. Добавить actions: + - `setOwnerSlotAssignment` + - `swapOwnerSlots` + - `clearTeamSlotAssignments` + - `resetTeamSlotAssignmentsToPlannedDefaults` +4. Продумать first-load migration для старых raw pinning paths +5. Явно зафиксировать, что storage относится только к member sector slots, а не к lead/unassigned geometry +6. Добавить compare-and-reset semantics для `slotLayoutVersion` +7. Добавить one-time migration path `fallback member.name -> agentId`, если assignment уже существовал +8. Зафиксировать precedence между version reset, existing stable assignment и fallback migration + +### Done when + +- owner placement переживает refresh и не зависит от случайной d3-стабилизации +- legacy owner pinning больше не конкурирует со slot assignment как второй source of truth +- `slotLayoutVersion` mismatch сбрасывает старые member assignments предсказуемо +- existing fallback assignment может сохраниться при переходе `member.name -> agentId` +- version reset и fallback migration не создают две конкурирующие записи для одного member-а + +## Phase 4 - integrate planner into graph simulation + +### Цель + +Сделать owner positions planner-derived. + +### Сделать + +1. `useGraphSimulation` получает slot frames +2. Lead фиксируется в центре +3. `unassigned task slot`, если нужен, строится как special fixed frame под lead +4. Member node positions берутся из slot frames +5. Free-force больше не определяет owner layout +6. Если d3-force остаётся, он не должен двигать owners вне slot planner-а + +### Done when + +- owner topology не меняется из-за zoom, pan или случайного re-tick + +## Phase 5 - task band integration + +### Цель + +Встроить kanban в slot frame без cross-owner overlap. + +### Сделать + +1. `KanbanLayoutEngine` начинает работать от `slot frame` +2. Сохраняем current column order / semantics +3. Считаем реальный `kanbanWidth` +4. Ограничиваем task band по `5 rows` +5. Реализуем overflow stack per column +6. Явно сохранить существующий column order source, не вводя новый sort-rule +7. Отдельно встроить `unassigned task slot` как special pseudo-owner case: + - без `activity band` + - без `process band` + - с теми же bounded task rules + +### Done when + +- tasks не залезают в activity zone соседнего owner +- unassigned tasks больше не теряются и не "прилипают" к случайным member slots + +## Phase 6 - process rail integration + +### Цель + +Прикрепить process к owner slot. + +### Сделать + +1. Убрать свободное process placement +2. Привязать process presentation к process band внутри slot +3. Сохранить current visual style +4. Ограничить до одного релевантного process rail для `v1` + +### Done when + +- process перестаёт быть отдельным плавающим источником хаоса + +## Phase 7 - activity band integration + +### Цель + +Сделать activity частью slot, а не внешним solver-ом. + +### Сделать + +1. `GraphActivityHud` перестаёт pack-ить owners +2. Получает slot-local coordinates +3. Рисует local connector +4. Сохраняет existing compact message UI +5. Сохраняет `+N more -> Activity tab` +6. Сохраняет deterministic `newest first` + tie-break path +7. Удаляет screen-space self-packing и DOM-measurement-driven repositioning из activity path + +### Done when + +- activity больше не может сместиться независимо от owner slot + +## Phase 8 - drag / snap / swap + +### Цель + +Сохранить manual control, не ломая стабильность. + +### Сделать + +1. Drag разрешён только для member slots +2. На drop находим nearest valid slot +3. Если slot занят, делаем `swap` +4. Сохраняем assignment +5. Обновляем planner state без full random reshuffle + +### Done when + +- drag меняет slot assignment, а не свободную world position + +## Phase 9 - fit / bounds / camera + +### Цель + +Чтобы camera видела реальный layout. + +### Сделать + +1. `zoomToFit` учитывает полные slot bounds +2. Учитывать: + - activity bands + - task bands + - process rails + - central exclusion + - unassigned task slot +3. Проверить initial fit и manual fit + +### Done when + +- fit больше не обрезает реальные owner-local зоны + +## Phase 10 - cleanup old geometry paths + +### Цель + +Убрать старые механизмы, которые будут конфликтовать с новым planner-ом. + +### Сделать + +1. Удалить или задизейблить устаревший owner overlap pack path +2. Удалить owner-specific reliance on raw pinning +3. Удалить activity self-packing logic, если она больше не нужна +4. Проверить, что остались только необходимые world transforms + +### Done when + +- в коде остаётся один понятный source of truth для owner layout + +## Phase 11 - parity review + +### Цель + +Перед финальным merge проверить, что новый planner не потерял уже работающие UX-paths. + +### Проверить + +1. `+N more -> Activity tab` +2. existing activity item click behavior +3. process visual styling +4. lead launch HUD +5. fit / zoom controls +6. graph tab / fullscreen shared layout behavior +7. filters не вызывают layout jumps + +### Done when + +- новый layout стабилен и не деградирует уже полезные interaction-ы + +## Recommended PR split + +Чтобы этот refactor было реально безопасно довезти, лучше не делать его одним гигантским diff. + +Рекомендуемая нарезка: + +### PR 1 - stable identity and slot state + +- `agentId -> ResolvedTeamMember` +- `stableOwnerId` +- новые member node ids +- `slotAssignmentsByTeam` +- migration policy для legacy pinning + +### PR 2 - pure planner and simulation integration + +- `OwnerFootprint` +- `SlotFrame` +- planner helpers +- `validateStableSlotLayout` +- `StableSlotLayoutSnapshot` +- ring/sector logic +- интеграция planner-а в `useGraphSimulation` + +### PR 3 - task/process/activity slot integration + +- `KanbanLayoutEngine` от `slot frame` +- process rail inside slot +- activity inside slot +- local anchors / connector path + +### PR 4 - drag, fit, cleanup, parity + +- drag/snap/swap +- reset-layout path +- fit bounds +- удаление старых geometry paths +- parity review и финальный cleanup + +Если в реальности придётся объединить PR 2 и PR 3 - это ещё допустимо. Но делать всё одним большим PR хуже по риску и по способности нормально проверить регрессии. + +## Тест-план + +Ниже обязательные тесты, без которых этот refactor нельзя считать завершённым. + +## Identity / ordering + +- `ResolvedTeamMember.agentId` проходит до graph layer +- member node id строится на `stableOwnerId` +- rename при том же `agentId` не меняет slot assignment +- fallback на `member.name` работает, если `agentId` отсутствует +- duplicate `agentId` или duplicate fallback `member.name` не приводят к silent merge owners +- existing fallback assignment корректно мигрирует на `agentId`, если `agentId` появился позже +- hidden member при возвращении с тем же `stableOwnerId` пытается переиспользовать прежний assignment +- initial ordering стабилен и совпадает с `config.members[]` + +## Planner + +- owner slots не пересекаются друг с другом +- owner slots не пересекают central exclusion +- `leadCentralReservedBlock` не схлопывается от hidden launch HUD или пустого lead activity state +- `SlotFrame` строится по канонической формуле от `ownerAnchor` +- `activityOrigin/processOrigin/taskOrigin` детерминированно выводятся из `SlotFrame` +- validator ловит `NaN/Infinity` в frame-ах и bounds +- validator ловит duplicate assignment-ы +- validator проверяет, что band-local anchors не вылезают из своего `SlotFrame` +- при validation failure новый broken layout не перетирает последний валидный snapshot +- один planner run собирает один цельный `StableSlotLayoutSnapshot` +- snapshot коммитится атомарно, без частичного обновления `SlotFrame`/`fitBounds`/`central exclusion` +- ring 1 overflow создаёт ring 2 +- один ring не принимает больше одного owner на один sector anchor +- existing valid assignments сохраняются +- invalid assignment перепланируется локально, без полного reshuffle +- planner сначала пытается сохранить сектор, потом увести owner на внешний ring +- planner работает в world coordinates и не зависит от camera zoom/pan +- planner не пытается сам разместить `lead` или `unassigned task slot` +- planner не запускается как полноценный stable-sector layout без `lead` +- при конфликте из-за роста одного owner footprint planner сначала пытается двигать именно этот owner, а не весь ring + +## Tasks + +- task band ограничен `5 rows` +- overflow stack работает per column +- все active non-empty columns показываются +- slot width растёт при росте числа columns +- current канонический order внутри column сохраняется +- задачи без owner-а попадают в отдельный `unassigned task slot` +- задачи с owner-ом, который сейчас не видим или не существует, тоже уходят в `unassigned task slot` + +## Activity + +- activity band показывает `3` items +- `+N more` считается корректно +- comments показывают target task, а не fake-recipient member +- activity order внутри slot - newest first +- tie-break при одинаковом timestamp детерминирован и стабилен +- `+N more` открывает профиль на вкладке `Activity` + +## Process + +- process rail остаётся owner-local +- running process имеет приоритет над finished +- пустой process band не вызывает layout jump + +## Drag / persistence + +- drop снапает в nearest valid slot +- занятый target slot делает `swap` +- невалидный drop возвращает owner в прежний assignment +- refresh сохраняет slot assignments +- zoom/pan не меняют owner placement +- graph tab и fullscreen overlay используют один и тот же member slot state +- graph tab и fullscreen overlay могут иметь разный camera state +- `slotLayoutVersion` mismatch сбрасывает устаревшие member assignments +- fallback migration `member.name -> agentId` не создаёт дубли assignment-ов +- team switch не смешивает assignments разных команд +- если одна команда открыта в нескольких pane-ах, layout state у них общий и синхронизируется через shared store +- no-lead transient state не портит persistent assignments +- validation failure не делает invalid snapshot active render geometry +- no-lead path не делает stale stable-slot snapshot активным render-path вместо fallback UI +- snapshot cache не подменяет `slotAssignmentsByTeam` как placement source of truth + +## Fit / camera + +- initial fit учитывает slot bounds +- manual fit учитывает slot bounds +- initial fit учитывает `unassigned task slot` +- manual fit учитывает `unassigned task slot` +- initial fit учитывает `lead central reserved block` +- manual fit учитывает `lead central reserved block` +- zoom меняет только camera transform +- pan меняет только camera transform +- filters не меняют topology bounds, которые использует fit + +## Filters + +- `showTasks` не меняет slot topology +- `showProcesses` не меняет slot topology +- `showEdges` не меняет slot topology +- скрытие tasks/processes не убирает reserved band geometry и не вызывает layout jump +- `showTasks = false` не удаляет topology footprint у `unassigned task slot`, если в dataset всё ещё есть unassigned tasks + +## Team scoping + +- `slotAssignmentsByTeam` изолирует layout state по `teamName` +- team switch не переиспользует assignments от другой команды +- параллельно открытые graph panes не должны ломать team-scoped layout друг друга + +## Rollout / flag behavior + +- feature flag не включает mixed mode между старым и новым owner-local layout path +- при включённом новом path activity/process/tasks используют один и тот же stable-slot geometry source +- старый owner pack/reposition path не влияет на новый stable-slot render path + +## Dense teams + +- граф с большим числом участников уходит во второй ring, а не превращается в overlap-chaos +- широкие slots не придавливают соседние owner zones +- lead central zone остаётся чистой +- появление `unassigned task slot` не ломает весь ring-layout глобальным reshuffle + +## Самые слабые места и что проверять особенно внимательно + +## 1. Stable identity + +Если тут останется хотя бы один тихий fallback на `member.name` в layout storage, всё снова станет хрупким. + +И отдельно: + +- если silent merge случится из-за неуникального `agentId` или `member.name`, это сломает сразу и slot assignments, и drag, и ownership links + +## 2. Slot width growth + +Это самый рискованный geometry block после identity, потому что пользователь сознательно выбрал: + +- показывать все columns +- не вводить scroll +- не cap-ить visible columns + +## 3. Partial replanning + +Самая большая UX-ошибка тут - случайно делать global reshuffle там, где достаточно локального spill на outer ring. + +## 4. Lead central exclusion + +Если planner забудет учесть хотя бы один из центральных блоков: + +- lead +- launch HUD +- lead activity + +то первый ring начнёт врезаться в центр. + +## 5. Old geometry paths + +Если оставить старые packers и force-assumptions живыми параллельно, новый planner будет "исправляться" чужой логикой, и баги станут трудноотлавливаемыми. + +## 6. World-space vs screen-space mixing + +Это самый коварный класс багов после identity. + +Если хотя бы один owner-local слой снова начнёт: + +- сам себя pack-ить в screen-space +- двигаться из DOM measurements +- учитывать текущий zoom как вход planner-а + +то визуально всё опять будет "плавать" отдельно от графа. + +## Запрещённые переинтерпретации плана + +Ниже короткий список вещей, которые implementer не должен "упростить по ходу", потому что именно так обычно и возвращаются старые баги. + +- нельзя трактовать `Activity` как свободный overlay поверх итогового layout +- нельзя трактовать `lead` как обычный member slot только с особыми стилями +- нельзя сохранять raw `x/y` "временно, пока не доделаем slot assignment" +- нельзя позволять filters менять topology, даже если визуально это кажется проще +- нельзя схлопывать `lead activity frame` или `launch HUD frame` только потому, что они сейчас не видимы +- нельзя строить `SlotFrame` отдельно в planner и отдельно в renderer с немного разными формулами +- нельзя заменять local replanning глобальным reshuffle только потому, что так проще написать первую версию +- нельзя допускать, чтобы fullscreen и tab считали layout независимо друг от друга +- нельзя лечить overlap постфактум screen-space сдвигами вместо исправления planner-а или footprint contract + +Если для какой-то задачи кажется, что одно из этих правил "мешает быстро доделать", значит меняется не реализация плана, а сам план. Это нужно сначала явно переоткрыть как product/architecture decision, а не менять молча по коду. + +## Acceptance criteria + +- [ ] lead остаётся центральным anchor +- [ ] lead использует central reserved block, а не обычный member slot +- [ ] `leadCentralReservedBlock` детерминированно собирается из lead activity frame, lead core frame и launch HUD frame +- [ ] member slots распределяются по стабильным секторам +- [ ] second ring работает по footprint-budget, а не по member count +- [ ] stable owner id построен на `agentId`, с fallback на `member.name` +- [ ] member node ids больше не name-based +- [ ] slot assignment хранится отдельно от raw `x/y` +- [ ] `slotAssignmentsByTeam` хранит только member sector assignments, а не lead/unassigned geometry +- [ ] persistent layout state ограничен assignment-ами и version marker-ом, а geometry остаётся derived +- [ ] `slotLayoutVersion` mismatch безопасно сбрасывает старые member assignments +- [ ] existing fallback assignment может мигрировать на `agentId`, если `agentId` появился позже +- [ ] precedence между version reset, stable assignment и fallback migration определена и не создаёт дублей +- [ ] slot geometry считается в world coordinates и не чинится screen-space packer-ом +- [ ] `SlotFrame` строится по одному каноническому правилу от `ownerAnchor`, без альтернативных renderer-side трактовок +- [ ] geometry constants живут в одном stable-slot layout module и не дублируются по коду +- [ ] renderer/simulation получают один цельный `StableSlotLayoutSnapshot`, а не набор несвязанных geometry-кусочков +- [ ] planner output проходит отдельную validation pass перед commit/render +- [ ] validation failure не приводит к partially-broken render и не записывает невалидные assignments в store +- [ ] один layout update коммитится атомарно, без смешивания старых и новых поколений geometry state +- [ ] session-local last valid snapshot cache не становится альтернативным source of truth для placement +- [ ] no-lead path не коммитит новый stable-slot snapshot и не подменяет fallback render stale snapshot-ом +- [ ] runtime precedence между assignments, active snapshot, snapshot cache и fallback path соблюдается без mixed-state +- [ ] tasks bounded по высоте до `5 rows` +- [ ] activity bounded до `3 items` +- [ ] все active non-empty kanban columns показываются +- [ ] slot width может расти +- [ ] unassigned tasks живут в отдельном bounded slot под lead +- [ ] process attached к owner slot +- [ ] drag работает через snap-to-slot +- [ ] occupied slot приводит к swap +- [ ] invalid drop возвращает owner в прежний slot +- [ ] zoom/pan не меняют owner topology +- [ ] graph tab и fullscreen overlay разделяют один и тот же layout state +- [ ] graph tab и fullscreen overlay могут иметь независимый camera state без расхождения layout +- [ ] graph filters не перестраивают slot topology +- [ ] `showTasks = false` не убирает topology footprint у `unassigned task slot`, если dataset не изменился +- [ ] team switch не смешивает layout state разных команд +- [ ] team rename не обязан мигрировать layout state в `v1` и это явно осознано как tradeoff +- [ ] одна и та же команда в нескольких pane-ах использует общий shared layout state +- [ ] hidden member при возвращении с тем же `stableOwnerId` может переиспользовать прежний assignment +- [ ] no-lead transient state не порождает случайный persistent layout +- [ ] fit использует topology bounds, а не только текущую видимую presentation +- [ ] fit учитывает полные slot bounds +- [ ] dense teams больше не превращают graph в overlay-chaos + +## Итоговое решение в одной секции + +Финально мы пришли вот к чему: + +- берём `Variant 3` +- делаем `stable sectors around lead` +- фиксируем `6` sector anchors в `v1` +- добавляем `second ring` +- считаем ring capacity по footprint-budget +- используем `S / M / L` только как packing heuristic +- показываем все active non-empty kanban columns +- ограничиваем task band по высоте до `5 rows` +- ограничиваем activity band до `3 items` +- unassigned tasks уводим в отдельный bounded slot под lead +- process делаем attached sub-rail owner-а +- drag делаем через snap и swap +- slot assignment храним отдельно +- stable owner identity строим на `agentId`, fallback на `member.name` + +Это и есть зафиксированный target-state для следующей большой переделки graph layout. + +## Что ещё можно тюнить без изменения архитектуры + +Блокирующих product-level вопросов не осталось. + +Разрешён только визуальный и micro-spacing tuning, который не меняет planner semantics: + +- padding внутри activity item shell +- точные visual offsets process rail +- glow / shadow / label spacing + +Нельзя под видом тюнинга менять: + +- source of truth +- slot assignment model +- ring planner semantics +- bounded band rules +- drag/snap/swap rules + +## IOF execution checklist + +Это короткий practical checklist, по которому удобно идти во время реализации, чтобы не потерять зафиксированные решения. + +1. Сначала убедиться, что `ResolvedTeamMember` реально получил `agentId` и что graph member node ids перестали зависеть от `member.name`. +2. До включения нового planner path зафиксировать все места, где старый код всё ещё двигает owners через force/pack/pinning. +3. Вынести slot geometry в pure helpers до переписывания UI. +4. Сначала сделать planner и его unit tests, и только потом подключать UI. +5. Прежде чем трогать `GraphActivityHud`, зафиксировать один канонический `ownerAnchor/activityOrigin/processOrigin/taskOrigin`. +6. Не трогать одновременно и data model, и visual tuning, пока planner не стал детерминированным. +7. После интеграции planner-а отдельно проверить, что zoom/pan больше не влияют на topology. +8. После интеграции task band отдельно проверить dense-team cases, где slot width растёт. +9. После интеграции drag отдельно проверить invalid drop, swap и reset-layout. +10. Отдельно проверить cases без owner-а и с "битым" owner reference, чтобы такие задачи гарантированно уходили в `unassigned task slot`. +11. Перед cleanup старых paths убедиться, что новый planner покрывает `Activity`, `Process`, `Tasks` и fit bounds. +12. Перед финальным merge вручную проверить `+N more -> Activity`, launch HUD и process visual parity. +13. Не мержить состояние, где в коде остаются два равноправных source of truth для owner placement. diff --git a/src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts similarity index 80% rename from src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts rename to src/features/agent-graph/core/domain/buildInlineActivityEntries.ts index 1327fd64..a5ddfa5c 100644 --- a/src/renderer/features/agent-graph/utils/buildInlineActivityEntries.ts +++ b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts @@ -3,6 +3,8 @@ import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { buildGraphMemberNodeIdAliasMap } from './graphOwnerIdentity'; + import type { GraphActivityItem } from '@claude-teams/agent-graph'; import type { AttachmentMeta, @@ -18,6 +20,8 @@ export interface InlineActivityEntry { ownerNodeId: string; graphItem: GraphActivityItem; message: InboxMessage; + sourceKind: 'message' | 'comment'; + sourceOrder: number | null; } export interface ActivityEntrySourceData { @@ -49,6 +53,10 @@ export function buildInlineActivityEntries({ ownerNodeIds, }: BuildInlineActivityEntriesArgs): Map { const entriesByOwnerNodeId = new Map(); + const memberNodeIdByAlias = buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) + ); const appendEntry = (entry: InlineActivityEntry): void => { const targetOwnerNodeId = ownerNodeIds.has(entry.ownerNodeId) ? entry.ownerNodeId : leadId; @@ -65,6 +73,9 @@ export function buildInlineActivityEntries({ } const orderedMessages = [...data.messages].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + const messageSourceOrderByKey = new Map( + data.messages.map((message, index) => [getActivityMessageKey(message), index] as const) + ); for (const message of orderedMessages) { if (message.summary?.startsWith('Comment on ')) { continue; @@ -80,10 +91,10 @@ export function buildInlineActivityEntries({ const ownerNodeId = resolveMessageOwnerNodeId({ message, - teamName, leadId, leadName, ownerNodeIds, + memberNodeIdByAlias, }); if (!ownerNodeId) { continue; @@ -113,6 +124,8 @@ export function buildInlineActivityEntries({ ownerNodeId, graphItem, message, + sourceKind: 'message', + sourceOrder: messageSourceOrderByKey.get(getActivityMessageKey(message)) ?? null, }); } @@ -123,10 +136,10 @@ export function buildInlineActivityEntries({ const ownerNodeId = resolveCommentOwnerNodeId({ taskOwner: item.task.owner, author: item.comment.author, - teamName, leadId, leadName, ownerNodeIds, + memberNodeIdByAlias, }); if (!ownerNodeId) { continue; @@ -154,14 +167,13 @@ export function buildInlineActivityEntries({ task: item.task, comment: item.comment, }), + sourceKind: 'comment', + sourceOrder: item.sourceOrder, }); } for (const [ownerNodeId, entries] of entriesByOwnerNodeId) { - entriesByOwnerNodeId.set( - ownerNodeId, - entries.sort((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp)) - ); + entriesByOwnerNodeId.set(ownerNodeId, entries.toSorted(compareInlineActivityEntries)); } return entriesByOwnerNodeId; @@ -169,30 +181,55 @@ export function buildInlineActivityEntries({ function collectTaskComments( tasks: readonly TeamTaskWithKanban[] -): { task: TeamTaskWithKanban; comment: TaskComment }[] { - const items: { task: TeamTaskWithKanban; comment: TaskComment }[] = []; +): { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] { + const items: { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] = []; + let sourceOrder = 0; for (const task of tasks) { for (const comment of task.comments ?? []) { - items.push({ task, comment }); + items.push({ task, comment, sourceOrder }); + sourceOrder += 1; } } return items; } +function compareInlineActivityEntries( + left: InlineActivityEntry, + right: InlineActivityEntry +): number { + const timestampDiff = right.graphItem.timestamp.localeCompare(left.graphItem.timestamp); + if (timestampDiff !== 0) { + return timestampDiff; + } + + if ( + left.sourceKind === right.sourceKind && + left.sourceOrder != null && + right.sourceOrder != null && + left.sourceOrder !== right.sourceOrder + ) { + return left.sourceOrder - right.sourceOrder; + } + + return left.graphItem.id.localeCompare(right.graphItem.id); +} + function resolveMessageOwnerNodeId(args: { message: InboxMessage; - teamName: string; leadId: string; leadName: string; ownerNodeIds: ReadonlySet; + memberNodeIdByAlias: ReadonlyMap; }): string | null { - const { message, teamName, leadId, leadName, ownerNodeIds } = args; + const { message, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args; if (message.source === 'cross_team' || message.source === 'cross_team_sent') { return leadId; } - const fromId = resolveParticipantId(message.from ?? '', teamName, leadId, leadName); - const toId = message.to ? resolveParticipantId(message.to, teamName, leadId, leadName) : leadId; + const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByAlias); + const toId = message.to + ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByAlias) + : leadId; if (toId !== leadId && ownerNodeIds.has(toId)) { return toId; @@ -206,20 +243,20 @@ function resolveMessageOwnerNodeId(args: { function resolveCommentOwnerNodeId(args: { taskOwner: string | undefined; author: string; - teamName: string; leadId: string; leadName: string; ownerNodeIds: ReadonlySet; + memberNodeIdByAlias: ReadonlyMap; }): string | null { - const { taskOwner, author, teamName, leadId, leadName, ownerNodeIds } = args; + const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args; if (taskOwner) { - const ownerId = resolveParticipantId(taskOwner, teamName, leadId, leadName); + const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByAlias); if (ownerNodeIds.has(ownerId)) { return ownerId; } } - const authorId = resolveParticipantId(author, teamName, leadId, leadName); + const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByAlias); if (ownerNodeIds.has(authorId)) { return authorId; } @@ -327,9 +364,9 @@ function getActivityMessageKey(message: InboxMessage): string { function resolveParticipantId( name: string, - teamName: string, leadId: string, - leadName?: string + leadName: string | undefined, + memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') { @@ -338,7 +375,7 @@ function resolveParticipantId( if (normalized === leadName?.trim().toLowerCase()) { return leadId; } - return `member:${teamName}:${name}`; + return memberNodeIdByAlias.get(name) ?? leadId; } function buildParticipantLabel(name: string | undefined, leadName: string): string { diff --git a/src/renderer/features/agent-graph/utils/collapseOverflowStacks.ts b/src/features/agent-graph/core/domain/collapseOverflowStacks.ts similarity index 100% rename from src/renderer/features/agent-graph/utils/collapseOverflowStacks.ts rename to src/features/agent-graph/core/domain/collapseOverflowStacks.ts diff --git a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts new file mode 100644 index 00000000..02a02aa8 --- /dev/null +++ b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts @@ -0,0 +1,55 @@ +import { getStableTeamOwnerId, type StableTeamOwnerLike } from '@shared/utils/teamStableOwnerId'; + +export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; + +export function getGraphStableOwnerId(member: StableTeamOwnerLike): string { + return getStableTeamOwnerId(member); +} + +export function buildGraphMemberNodeId(teamName: string, stableOwnerId: string): string { + return `member:${teamName}:${stableOwnerId}`; +} + +export function buildGraphMemberNodeIdForMember( + teamName: string, + member: StableTeamOwnerLike +): string { + return buildGraphMemberNodeId(teamName, getGraphStableOwnerId(member)); +} + +export function buildGraphMemberNodeIdAliasMap( + teamName: string, + members: readonly StableTeamOwnerLike[] +): Map { + const aliases = new Map(); + + for (const member of members) { + const stableOwnerId = getGraphStableOwnerId(member).trim(); + if (!stableOwnerId) { + continue; + } + aliases.set(stableOwnerId, buildGraphMemberNodeId(teamName, stableOwnerId)); + } + + for (const member of members) { + const memberName = member.name.trim(); + if (!memberName || aliases.has(memberName)) { + continue; + } + aliases.set(memberName, buildGraphMemberNodeIdForMember(teamName, member)); + } + + return aliases; +} + +export function parseGraphMemberNodeId(nodeId: string, teamName?: string): string | null { + const prefix = teamName ? `member:${teamName}:` : 'member:'; + if (!nodeId.startsWith(prefix)) { + return null; + } + if (teamName) { + return nodeId.slice(prefix.length) || null; + } + const [, , ...rest] = nodeId.split(':'); + return rest.length > 0 ? rest.join(':') : null; +} diff --git a/src/renderer/features/agent-graph/utils/taskGraphSemantics.ts b/src/features/agent-graph/core/domain/taskGraphSemantics.ts similarity index 100% rename from src/renderer/features/agent-graph/utils/taskGraphSemantics.ts rename to src/features/agent-graph/core/domain/taskGraphSemantics.ts diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts similarity index 81% rename from src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts rename to src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index bb279bbd..5e9322b8 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -1,8 +1,9 @@ /** * TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort. * - * This is the ONLY file in this feature that imports from @renderer/store. - * If the project data model changes, ONLY this class needs updating. + * This adapter owns the graph projection from team runtime state into the + * reusable package port model. Renderer hooks may still read store state, but + * projection rules stay here so the mapping logic has one main reason to change. * * Class-based with ES #private fields and DI-ready constructor. */ @@ -22,24 +23,32 @@ import { } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout'; import { buildInlineActivityEntries, getGraphLeadMemberName, -} from '../utils/buildInlineActivityEntries'; -import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks'; +} from '../../core/domain/buildInlineActivityEntries'; +import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; +import { + buildGraphMemberNodeIdAliasMap, + buildGraphMemberNodeIdForMember, + getGraphStableOwnerId, + GRAPH_STABLE_SLOT_LAYOUT_VERSION, +} from '../../core/domain/graphOwnerIdentity'; import { isTaskBlocked, isTaskInReviewCycle, resolveTaskReviewer, -} from '../utils/taskGraphSemantics'; +} from '../../core/domain/taskGraphSemantics'; import type { - GraphActivityItem, GraphDataPort, GraphEdge, + GraphLayoutPort, GraphNode, GraphNodeState, + GraphOwnerSlotAssignment, GraphParticle, } from '@claude-teams/agent-graph'; import type { @@ -49,6 +58,7 @@ import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, TeamData, + TeamProcess, TeamProvisioningProgress, } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; @@ -90,12 +100,23 @@ export class TeamGraphAdapter { toolHistory?: Record, commentReadState?: Record, provisioningProgress?: TeamProvisioningProgress | null, - memberSpawnSnapshot?: MemberSpawnStatusesSnapshot + memberSpawnSnapshot?: MemberSpawnStatusesSnapshot, + slotAssignments?: Record ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); } + const duplicateStableOwnerIds = TeamGraphAdapter.#collectDuplicateStableOwnerIds( + teamData.members.filter((member) => !member.removedAt && !isLeadMember(member)) + ); + if (duplicateStableOwnerIds.length > 0) { + console.error( + `[agent-graph] duplicate stable owner ids in team=${teamName}: ${duplicateStableOwnerIds.join(', ')}` + ); + return TeamGraphAdapter.#emptyResult(teamName); + } + // Reset particle tracking when team changes if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); @@ -115,6 +136,7 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); + const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName); const provisioningPresentation = buildTeamProvisioningPresentation({ progress: provisioningProgress, members: teamData.members, @@ -144,6 +166,7 @@ export class TeamGraphAdapter { leadId, teamData, teamName, + memberNodeIdByAlias, spawnStatuses, pendingApprovalAgents, activeTools, @@ -152,8 +175,8 @@ export class TeamGraphAdapter { isTeamProvisioning, isLaunchSettling ); - this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState); - this.#buildProcessNodes(nodes, edges, teamData, teamName); + this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias); + this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( particles, @@ -162,9 +185,18 @@ export class TeamGraphAdapter { teamName, leadId, leadName, - edges + edges, + memberNodeIdByAlias + ); + this.#buildCommentParticles( + particles, + teamData, + teamName, + leadId, + leadName, + edges, + memberNodeIdByAlias ); - this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges); return { nodes, @@ -173,6 +205,7 @@ export class TeamGraphAdapter { teamName, teamColor: teamData.config.color ?? undefined, isAlive: teamData.isAlive, + layout: TeamGraphAdapter.#buildLayoutPort(teamData, teamName, slotAssignments), }; } @@ -195,6 +228,97 @@ export class TeamGraphAdapter { return getGraphLeadMemberName(data, teamName); } + static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map { + return buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) + ); + } + + static #buildLayoutPort( + data: TeamData, + teamName: string, + slotAssignments?: Record + ): GraphLayoutPort { + const ownerOrder: string[] = []; + const seenOwnerNodeIds = new Set(); + const visibleMembers = data.members.filter( + (member) => !member.removedAt && !isLeadMember(member) + ); + const visibleMemberByStableOwnerId = new Map( + visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const) + ); + const canonicalVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds( + data.members, + data.config.members ?? [] + ); + const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); + + const pushMember = (member: TeamData['members'][number] | undefined): void => { + if (!member) { + return; + } + const nodeId = buildGraphMemberNodeIdForMember(teamName, member); + if (seenOwnerNodeIds.has(nodeId)) { + return; + } + seenOwnerNodeIds.add(nodeId); + ownerOrder.push(nodeId); + }; + + for (const stableOwnerId of canonicalVisibleOwnerIds) { + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + if (!visibleMember) { + continue; + } + if (!assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + pushMember(visibleMember); + } + + for (const stableOwnerId of canonicalVisibleOwnerIds) { + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + if (!visibleMember) { + continue; + } + if (assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + pushMember(visibleMember); + } + + const normalizedAssignments: Record = {}; + for (const member of visibleMembers) { + const stableOwnerId = getGraphStableOwnerId(member); + const assignment = slotAssignments?.[stableOwnerId]; + if (!assignment) { + continue; + } + normalizedAssignments[buildGraphMemberNodeIdForMember(teamName, member)] = assignment; + } + + return { + version: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + ownerOrder, + slotAssignments: normalizedAssignments, + }; + } + + static #collectDuplicateStableOwnerIds( + members: readonly TeamData['members'][number][] + ): string[] { + const counts = new Map(); + for (const member of members) { + const stableOwnerId = getGraphStableOwnerId(member); + counts.set(stableOwnerId, (counts.get(stableOwnerId) ?? 0) + 1); + } + return Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([stableOwnerId]) => stableOwnerId) + .sort((left, right) => left.localeCompare(right)); + } + static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean { if (!timestamp || cutoffMs == null) { return false; @@ -287,6 +411,7 @@ export class TeamGraphAdapter { leadMember?.effort ), launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined, + launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, avatarUrl: agentAvatarUrl(leadName, 64), pendingApproval, @@ -324,6 +449,7 @@ export class TeamGraphAdapter { leadId: string, data: TeamData, teamName: string, + memberNodeIdByAlias: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -336,7 +462,8 @@ export class TeamGraphAdapter { if (member.removedAt) continue; if (isLeadMember(member)) continue; - const memberId = `member:${teamName}:${member.name}`; + const memberId = + memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); const spawn = spawnStatuses?.[member.name]; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[member.name], @@ -377,6 +504,7 @@ export class TeamGraphAdapter { ), spawnStatus: spawn?.status, launchVisualState: launchPresentation.launchVisualState ?? undefined, + launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined, avatarUrl: agentAvatarUrl(member.name, 64), currentTaskId: member.currentTaskId ?? undefined, currentTaskSubject: member.currentTaskId @@ -425,7 +553,8 @@ export class TeamGraphAdapter { edges: GraphEdge[], data: TeamData, teamName: string, - commentReadState?: Record + commentReadState?: Record, + memberNodeIdByAlias?: ReadonlyMap ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -446,7 +575,7 @@ export class TeamGraphAdapter { for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; - const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; + const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); @@ -496,7 +625,7 @@ export class TeamGraphAdapter { } const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } = - collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 6); + collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 5); const visibleTaskIds = new Set( visibleTaskNodes.flatMap((taskNode) => taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] @@ -571,7 +700,10 @@ export class TeamGraphAdapter { for (const relatedId of task.related ?? []) { if (!visibleTaskIds.has(relatedId)) continue; - const key = [task.id, relatedId].sort().join(':'); + const key = + task.id.localeCompare(relatedId) <= 0 + ? `${task.id}:${relatedId}` + : `${relatedId}:${task.id}`; if (this.#seenRelated.has(key)) continue; this.#seenRelated.add(key); edges.push({ @@ -605,18 +737,21 @@ export class TeamGraphAdapter { nodes: GraphNode[], edges: GraphEdge[], data: TeamData, - teamName: string + teamName: string, + memberNodeIdByAlias?: ReadonlyMap ): void { - for (const proc of data.processes) { - if (proc.stoppedAt) continue; + for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses( + data.processes, + memberNodeIdByAlias + )) { const procId = `process:${teamName}:${proc.id}`; - const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null; nodes.push({ id: procId, kind: 'process', label: proc.label, state: 'active', + ownerId, processUrl: proc.url ?? undefined, processRegisteredBy: proc.registeredBy ?? undefined, processCommand: proc.command ?? undefined, @@ -635,6 +770,48 @@ export class TeamGraphAdapter { } } + static #selectRelevantProcesses( + processes: readonly TeamProcess[], + memberNodeIdByAlias?: ReadonlyMap + ): { process: TeamProcess; ownerId: string }[] { + const selectedByOwnerId = new Map(); + + for (const process of processes) { + const ownerId = process.registeredBy + ? (memberNodeIdByAlias?.get(process.registeredBy) ?? null) + : null; + if (!ownerId) { + continue; + } + + const existing = selectedByOwnerId.get(ownerId); + if (!existing || TeamGraphAdapter.#compareProcessPriority(process, existing) < 0) { + selectedByOwnerId.set(ownerId, process); + } + } + + return Array.from(selectedByOwnerId.entries()).map(([ownerId, process]) => ({ + process, + ownerId, + })); + } + + static #compareProcessPriority(left: TeamProcess, right: TeamProcess): number { + const leftRank = left.stoppedAt ? 1 : 0; + const rightRank = right.stoppedAt ? 1 : 0; + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + + const leftTimestamp = left.stoppedAt ?? left.registeredAt; + const rightTimestamp = right.stoppedAt ?? right.registeredAt; + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp.localeCompare(leftTimestamp); + } + + return left.id.localeCompare(right.id); + } + #attachActivityFeeds( nodes: GraphNode[], data: TeamData, @@ -680,7 +857,8 @@ export class TeamGraphAdapter { teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByAlias: ReadonlyMap ): void { const ordered = [...messages].reverse(); @@ -763,16 +941,22 @@ export class TeamGraphAdapter { continue; } - const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); + const edgeId = TeamGraphAdapter.#resolveMessageEdge( + msg, + leadId, + leadName, + edges, + memberNodeIdByAlias + ); if (!edgeId) continue; // Determine direction: messages FROM a teammate TO lead should reverse // (edges are always lead→member, but message goes member→lead) const fromId = TeamGraphAdapter.#resolveParticipantId( msg.from ?? '', - teamName, leadId, - leadName + leadName, + memberNodeIdByAlias ); const isFromTeammate = fromId !== leadId; @@ -812,7 +996,8 @@ export class TeamGraphAdapter { teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByAlias: ReadonlyMap ): void { // First call: record current comment counts without creating particles. // This prevents pre-existing comments from spawning particles when the graph opens. @@ -851,9 +1036,9 @@ export class TeamGraphAdapter { } const authorNodeId = TeamGraphAdapter.#resolveParticipantId( newComment.author, - teamName, leadId, - leadName + leadName, + memberNodeIdByAlias ); const taskNodeId = `task:${teamName}:${task.id}`; const authorEdge = @@ -985,16 +1170,26 @@ export class TeamGraphAdapter { static #resolveMessageEdge( msg: InboxMessage, - teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByAlias: ReadonlyMap ): string | null { const { from, to } = msg; if (from && to) { - const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); - const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName); + const fromId = TeamGraphAdapter.#resolveParticipantId( + from, + leadId, + leadName, + memberNodeIdByAlias + ); + const toId = TeamGraphAdapter.#resolveParticipantId( + to, + leadId, + leadName, + memberNodeIdByAlias + ); return ( edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ?? @@ -1003,7 +1198,12 @@ export class TeamGraphAdapter { } if (from && !to) { - const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); + const fromId = TeamGraphAdapter.#resolveParticipantId( + from, + leadId, + leadName, + memberNodeIdByAlias + ); return ( edges.find( (e) => @@ -1018,14 +1218,14 @@ export class TeamGraphAdapter { static #resolveParticipantId( name: string, - teamName: string, leadId: string, - leadName?: string + leadName: string | undefined, + memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') return leadId; if (normalized === leadName?.trim().toLowerCase()) return leadId; - return `member:${teamName}:${name}`; + return memberNodeIdByAlias.get(name) ?? leadId; } /** Extract external team name from cross-team "from" field like "team-b.alice" */ diff --git a/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts new file mode 100644 index 00000000..ae50b51d --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts @@ -0,0 +1,17 @@ +import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { useShallow } from 'zustand/react/shallow'; + +import type { TeamData, TeamSummary } from '@shared/types/team'; + +export function useGraphActivityContext(teamName: string): { + teamData: TeamData | null; + teams: TeamSummary[]; +} { + return useStore( + useShallow((state) => ({ + teamData: selectTeamDataForName(state, teamName), + teams: state.teams, + })) + ); +} diff --git a/src/renderer/features/agent-graph/ui/useGraphCreateTaskDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx similarity index 100% rename from src/renderer/features/agent-graph/ui/useGraphCreateTaskDialog.tsx rename to src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts new file mode 100644 index 00000000..6ac0fdad --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts @@ -0,0 +1,19 @@ +import { useStore } from '@renderer/store'; +import { + getCurrentProvisioningProgressForTeam, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; +import { useShallow } from 'zustand/react/shallow'; + +export function useGraphMemberPopoverContext(teamName: string, memberName: string) { + return useStore( + useShallow((state) => ({ + teamData: teamName ? selectTeamDataForName(state, teamName) : null, + spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, + leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, + progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, + memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, + memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, + })) + ); +} diff --git a/src/features/agent-graph/renderer/hooks/useGraphSidebarVisibility.ts b/src/features/agent-graph/renderer/hooks/useGraphSidebarVisibility.ts new file mode 100644 index 00000000..b88c983a --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphSidebarVisibility.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useStore } from '@renderer/store'; + +const GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY = 'team-graph-sidebar-visible'; + +function readInitialVisibility(): boolean { + if (typeof window === 'undefined') { + return true; + } + + try { + return window.localStorage.getItem(GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY) !== 'false'; + } catch { + return true; + } +} + +export function useGraphSidebarVisibility(): { + sidebarVisible: boolean; + toggleSidebarVisible: () => void; +} { + const [sidebarEnabled, setSidebarEnabled] = useState(readInitialVisibility); + const messagesPanelMode = useStore((state) => state.messagesPanelMode); + const setMessagesPanelMode = useStore((state) => state.setMessagesPanelMode); + const sidebarVisible = sidebarEnabled && messagesPanelMode === 'sidebar'; + + useEffect(() => { + try { + window.localStorage.setItem(GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY, String(sidebarEnabled)); + } catch { + // Ignore storage failures and keep UI responsive. + } + }, [sidebarEnabled]); + + const toggleSidebarVisible = useCallback(() => { + if (sidebarVisible) { + setSidebarEnabled(false); + return; + } + + setSidebarEnabled(true); + if (messagesPanelMode !== 'sidebar') { + setMessagesPanelMode('sidebar'); + } + }, [messagesPanelMode, setMessagesPanelMode, sidebarVisible]); + + return { + sidebarVisible, + toggleSidebarVisible, + }; +} diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts similarity index 51% rename from src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts rename to src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 2b160983..42545bb3 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -3,17 +3,19 @@ * Thin wrapper — instantiates the class adapter and calls adapt() with store data. */ -import { useMemo, useRef, useSyncExternalStore } from 'react'; +import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, + isTeamGraphSlotPersistenceDisabled, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; +import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { useShallow } from 'zustand/react/shallow'; -import { TeamGraphAdapter } from './TeamGraphAdapter'; +import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; import type { GraphDataPort } from '@claude-teams/agent-graph'; @@ -31,6 +33,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory, provisioningProgress, memberSpawnSnapshot, + slotAssignments, + graphLayoutSession, + ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ teamData: selectTeamDataForName(s, teamName), @@ -43,6 +48,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, + slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined, + graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined, + ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments, })) ); @@ -58,6 +66,53 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const commentReadState = useSyncExternalStore(subscribe, getSnapshot); + const effectiveSlotAssignments = useMemo(() => { + if (!teamData) { + return slotAssignments; + } + if (!isTeamGraphSlotPersistenceDisabled()) { + return slotAssignments; + } + if (graphLayoutSession?.mode === 'manual') { + return slotAssignments; + } + const defaultSeed = buildTeamGraphDefaultLayoutSeed( + teamData.members, + teamData.config.members ?? [] + ); + const defaultAssignments = + Object.keys(defaultSeed.assignments).length === 0 ? undefined : defaultSeed.assignments; + if (!slotAssignments) { + return defaultAssignments; + } + if (graphLayoutSession?.signature !== defaultSeed.signature) { + return defaultAssignments; + } + const visibleAssignmentKeys = defaultSeed.orderedVisibleOwnerIds.filter( + (stableOwnerId) => slotAssignments[stableOwnerId] + ); + const hasExactVisibleDefaults = + visibleAssignmentKeys.length === Object.keys(defaultSeed.assignments).length && + visibleAssignmentKeys.every((stableOwnerId) => { + const currentAssignment = slotAssignments[stableOwnerId]; + const defaultAssignment = defaultSeed.assignments[stableOwnerId]; + return ( + currentAssignment && + defaultAssignment && + currentAssignment.ringIndex === defaultAssignment.ringIndex && + currentAssignment.sectorIndex === defaultAssignment.sectorIndex + ); + }); + return hasExactVisibleDefaults ? slotAssignments : defaultAssignments; + }, [graphLayoutSession, slotAssignments, teamData]); + + useLayoutEffect(() => { + if (!teamName || !teamData) { + return; + } + ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []); + }, [ensureTeamGraphSlotAssignments, teamData, teamName]); + return useMemo( () => adapterRef.current.adapt( @@ -72,7 +127,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory, commentReadState, provisioningProgress, - memberSpawnSnapshot + memberSpawnSnapshot, + effectiveSlotAssignments ), [ teamData, @@ -87,6 +143,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, + effectiveSlotAssignments, ] ); } diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts new file mode 100644 index 00000000..edea698b --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts @@ -0,0 +1,18 @@ +import { useLayoutEffect } from 'react'; + +import { useStore } from '@renderer/store'; +import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; + +export function useTeamGraphSlotReset(teamName: string, enabled = true): void { + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); + + useLayoutEffect(() => { + if (!enabled || !isTeamGraphSlotPersistenceDisabled()) { + return; + } + + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [enabled, resetTeamGraphSlotAssignmentsToDefaults, teamName]); +} diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts new file mode 100644 index 00000000..ed30a998 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; + +import { useStore } from '@renderer/store'; + +import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity'; + +import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; + +export function useTeamGraphSurfaceActions(teamName: string): { + openTeamPage: () => void; + commitOwnerSlotDrop: (payload: { + nodeId: string; + assignment: GraphOwnerSlotAssignment; + displacedNodeId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + }) => void; +} { + const openTeamPage = useCallback(() => { + useStore.getState().openTeamTab(teamName); + }, [teamName]); + + const commitOwnerSlotDrop = useCallback( + (payload: { + nodeId: string; + assignment: GraphOwnerSlotAssignment; + displacedNodeId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + }) => { + const stableOwnerId = parseGraphMemberNodeId(payload.nodeId, teamName); + if (!stableOwnerId) { + return; + } + const displacedStableOwnerId = payload.displacedNodeId + ? parseGraphMemberNodeId(payload.displacedNodeId, teamName) + : null; + const store = useStore.getState(); + if (displacedStableOwnerId && payload.displacedAssignment) { + store.commitTeamGraphOwnerSlotDrop( + teamName, + stableOwnerId, + payload.assignment, + displacedStableOwnerId, + payload.displacedAssignment + ); + return; + } + store.setTeamGraphOwnerSlotAssignment(teamName, stableOwnerId, payload.assignment); + }, + [teamName] + ); + + return { + openTeamPage, + commitOwnerSlotDrop, + }; +} diff --git a/src/features/agent-graph/renderer/index.ts b/src/features/agent-graph/renderer/index.ts new file mode 100644 index 00000000..91f45840 --- /dev/null +++ b/src/features/agent-graph/renderer/index.ts @@ -0,0 +1,13 @@ +/** + * agent-graph renderer feature - public API. + * + * Consumers outside the feature should import from here instead of reaching + * into ui/, hooks/, or core/ directly. + */ + +export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries'; +export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity'; +export { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; +export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay'; +export { TeamGraphOverlay } from './ui/TeamGraphOverlay'; +export { TeamGraphTab } from './ui/TeamGraphTab'; diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx new file mode 100644 index 00000000..7a5d4eda --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -0,0 +1,532 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { ACTIVITY_LANE } from '@claude-teams/agent-graph'; +import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; +import { + buildMessageContext, + resolveMessageRenderProps, +} from '@renderer/components/team/activity/activityMessageContext'; +import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; +import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; +import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; + +import { + buildInlineActivityEntries, + getGraphLeadMemberName, + type InlineActivityEntry, +} from '../../core/domain/buildInlineActivityEntries'; +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; + +import type { GraphNode } from '@claude-teams/agent-graph'; +import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; +import type { + MemberActivityFilter, + MemberDetailTab, +} from '@renderer/components/team/members/memberDetailTypes'; +import type { ResolvedTeamMember } from '@shared/types/team'; + +const ACTIVITY_SHELL_HEIGHT = + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight; + +interface GraphActivityHudProps { + teamName: string; + nodes: GraphNode[]; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + getViewportSize?: () => { width: number; height: number }; + focusNodeIds: ReadonlySet | null; + enabled?: boolean; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: ( + memberName: string, + options?: { + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } + ) => void; +} + +export const GraphActivityHud = ({ + teamName, + nodes, + getActivityWorldRect = () => null, + getCameraZoom = () => 1, + worldToScreen, + getNodeWorldPosition = () => null, + getViewportSize, + focusNodeIds, + enabled = true, + onOpenTaskDetail, + onOpenMemberProfile, +}: GraphActivityHudProps): React.JSX.Element | null => { + const worldLayerRef = useRef(null); + const shellRefs = useRef(new Map()); + const connectorRefs = useRef(new Map()); + const connectorPathRefs = useRef(new Map()); + const [expandedItem, setExpandedItem] = useState(null); + const { teamData, teams } = useGraphActivityContext(teamName); + + const ownerNodes = useMemo( + () => + nodes.filter( + (node): node is GraphNode & { kind: 'lead' | 'member' } => + node.kind === 'lead' || node.kind === 'member' + ), + [nodes] + ); + const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`; + const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`; + const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]); + const entryMapByOwnerNodeId = useMemo(() => { + if (!teamData) { + return new Map(); + } + return buildInlineActivityEntries({ + data: teamData, + teamName, + leadId: leadNodeId, + leadName, + ownerNodeIds, + }); + }, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]); + const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); + const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); + const { readSet } = useTeamMessagesRead(teamName); + + useEffect(() => { + setExpandedItem(null); + }, [teamName]); + + const visibleLanes = useMemo(() => { + return ownerNodes + .map((node) => { + const graphItems = node.activityItems ?? []; + const overflowCount = node.activityOverflowCount ?? 0; + const visibleCount = Math.max(0, graphItems.length - overflowCount); + const visibleGraphItems = graphItems.slice(0, visibleCount); + const entriesById = new Map( + (entryMapByOwnerNodeId.get(node.id) ?? []).map( + (entry) => [entry.graphItem.id, entry] as const + ) + ); + const entries = visibleGraphItems + .map((item) => entriesById.get(item.id)) + .filter((entry): entry is NonNullable => Boolean(entry)); + + return { + node, + entries, + overflowCount, + }; + }) + .filter( + (lane) => lane.node.kind === 'member' || lane.entries.length > 0 || lane.overflowCount > 0 + ); + }, [entryMapByOwnerNodeId, ownerNodes]); + + useLayoutEffect(() => { + if (!enabled || visibleLanes.length === 0) { + for (const shell of shellRefs.current.values()) { + if (shell) { + shell.style.opacity = '0'; + } + } + for (const connector of connectorRefs.current.values()) { + if (connector) { + connector.style.opacity = '0'; + } + } + return; + } + + let frameId = 0; + const updatePositions = (): void => { + const worldLayer = worldLayerRef.current; + if (worldLayer && worldToScreen) { + const origin = worldToScreen(0, 0); + const zoom = Math.max(getCameraZoom(), 0.001); + worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`; + } + + const measurableLanes: { + lane: (typeof visibleLanes)[number]; + shell: HTMLDivElement; + connector: SVGSVGElement | null; + connectorPath: SVGPathElement | null; + laneRect: NonNullable>; + nodeWorld: { x: number; y: number }; + }[] = []; + + for (const lane of visibleLanes) { + const shell = shellRefs.current.get(lane.node.id); + if (!shell) { + continue; + } + const connector = connectorRefs.current.get(lane.node.id) ?? null; + const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null; + + const laneRect = getActivityWorldRect(lane.node.id); + const nodeWorld = getNodeWorldPosition(lane.node.id); + if (!laneRect || !nodeWorld || !worldToScreen) { + shell.style.opacity = '0'; + if (connector) { + connector.style.opacity = '0'; + } + continue; + } + + const zoom = Math.max(getCameraZoom(), 0.001); + const screenTopLeft = worldToScreen(laneRect.left, laneRect.top); + const widthScreen = Math.max(1, laneRect.width * zoom); + const heightScreen = Math.max(1, laneRect.height * zoom); + const viewport = getViewportSize?.(); + const laneVisible = + !viewport || + (screenTopLeft.x + widthScreen > -80 && + screenTopLeft.x < viewport.width + 80 && + screenTopLeft.y + heightScreen > -80 && + screenTopLeft.y < viewport.height + 80); + if (!laneVisible) { + shell.style.opacity = '0'; + if (connector) { + connector.style.opacity = '0'; + } + continue; + } + + measurableLanes.push({ + lane, + shell, + connector, + connectorPath, + laneRect, + nodeWorld, + }); + } + + for (const entry of measurableLanes) { + const { lane, shell, connector, connectorPath, laneRect, nodeWorld } = entry; + const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; + + shell.style.opacity = String(baseOpacity); + shell.style.left = `${Math.round(laneRect.left)}px`; + shell.style.top = `${Math.round(laneRect.top)}px`; + shell.style.transform = ''; + + if (connector && connectorPath) { + const endX = laneRect.left + laneRect.width / 2; + const endY = laneRect.top >= nodeWorld.y ? laneRect.top + 10 : laneRect.bottom - 10; + const startX = nodeWorld.x; + const startY = nodeWorld.y - 18; + const minX = Math.min(startX, endX); + const minY = Math.min(startY, endY); + const connectorWidth = Math.max(1, Math.abs(endX - startX)); + const connectorHeight = Math.max(1, Math.abs(endY - startY)); + const localStartX = startX - minX; + const localStartY = startY - minY; + const localEndX = endX - minX; + const localEndY = endY - minY; + const dx = localEndX - localStartX; + const curve = Math.max(28, Math.abs(dx) * 0.35); + const c1x = localStartX + Math.sign(dx || 1) * curve; + const c1y = localStartY; + const c2x = localEndX - Math.sign(dx || 1) * curve; + const c2y = localEndY; + + connector.style.opacity = String(baseOpacity); + connector.style.left = `${Math.round(minX)}px`; + connector.style.top = `${Math.round(minY)}px`; + connector.setAttribute('width', String(Math.ceil(connectorWidth))); + connector.setAttribute('height', String(Math.ceil(connectorHeight))); + connector.setAttribute( + 'viewBox', + `0 0 ${Math.ceil(connectorWidth)} ${Math.ceil(connectorHeight)}` + ); + connectorPath.setAttribute( + 'd', + `M ${localStartX.toFixed(1)} ${localStartY.toFixed(1)} C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${localEndX.toFixed(1)} ${localEndY.toFixed(1)}` + ); + } + } + + frameId = window.requestAnimationFrame(updatePositions); + }; + + updatePositions(); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [ + enabled, + focusNodeIds, + getActivityWorldRect, + getCameraZoom, + getNodeWorldPosition, + getViewportSize, + worldToScreen, + visibleLanes, + ]); + + const expandedItemsByKey = useMemo(() => { + const items = new Map(); + for (const lane of visibleLanes) { + for (const entry of lane.entries) { + const key = toMessageKey(entry.message); + items.set(key, { type: 'message', message: entry.message }); + } + } + return items; + }, [visibleLanes]); + + const handleExpandItem = useCallback( + (key: string) => { + const next = expandedItemsByKey.get(key); + if (next) { + setExpandedItem(next); + } + }, + [expandedItemsByKey] + ); + + const handleMessageClick = useCallback((item: TimelineItem) => { + setExpandedItem(item); + }, []); + + const handleMemberNameClick = useCallback( + (memberName: string) => { + onOpenMemberProfile?.(memberName); + }, + [onOpenMemberProfile] + ); + + const handleMemberClick = useCallback( + (member: ResolvedTeamMember) => { + onOpenMemberProfile?.(member.name); + }, + [onOpenMemberProfile] + ); + + const handleOpenOwnerActivity = useCallback( + (node: GraphNode & { kind: 'lead' | 'member' }) => { + if (node.domainRef.kind !== 'lead' && node.domainRef.kind !== 'member') { + return; + } + onOpenMemberProfile?.(node.domainRef.memberName, { + initialTab: 'activity', + initialActivityFilter: 'all', + }); + }, + [onOpenMemberProfile] + ); + + const forwardWheelToGraph = useCallback((event: WheelEvent, shell: HTMLDivElement) => { + const graphRoot = shell.closest('.team-graph-view'); + const canvas = graphRoot?.querySelector('canvas'); + if (!(canvas instanceof HTMLCanvasElement)) { + return; + } + if (event.cancelable) { + event.preventDefault(); + } + canvas.dispatchEvent( + new WheelEvent('wheel', { + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + bubbles: true, + cancelable: true, + }) + ); + }, []); + + useEffect(() => { + if (!enabled) { + return; + } + + const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = []; + + for (const lane of visibleLanes) { + const shell = shellRefs.current.get(lane.node.id); + if (!shell) { + continue; + } + const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell); + shell.addEventListener('wheel', handler, { passive: false }); + listeners.push({ shell, handler }); + } + + return () => { + for (const { shell, handler } of listeners) { + shell.removeEventListener('wheel', handler); + } + }; + }, [enabled, forwardWheelToGraph, visibleLanes]); + + if (!enabled || !teamData || visibleLanes.length === 0) { + return null; + } + + return ( + <> +
+ {visibleLanes.map((lane) => ( +
+ {(() => { + const laneRect = getActivityWorldRect(lane.node.id); + const laneWidth = laneRect?.width ?? ACTIVITY_LANE.width; + const laneHeight = laneRect?.height ?? ACTIVITY_SHELL_HEIGHT; + + return ( + <> + { + connectorRefs.current.set(lane.node.id, element); + }} + className="pointer-events-none absolute z-[9] overflow-visible opacity-0" + > + { + connectorPathRefs.current.set(lane.node.id, element); + }} + d="" + fill="none" + stroke="rgba(148, 163, 184, 0.3)" + strokeWidth="1.25" + strokeLinecap="round" + strokeDasharray="3 4" + /> + +
{ + shellRefs.current.set(lane.node.id, element); + }} + className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0" + style={{ + width: `${laneWidth}px`, + maxWidth: `${laneWidth}px`, + height: `${laneHeight}px`, + }} + onDragStart={(event) => { + event.preventDefault(); + }} + > +
+
+ Activity +
+
+ {lane.entries.length === 0 && lane.overflowCount === 0 ? ( +
+ No recent activity +
+ ) : null} + {lane.entries.map((entry, index) => { + const messageKey = toMessageKey(entry.message); + const renderProps = resolveMessageRenderProps( + entry.message, + messageContext + ); + const timelineItem: TimelineItem = { + type: 'message', + message: entry.message, + }; + const isUnread = !entry.message.read && !readSet.has(messageKey); + + return ( +
handleMessageClick(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMessageClick(timelineItem); + } + }} + > + +
+ ); + })} + + {lane.overflowCount > 0 ? ( + + ) : null} +
+
+
+ + ); + })()} +
+ ))} +
+ + { + if (!open) { + setExpandedItem(null); + } + }} + teamName={teamName} + members={teamData.members} + onMemberClick={handleMemberClick} + onTaskIdClick={onOpenTaskDetail} + teamNames={teamNames} + teamColorByName={teamColorByName} + /> + + ); +}; diff --git a/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx b/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx similarity index 96% rename from src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx rename to src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx index ff74df0f..5b4d5c82 100644 --- a/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx @@ -2,8 +2,8 @@ import { useMemo } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; -import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; + +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -63,7 +63,7 @@ export const GraphBlockingEdgePopover = ({ onSelectNode, onOpenTaskDetail, }: GraphBlockingEdgePopoverProps): React.JSX.Element => { - const teamData = useStore((state) => selectTeamDataForName(state, teamName)); + const { teamData } = useGraphActivityContext(teamName); const tasksById = useMemo( () => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)), [teamData?.tasks] diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx similarity index 92% rename from src/renderer/features/agent-graph/ui/GraphNodePopover.tsx rename to src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index 029b1834..a341ec4a 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -1,22 +1,18 @@ /** * GraphNodePopover — renders popover for graph nodes using project UI components. - * Lives in features/ (not in package) so it CAN import from @renderer/. - * Reuses agentAvatarUrl, status helpers, and UI primitives from the project. + * This stays in the renderer slice instead of the reusable package because it + * composes project-specific UI, selectors, and presentation helpers. */ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; -import { useStore } from '@renderer/store'; -import { - getCurrentProvisioningProgressForTeam, - selectTeamDataForName, -} from '@renderer/store/slices/teamSlice'; import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; -import { useShallow } from 'zustand/react/shallow'; -import { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics'; +import { isTaskInReviewCycle, resolveTaskReviewer } from '../../core/domain/taskGraphSemantics'; +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; +import { useGraphMemberPopoverContext } from '../hooks/useGraphMemberPopoverContext'; import { GraphTaskCard } from './GraphTaskCard'; @@ -40,11 +36,22 @@ function formatToolPreview(preview: string | undefined): string | undefined { // If it looks like raw JSON object, try to extract a readable field if (preview.startsWith('{') || preview.startsWith('[')) { try { - const obj = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview); - // Common readable fields - return ( - obj.subject ?? obj.name ?? obj.label ?? obj.file_path ?? obj.path ?? obj.query ?? undefined - ); + const parsed: unknown = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const previewRecord = parsed as Record; + const candidates = [ + previewRecord.subject, + previewRecord.name, + previewRecord.label, + previewRecord.file_path, + previewRecord.path, + previewRecord.query, + ]; + const firstText = candidates.find((value) => typeof value === 'string'); + if (typeof firstText === 'string') { + return firstText; + } + } } catch { // Truncated JSON — extract first quoted value const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview); @@ -202,7 +209,7 @@ const OverflowPopoverContent = ({ onClose: () => void; onOpenTaskDetail?: (taskId: string) => void; }): React.JSX.Element => { - const teamData = useStore((state) => selectTeamDataForName(state, teamName)); + const { teamData } = useGraphActivityContext(teamName); const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task])); const hiddenTasks = (node.overflowTaskIds ?? []) .map((taskId) => tasksById.get(taskId) ?? null) @@ -286,16 +293,7 @@ const MemberPopoverContent = ({ : ''; const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } = - useStore( - useShallow((state) => ({ - teamData: teamName ? selectTeamDataForName(state, teamName) : null, - spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, - leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, - progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, - memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, - memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, - })) - ); + useGraphMemberPopoverContext(teamName, memberName); const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null; const provisioningPresentation = teamData && teamName @@ -322,11 +320,17 @@ const MemberPopoverContent = ({ : null; const fallbackSpawnStatusLabel = node.spawnStatus && node.spawnStatus !== 'online' - ? node.spawnStatus === 'waiting' || node.spawnStatus === 'spawning' - ? 'starting' - : node.spawnStatus + ? node.spawnStatus === 'waiting' + ? 'waiting to start' + : node.spawnStatus === 'spawning' + ? 'starting' + : node.spawnStatus === 'error' + ? 'failed' + : node.spawnStatus : null; const statusLabel = + launchPresentation?.launchStatusLabel ?? + node.launchStatusLabel ?? launchPresentation?.presenceLabel ?? fallbackSpawnStatusLabel ?? (node.state === 'active' @@ -421,7 +425,7 @@ const MemberPopoverContent = ({ )}
- {/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */} + {/* Context usage stays hidden for now because LeadContextUsage.percent is unreliable. */} {/* Current task indicator — reuses same pattern as MemberCard */} {node.currentTaskId && node.currentTaskSubject && ( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx new file mode 100644 index 00000000..34574126 --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps'; +import { StepProgressBar } from '@renderer/components/team/StepProgressBar'; +import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel'; +import { useTeamProvisioningPresentation } from '@renderer/components/team/useTeamProvisioningPresentation'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; + +import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; +import type { CSSProperties } from 'react'; + +const MINI_STEPS = DISPLAY_STEPS.map((step) => ({ key: step.key, label: step.label })); +const HUD_STEPPER_STYLE: CSSProperties = { + ['--stepper-done' as string]: '#22c55e', + ['--stepper-done-glow' as string]: 'rgba(34, 197, 94, 0.24)', + ['--stepper-current' as string]: '#22c55e', + ['--stepper-current-ring' as string]: 'rgba(34, 197, 94, 0.18)', + ['--stepper-pending' as string]: 'rgba(148, 163, 184, 0.08)', + ['--stepper-pending-text' as string]: '#cbd5e1', + ['--stepper-pending-border' as string]: 'rgba(148, 163, 184, 0.2)', + ['--stepper-line' as string]: 'rgba(148, 163, 184, 0.14)', + ['--stepper-line-done' as string]: '#22c55e', + ['--stepper-label' as string]: '#94a3b8', + ['--stepper-label-active' as string]: '#e2e8f0', + ['--stepper-error' as string]: '#ef4444', + ['--stepper-error-glow' as string]: 'rgba(239, 68, 68, 0.22)', + ['--stepper-label-error' as string]: '#fca5a5', +}; + +function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean { + return presentation != null && (presentation.isActive || presentation.isFailed); +} + +export interface GraphProvisioningHudProps { + teamName: string; + enabled?: boolean; +} + +export const GraphProvisioningHud = ({ + teamName, + enabled = true, +}: GraphProvisioningHudProps): React.JSX.Element | null => { + const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName); + const lastActiveStepRef = useRef(-1); + const [detailsOpen, setDetailsOpen] = useState(false); + const shouldRender = enabled && shouldRenderLaunchHud(presentation); + const errorStepIndex = presentation?.isFailed + ? lastActiveStepRef.current >= 0 + ? lastActiveStepRef.current + : 0 + : undefined; + + useEffect(() => { + setDetailsOpen(false); + lastActiveStepRef.current = -1; + }, [runInstanceKey, teamName]); + + useEffect(() => { + if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) { + lastActiveStepRef.current = presentation.currentStepIndex; + } + }, [presentation]); + + const ariaLabel = useMemo(() => { + const parts = [presentation?.compactTitle, presentation?.compactDetail].filter(Boolean); + return parts.join(' - ') || 'Open launch details'; + }, [presentation?.compactDetail, presentation?.compactTitle]); + + if (!shouldRender || !presentation) { + return null; + } + + return ( + <> + + + + + + Launch details + + Detailed team launch progress, live output and CLI logs. + + +
+ +
+
+
+ + ); +}; diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx similarity index 85% rename from src/renderer/features/agent-graph/ui/GraphTaskCard.tsx rename to src/features/agent-graph/renderer/ui/GraphTaskCard.tsx index ba2c343e..a051ba17 100644 --- a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx +++ b/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx @@ -1,19 +1,18 @@ /** * GraphTaskCard — wraps the REAL KanbanTaskCard with graph-specific glow/pulse effects. - * Lives in features/ so it CAN import from @renderer/. + * This is a renderer integration component, so it is allowed to compose + * project UI primitives and store-backed selectors. */ import { useMemo } from 'react'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; -import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; -import { useShallow } from 'zustand/react/shallow'; -import { isTaskBlocked, resolveTaskGraphColumn } from '../utils/taskGraphSemantics'; +import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics'; +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import type { GraphNode } from '@claude-teams/agent-graph'; -import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; +import type { KanbanColumnId, TeamTask } from '@shared/types'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -83,14 +82,10 @@ export const GraphTaskCard = ({ onDeleteTask, }: GraphTaskCardProps): React.JSX.Element => { const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; - - const { task, tasks, members } = useStore( - useShallow((s) => ({ - tasks: selectTeamDataForName(s, teamName)?.tasks ?? [], - members: selectTeamDataForName(s, teamName)?.members ?? [], - task: selectTeamDataForName(s, teamName)?.tasks.find((t) => t.id === taskId), - })) - ); + const { teamData } = useGraphActivityContext(teamName); + const tasks = teamData?.tasks ?? []; + const members = teamData?.members ?? []; + const task = tasks.find((candidate) => candidate.id === taskId); const taskMap = useMemo(() => { const map = new Map(); @@ -119,8 +114,8 @@ export const GraphTaskCard = ({ const columnId = resolveColumn(task); const taskWithKanban = task; - const closeAct = (fn?: (id: string) => void) => (taskId: string) => { - fn?.(taskId); + const closeAct = (fn?: (id: string) => void) => (nextTaskId: string) => { + fn?.(nextTaskId); onClose(); }; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx similarity index 63% rename from src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx rename to src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 2a12a7d3..c5e87d8c 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -7,15 +7,17 @@ import { useCallback, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; -import { useStore } from '@renderer/store'; -import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; +import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; +import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset'; +import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; -import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; import type { @@ -27,6 +29,8 @@ export interface TeamGraphOverlayProps { teamName: string; onClose: () => void; onPinAsTab?: () => void; + sidebarVisible?: boolean; + onToggleSidebar?: () => void; onSendMessage?: (memberName: string) => void; onOpenTaskDetail?: (taskId: string) => void; onOpenMemberProfile?: ( @@ -42,16 +46,21 @@ export const TeamGraphOverlay = ({ teamName, onClose, onPinAsTab, + sidebarVisible, + onToggleSidebar, onSendMessage, onOpenTaskDetail, onOpenMemberProfile, }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); + const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); + const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = + useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); - const leadNodeId = useMemo( - () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, - [graphData.nodes] - ); + const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; + const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; + + useTeamGraphSlotReset(teamName); // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( @@ -73,9 +82,9 @@ export const TeamGraphOverlay = ({ [dispatchTaskAction] ); const openTeamPage = useCallback(() => { - useStore.getState().openTeamTab(teamName); + openTeamTab(); onClose(); - }, [onClose, teamName]); + }, [onClose, openTeamTab]); const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); @@ -104,38 +113,56 @@ export const TeamGraphOverlay = ({ return (
- + {effectiveSidebarVisible ? ( + + ) : null} } + onOwnerSlotDrop={commitOwnerSlotDrop} className="team-graph-view min-w-0 flex-1" - renderHud={({ - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getNodeScreenPosition, - focusNodeIds, - }) => ( - <> - - - - )} + renderHud={(hudProps) => { + const extraHudProps = hudProps as typeof hudProps & { + getViewportSize?: () => { width: number; height: number }; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + }; + const { getViewportSize, focusNodeIds } = extraHudProps; + + return ( + <> + + + ); + }} renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => ( { const graphData = useTeamGraphAdapter(teamName); - const leadNodeId = useMemo( - () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, - [graphData.nodes] - ); + const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); const [fullscreen, setFullscreen] = useState(false); + const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); + useTeamGraphSlotReset(teamName, isActive); + // Typed event dispatchers (DRY — used in both events + renderOverlay) const dispatchOpenTask = useCallback( (taskId: string) => @@ -73,9 +75,6 @@ export const TeamGraphTab = ({ ), [teamName] ); - const openTeamPage = useCallback(() => { - useStore.getState().openTeamTab(teamName); - }, [teamName]); const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); @@ -130,46 +129,65 @@ export const TeamGraphTab = ({ return (
- + {sidebarVisible ? ( + + ) : null}
setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} - renderHud={({ - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getNodeScreenPosition, - focusNodeIds, - }) => ( - <> - - - + onToggleSidebar={toggleSidebarVisible} + isSidebarVisible={sidebarVisible} + renderTopToolbarContent={() => ( + )} + onOwnerSlotDrop={commitOwnerSlotDrop} + renderHud={(hudProps) => { + const extraHudProps = hudProps as typeof hudProps & { + getViewportSize?: () => { width: number; height: number }; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + }; + const { getViewportSize, focusNodeIds } = extraHudProps; + + return ( + <> + + + ); + }} renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => ( setFullscreen(false)} + sidebarVisible={sidebarVisible} + onToggleSidebar={toggleSidebarVisible} onSendMessage={dispatchSendMessage} onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} diff --git a/src/features/recent-projects/contracts/api.ts b/src/features/recent-projects/contracts/api.ts new file mode 100644 index 00000000..c1a74622 --- /dev/null +++ b/src/features/recent-projects/contracts/api.ts @@ -0,0 +1,5 @@ +import type { DashboardRecentProjectsPayload } from './dto'; + +export interface RecentProjectsElectronApi { + getDashboardRecentProjects(): Promise; +} diff --git a/src/features/recent-projects/contracts/channels.ts b/src/features/recent-projects/contracts/channels.ts new file mode 100644 index 00000000..ce463578 --- /dev/null +++ b/src/features/recent-projects/contracts/channels.ts @@ -0,0 +1,2 @@ +export const GET_DASHBOARD_RECENT_PROJECTS = 'get-dashboard-recent-projects'; +export const DASHBOARD_RECENT_PROJECTS_ROUTE = '/api/dashboard/recent-projects'; diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts new file mode 100644 index 00000000..bdb4eda0 --- /dev/null +++ b/src/features/recent-projects/contracts/dto.ts @@ -0,0 +1,24 @@ +export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini'; + +export type DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed'; + +export type DashboardRecentProjectOpenTarget = + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; + +export interface DashboardRecentProject { + id: string; + name: string; + primaryPath: string; + associatedPaths: string[]; + mostRecentActivity: number; + providerIds: DashboardProviderId[]; + source: DashboardRecentProjectSource; + openTarget: DashboardRecentProjectOpenTarget; + primaryBranch?: string; +} + +export interface DashboardRecentProjectsPayload { + projects: DashboardRecentProject[]; + degraded: boolean; +} diff --git a/src/features/recent-projects/contracts/index.ts b/src/features/recent-projects/contracts/index.ts new file mode 100644 index 00000000..41e0bc74 --- /dev/null +++ b/src/features/recent-projects/contracts/index.ts @@ -0,0 +1,4 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; +export * from './normalize'; diff --git a/src/features/recent-projects/contracts/normalize.ts b/src/features/recent-projects/contracts/normalize.ts new file mode 100644 index 00000000..116912e6 --- /dev/null +++ b/src/features/recent-projects/contracts/normalize.ts @@ -0,0 +1,32 @@ +import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './dto'; + +export type DashboardRecentProjectsPayloadLike = + | DashboardRecentProjectsPayload + | DashboardRecentProject[] + | { degraded?: unknown; projects?: unknown } + | null + | undefined; + +export function normalizeDashboardRecentProjectsPayload( + value: DashboardRecentProjectsPayloadLike +): DashboardRecentProjectsPayload | null { + if (!value) { + return null; + } + + if (Array.isArray(value)) { + return { + projects: value, + degraded: false, + }; + } + + if (!Array.isArray(value.projects)) { + return null; + } + + return { + projects: value.projects, + degraded: value.degraded === true, + }; +} diff --git a/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts new file mode 100644 index 00000000..0800ee06 --- /dev/null +++ b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts @@ -0,0 +1,6 @@ +import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAggregate'; + +export interface ListDashboardRecentProjectsResponse { + projects: RecentProjectAggregate[]; + degraded: boolean; +} diff --git a/src/features/recent-projects/core/application/ports/ClockPort.ts b/src/features/recent-projects/core/application/ports/ClockPort.ts new file mode 100644 index 00000000..b7b6878b --- /dev/null +++ b/src/features/recent-projects/core/application/ports/ClockPort.ts @@ -0,0 +1,3 @@ +export interface ClockPort { + now(): number; +} diff --git a/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts b/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts new file mode 100644 index 00000000..b3fec9d4 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts @@ -0,0 +1,5 @@ +import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse'; + +export interface ListDashboardRecentProjectsOutputPort { + present(response: ListDashboardRecentProjectsResponse): TViewModel; +} diff --git a/src/features/recent-projects/core/application/ports/LoggerPort.ts b/src/features/recent-projects/core/application/ports/LoggerPort.ts new file mode 100644 index 00000000..d265d820 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/LoggerPort.ts @@ -0,0 +1,5 @@ +export interface LoggerPort { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; +} diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts new file mode 100644 index 00000000..92070e30 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts @@ -0,0 +1,5 @@ +export interface RecentProjectsCachePort { + get(key: string): Promise; + getStale(key: string): Promise; + set(key: string, value: T, ttlMs: number): Promise; +} diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts new file mode 100644 index 00000000..cba03607 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts @@ -0,0 +1,14 @@ +import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate'; + +export interface RecentProjectsSourceResult { + candidates: RecentProjectCandidate[]; + degraded: boolean; +} + +export type RecentProjectsSourcePayload = RecentProjectsSourceResult | RecentProjectCandidate[]; + +export interface RecentProjectsSourcePort { + readonly sourceId?: string; + readonly timeoutMs?: number; + list(): Promise; +} diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts new file mode 100644 index 00000000..348fc1b4 --- /dev/null +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -0,0 +1,193 @@ +import { mergeRecentProjectCandidates } from '../../domain/policies/mergeRecentProjectCandidates'; + +import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate'; +import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse'; +import type { ClockPort } from '../ports/ClockPort'; +import type { ListDashboardRecentProjectsOutputPort } from '../ports/ListDashboardRecentProjectsOutputPort'; +import type { LoggerPort } from '../ports/LoggerPort'; +import type { RecentProjectsCachePort } from '../ports/RecentProjectsCachePort'; +import type { + RecentProjectsSourcePayload, + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '../ports/RecentProjectsSourcePort'; + +const DEFAULT_CACHE_TTL_MS = 10_000; +const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500; + +interface SourceLoadResult { + candidates: RecentProjectCandidate[]; + degraded: boolean; +} + +function normalizeSourcePayload(payload: RecentProjectsSourcePayload): RecentProjectsSourceResult { + if (Array.isArray(payload)) { + return { + candidates: payload, + degraded: false, + }; + } + + return { + candidates: payload.candidates, + degraded: payload.degraded === true, + }; +} + +export interface ListDashboardRecentProjectsDeps { + sources: RecentProjectsSourcePort[]; + cache: RecentProjectsCachePort; + output: ListDashboardRecentProjectsOutputPort; + clock: ClockPort; + logger: LoggerPort; + cacheTtlMs?: number; + degradedCacheTtlMs?: number; +} + +export class ListDashboardRecentProjectsUseCase { + readonly #cacheTtlMs: number; + readonly #degradedCacheTtlMs: number; + readonly #inFlightByCacheKey = new Map>(); + + constructor(private readonly deps: ListDashboardRecentProjectsDeps) { + this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + this.#degradedCacheTtlMs = deps.degradedCacheTtlMs ?? DEFAULT_DEGRADED_CACHE_TTL_MS; + } + + async execute(cacheKey: string): Promise { + const cached = await this.deps.cache.get(cacheKey); + if (cached) { + return cached; + } + + const inFlight = this.#inFlightByCacheKey.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const execution = this.#executeUncached(cacheKey).finally(() => { + this.#inFlightByCacheKey.delete(cacheKey); + }); + + this.#inFlightByCacheKey.set(cacheKey, execution); + return execution; + } + + async #executeUncached(cacheKey: string): Promise { + const startedAt = this.deps.clock.now(); + const stale = await this.deps.cache.getStale(cacheKey); + const results = await Promise.all( + this.deps.sources.map((source, index) => this.#loadSource(source, index)) + ); + + const successful = results.flatMap((result) => result.candidates); + const hasDegradedSources = results.some((result) => result.degraded); + const response: ListDashboardRecentProjectsResponse = { + projects: mergeRecentProjectCandidates(successful), + degraded: hasDegradedSources, + }; + + if (hasDegradedSources && stale && response.projects.length === 0) { + await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs); + this.deps.logger.info('recent-projects served stale cache', { + cacheKey, + degradedSources: results.filter((result) => result.degraded).length, + cacheTtlMs: this.#degradedCacheTtlMs, + durationMs: this.deps.clock.now() - startedAt, + }); + return stale; + } + + const viewModel = this.deps.output.present(response); + const cacheTtlMs = hasDegradedSources + ? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs) + : this.#cacheTtlMs; + + await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs); + this.deps.logger.info('recent-projects loaded', { + cacheKey, + count: response.projects.length, + degradedSources: results.filter((result) => result.degraded).length, + cacheTtlMs, + durationMs: this.deps.clock.now() - startedAt, + }); + + return viewModel; + } + + async #loadSource( + source: RecentProjectsSourcePort, + sourceIndex: number + ): Promise { + const sourceId = source.sourceId ?? `source-${sourceIndex}`; + if (!source.timeoutMs || source.timeoutMs <= 0) { + return this.#loadSourceWithoutTimeout(source, sourceId, sourceIndex); + } + + let timer: ReturnType | null = null; + try { + const result = await Promise.race([ + source + .list() + .then( + (payload) => + ({ + kind: 'success', + payload: normalizeSourcePayload(payload), + }) as const + ) + .catch( + (error: unknown) => + ({ + kind: 'error', + error, + }) as const + ), + new Promise<{ kind: 'timeout' }>((resolve) => { + timer = setTimeout(() => resolve({ kind: 'timeout' }), source.timeoutMs); + }), + ]); + + if (result.kind === 'success') { + return result.payload; + } + + if (result.kind === 'timeout') { + this.deps.logger.warn('recent-projects source timed out', { + sourceId, + sourceIndex, + timeoutMs: source.timeoutMs, + }); + return { candidates: [], degraded: true }; + } + + this.deps.logger.warn('recent-projects source failed', { + sourceId, + sourceIndex, + error: result.error instanceof Error ? result.error.message : String(result.error), + }); + return { candidates: [], degraded: true }; + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + async #loadSourceWithoutTimeout( + source: RecentProjectsSourcePort, + sourceId: string, + sourceIndex: number + ): Promise { + try { + return normalizeSourcePayload(await source.list()); + } catch (error) { + this.deps.logger.warn('recent-projects source failed', { + sourceId, + sourceIndex, + error: error instanceof Error ? error.message : String(error), + }); + return { candidates: [], degraded: true }; + } + } +} diff --git a/src/features/recent-projects/core/domain/models/ProviderId.ts b/src/features/recent-projects/core/domain/models/ProviderId.ts new file mode 100644 index 00000000..d55bf3cd --- /dev/null +++ b/src/features/recent-projects/core/domain/models/ProviderId.ts @@ -0,0 +1 @@ +export type ProviderId = 'anthropic' | 'codex' | 'gemini'; diff --git a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts new file mode 100644 index 00000000..9c098a65 --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts @@ -0,0 +1,14 @@ +import type { ProviderId } from './ProviderId'; +import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; + +export interface RecentProjectAggregate { + identity: string; + displayName: string; + primaryPath: string; + associatedPaths: string[]; + lastActivityAt: number; + providerIds: ProviderId[]; + source: 'claude' | 'codex' | 'mixed'; + openTarget: RecentProjectOpenTarget; + branchName?: string; +} diff --git a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts new file mode 100644 index 00000000..2abc5315 --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts @@ -0,0 +1,14 @@ +import type { ProviderId } from './ProviderId'; +import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; + +export interface RecentProjectCandidate { + identity: string; + displayName: string; + primaryPath: string; + associatedPaths: string[]; + lastActivityAt: number; + providerIds: ProviderId[]; + sourceKind: 'claude' | 'codex'; + openTarget: RecentProjectOpenTarget; + branchName?: string; +} diff --git a/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts b/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts new file mode 100644 index 00000000..c4ae3214 --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts @@ -0,0 +1,3 @@ +export type RecentProjectOpenTarget = + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; diff --git a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts new file mode 100644 index 00000000..ce3be598 --- /dev/null +++ b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts @@ -0,0 +1,88 @@ +import type { ProviderId } from '../models/ProviderId'; +import type { RecentProjectAggregate } from '../models/RecentProjectAggregate'; +import type { RecentProjectCandidate } from '../models/RecentProjectCandidate'; + +function uniquePaths(paths: readonly string[], primaryPath: string): string[] { + const ordered = [primaryPath, ...paths]; + const seen = new Set(); + const result: string[] = []; + + for (const path of ordered) { + if (!path || seen.has(path)) { + continue; + } + seen.add(path); + result.push(path); + } + + return result; +} + +function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] { + return Array.from(new Set(providerIds)); +} + +function selectPreferredCandidate( + candidates: readonly RecentProjectCandidate[] +): RecentProjectCandidate { + const existingWorktreeCandidates = candidates.filter( + (candidate) => candidate.openTarget.type === 'existing-worktree' + ); + const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates; + + return [...pool].sort((left, right) => { + if (right.lastActivityAt !== left.lastActivityAt) { + return right.lastActivityAt - left.lastActivityAt; + } + return left.displayName.localeCompare(right.displayName); + })[0]; +} + +function mergeBranchName(candidates: readonly RecentProjectCandidate[]): string | undefined { + const branchNames = Array.from( + new Set(candidates.map((candidate) => candidate.branchName?.trim()).filter(Boolean)) + ); + + return branchNames.length === 1 ? branchNames[0] : undefined; +} + +export function mergeRecentProjectCandidates( + candidates: readonly RecentProjectCandidate[] +): RecentProjectAggregate[] { + const grouped = new Map(); + + for (const candidate of candidates) { + if (!candidate.identity || candidate.lastActivityAt <= 0) { + continue; + } + const bucket = grouped.get(candidate.identity); + if (bucket) { + bucket.push(candidate); + } else { + grouped.set(candidate.identity, [candidate]); + } + } + + const aggregates = Array.from(grouped.values()).map((group): RecentProjectAggregate => { + const preferred = selectPreferredCandidate(group); + const providerIds = uniqueProviders(group.flatMap((candidate) => candidate.providerIds)); + const sourceKinds = new Set(group.map((candidate) => candidate.sourceKind)); + + return { + identity: preferred.identity, + displayName: preferred.displayName, + primaryPath: preferred.primaryPath, + associatedPaths: uniquePaths( + group.flatMap((candidate) => candidate.associatedPaths), + preferred.primaryPath + ), + lastActivityAt: Math.max(...group.map((candidate) => candidate.lastActivityAt)), + providerIds, + source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude', + openTarget: preferred.openTarget, + branchName: mergeBranchName(group), + }; + }); + + return aggregates.sort((left, right) => right.lastActivityAt - left.lastActivityAt); +} diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts new file mode 100644 index 00000000..104ccb1a --- /dev/null +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -0,0 +1,30 @@ +import { + DASHBOARD_RECENT_PROJECTS_ROUTE, + type DashboardRecentProjectsPayload, + normalizeDashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; +import { createLogger } from '@shared/utils/logger'; + +import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('Feature:RecentProjects:HTTP'); + +export function registerRecentProjectsHttp( + app: FastifyInstance, + feature: RecentProjectsFeatureFacade +): void { + app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { + try { + return ( + normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? { + projects: [], + degraded: true, + } + ); + } catch (error) { + logger.error('Failed to load dashboard recent projects via HTTP', error); + return { projects: [], degraded: true }; + } + }); +} diff --git a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts new file mode 100644 index 00000000..a18ea436 --- /dev/null +++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts @@ -0,0 +1,33 @@ +import { + GET_DASHBOARD_RECENT_PROJECTS, + normalizeDashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; +import { createLogger } from '@shared/utils/logger'; + +import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; +import type { IpcMain } from 'electron'; + +const logger = createLogger('Feature:RecentProjects:IPC'); + +export function registerRecentProjectsIpc( + ipcMain: IpcMain, + feature: RecentProjectsFeatureFacade +): void { + ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => { + try { + return ( + normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? { + projects: [], + degraded: true, + } + ); + } catch (error) { + logger.error('Failed to load dashboard recent projects via IPC', error); + return { projects: [], degraded: true }; + } + }); +} + +export function removeRecentProjectsIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(GET_DASHBOARD_RECENT_PROJECTS); +} diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts new file mode 100644 index 00000000..c9a3e5fb --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -0,0 +1,27 @@ +import type { + DashboardRecentProject, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; +import type { ListDashboardRecentProjectsResponse } from '@features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse'; +import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort'; + +export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort { + present(response: ListDashboardRecentProjectsResponse): DashboardRecentProjectsPayload { + return { + degraded: response.degraded, + projects: response.projects.map( + (aggregate): DashboardRecentProject => ({ + id: aggregate.identity, + name: aggregate.displayName, + primaryPath: aggregate.primaryPath, + associatedPaths: aggregate.associatedPaths, + mostRecentActivity: aggregate.lastActivityAt, + providerIds: aggregate.providerIds, + source: aggregate.source, + openTarget: aggregate.openTarget, + primaryBranch: aggregate.branchName, + }) + ), + }; + } +} diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts new file mode 100644 index 00000000..700b9122 --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -0,0 +1,86 @@ +import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import { WorktreeGrouper } from '@main/services/discovery/WorktreeGrouper'; +import { getProjectsBasePath } from '@main/utils/pathDecoder'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; +import type { ServiceContext } from '@main/services'; +import type { RepositoryGroup, Worktree } from '@main/types'; + +function selectPreferredWorktree(worktrees: readonly Worktree[]): Worktree | undefined { + return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0]; +} + +function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null { + if (!repo.worktrees.length || !repo.mostRecentSession) { + return null; + } + + const preferredWorktree = selectPreferredWorktree(repo.worktrees); + if (!preferredWorktree) { + return null; + } + + return { + identity: repo.identity?.id ?? `path:${normalizeIdentityPath(preferredWorktree.path)}`, + displayName: repo.name, + primaryPath: preferredWorktree.path, + associatedPaths: repo.worktrees.map((worktree) => worktree.path), + lastActivityAt: repo.mostRecentSession, + providerIds: ['anthropic'], + sourceKind: 'claude', + openTarget: { + type: 'existing-worktree', + repositoryId: repo.id, + worktreeId: preferredWorktree.id, + }, + branchName: preferredWorktree.gitBranch, + }; +} + +export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePort { + readonly #localWorktreeGrouper = new WorktreeGrouper(getProjectsBasePath()); + + constructor( + private readonly getActiveContext: () => ServiceContext, + private readonly logger: LoggerPort + ) {} + + async list(): Promise { + const activeContext = this.getActiveContext(); + const groups = + activeContext.type === 'local' + ? await this.#groupLocalProjects(activeContext) + : await activeContext.projectScanner.scanWithWorktreeGrouping(); + + const candidates = groups + .map((group) => toCandidate(group)) + .filter((candidate): candidate is RecentProjectCandidate => candidate !== null); + + this.logger.info('claude recent-projects source loaded', { + count: candidates.length, + contextId: activeContext.id, + }); + + return { + candidates, + degraded: false, + }; + } + + async #groupLocalProjects(activeContext: ServiceContext): Promise { + try { + const projects = await activeContext.projectScanner.scan(); + return await this.#localWorktreeGrouper.groupByRepository(projects); + } catch (error) { + this.logger.warn('claude recent-projects fell back to simplified grouping', { + error: error instanceof Error ? error.message : String(error), + }); + return activeContext.projectScanner.scanWithWorktreeGrouping(); + } + } +} diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts new file mode 100644 index 00000000..f597fcfe --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -0,0 +1,211 @@ +import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import path from 'path'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; +import type { + CodexAppServerClient, + CodexRecentThreadsResult, + CodexThreadSummary, +} from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; +import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; +import type { ServiceContext } from '@main/services'; + +const CODEX_THREAD_LIMIT = 40; +const CODEX_INITIALIZE_TIMEOUT_MS = 6_000; +const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500; +const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500; +const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500; +const CODEX_TOTAL_FETCH_TIMEOUT_MS = + CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; +const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500; +const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS = + CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; + +function isInteractiveSource(source: unknown): boolean { + return source === 'vscode' || source === 'cli'; +} + +function normalizeTimestamp(value: number | undefined): number { + if (!value) { + return 0; + } + return value < 1_000_000_000_000 ? value * 1000 : value; +} + +function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean { + return Boolean(result.live.error || result.archived.error); +} + +export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort { + readonly sourceId = 'codex'; + readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS; + + constructor( + private readonly deps: { + getActiveContext: () => ServiceContext; + getLocalContext: () => ServiceContext | undefined; + resolveBinary: () => Promise; + appServerClient: CodexAppServerClient; + identityResolver: RecentProjectIdentityResolver; + logger: LoggerPort; + } + ) {} + + async list(): Promise { + const activeContext = this.deps.getActiveContext(); + const localContext = this.deps.getLocalContext(); + + if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) { + return { + candidates: [], + degraded: false, + }; + } + + const binaryPath = await this.deps.resolveBinary(); + if (!binaryPath) { + this.deps.logger.info('codex recent-projects source skipped - binary unavailable'); + return { + candidates: [], + degraded: false, + }; + } + + const threadSegments = await this.#listRecentThreadsSafe(binaryPath); + const degraded = isDegradedThreadResult(threadSegments); + this.#logSegmentFailure(threadSegments, 'live'); + this.#logSegmentFailure(threadSegments, 'archived'); + const liveThreads = threadSegments.live.threads; + const archivedThreads = threadSegments.archived.threads; + + const interactiveThreads = [...liveThreads, ...archivedThreads].filter( + (thread) => Boolean(thread.cwd) && isInteractiveSource(thread.source) + ); + + const candidates = ( + await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread))) + ).filter((candidate): candidate is RecentProjectCandidate => candidate !== null); + + this.deps.logger.info('codex recent-projects source loaded', { + count: candidates.length, + degraded, + }); + + return { + candidates, + degraded, + }; + } + + async #listRecentThreads(binaryPath: string): Promise { + const result = await this.deps.appServerClient.listRecentThreads(binaryPath, { + limit: CODEX_THREAD_LIMIT, + liveRequestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS, + archivedRequestTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS, + initializeTimeoutMs: CODEX_INITIALIZE_TIMEOUT_MS, + totalTimeoutMs: CODEX_TOTAL_FETCH_TIMEOUT_MS, + }); + + this.deps.logger.info('codex recent-projects thread lists loaded', { + liveCount: result.live.threads.length, + archivedCount: result.archived.threads.length, + }); + return result; + } + + #logSegmentFailure(result: CodexRecentThreadsResult, segment: 'live' | 'archived'): void { + const error = result[segment].error; + if (!error) { + return; + } + + if (segment === 'archived' && !result.live.error) { + this.deps.logger.info('codex recent-projects archived thread list degraded', { + error, + }); + return; + } + + this.deps.logger.warn('codex recent-projects thread list failed', { + segment, + error, + }); + } + + async #listRecentThreadsSafe(binaryPath: string): Promise { + try { + return await this.#listRecentThreads(binaryPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.deps.logger.warn('codex recent-projects thread list session failed', { + error: message, + }); + + if (message.toLowerCase().includes('timed out')) { + return { + live: { threads: [], error: message }, + archived: { threads: [], error: message }, + }; + } + + try { + const liveFallback = await this.deps.appServerClient.listRecentLiveThreads(binaryPath, { + limit: CODEX_THREAD_LIMIT, + requestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS, + initializeTimeoutMs: CODEX_INITIALIZE_TIMEOUT_MS, + totalTimeoutMs: CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS, + }); + + this.deps.logger.info('codex recent-projects recovered with live-only fallback', { + liveCount: liveFallback.threads.length, + }); + + return { + live: liveFallback, + archived: { threads: [], error: message }, + }; + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + this.deps.logger.warn('codex recent-projects live-only fallback failed', { + error: fallbackMessage, + }); + } + + return { + live: { threads: [], error: message }, + archived: { threads: [], error: message }, + }; + } + } + + async #toCandidate(thread: CodexThreadSummary): Promise { + const cwd = thread.cwd?.trim(); + if (!cwd) { + return null; + } + + const identity = await this.deps.identityResolver.resolve(cwd); + const displayName = identity?.name ?? path.basename(cwd) ?? thread.name?.trim() ?? cwd; + + return { + identity: identity?.id ?? `path:${normalizeIdentityPath(cwd)}`, + displayName, + primaryPath: cwd, + associatedPaths: [cwd], + lastActivityAt: normalizeTimestamp(thread.updatedAt ?? thread.createdAt), + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: cwd, + }, + branchName: thread.gitInfo?.branch ?? undefined, + }; + } +} diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts new file mode 100644 index 00000000..f7109381 --- /dev/null +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -0,0 +1,61 @@ +import { + type DashboardRecentProjectsPayload, + normalizeDashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; + +import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase'; +import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter'; +import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/ClaudeRecentProjectsSourceAdapter'; +import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter'; +import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache'; +import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient'; +import { CodexBinaryResolver } from '../infrastructure/codex/CodexBinaryResolver'; +import { JsonRpcStdioClient } from '../infrastructure/codex/JsonRpcStdioClient'; +import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver'; + +import type { ClockPort } from '../../core/application/ports/ClockPort'; +import type { LoggerPort } from '../../core/application/ports/LoggerPort'; +import type { ServiceContext } from '@main/services'; + +export interface RecentProjectsFeatureFacade { + listDashboardRecentProjects(): Promise; +} + +export function createRecentProjectsFeature(deps: { + getActiveContext: () => ServiceContext; + getLocalContext: () => ServiceContext | undefined; + logger: LoggerPort; +}): RecentProjectsFeatureFacade { + const cache = new InMemoryRecentProjectsCache(); + const presenter = new DashboardRecentProjectsPresenter(); + const clock: ClockPort = { now: () => Date.now() }; + const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger); + const codexAppServerClient = new CodexAppServerClient(jsonRpcStdioClient); + const identityResolver = new RecentProjectIdentityResolver(); + const sources = [ + new ClaudeRecentProjectsSourceAdapter(deps.getActiveContext, deps.logger), + new CodexRecentProjectsSourceAdapter({ + getActiveContext: deps.getActiveContext, + getLocalContext: deps.getLocalContext, + resolveBinary: () => CodexBinaryResolver.resolve(), + appServerClient: codexAppServerClient, + identityResolver, + logger: deps.logger, + }), + ]; + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output: presenter, + clock, + logger: deps.logger, + }); + + return { + listDashboardRecentProjects: async () => { + const activeContext = deps.getActiveContext(); + const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true }; + }, + }; +} diff --git a/src/features/recent-projects/main/index.ts b/src/features/recent-projects/main/index.ts new file mode 100644 index 00000000..bdf2a07b --- /dev/null +++ b/src/features/recent-projects/main/index.ts @@ -0,0 +1,7 @@ +export { registerRecentProjectsHttp } from './adapters/input/http/registerRecentProjectsHttp'; +export { + registerRecentProjectsIpc, + removeRecentProjectsIpc, +} from './adapters/input/ipc/registerRecentProjectsIpc'; +export type { RecentProjectsFeatureFacade } from './composition/createRecentProjectsFeature'; +export { createRecentProjectsFeature } from './composition/createRecentProjectsFeature'; diff --git a/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts new file mode 100644 index 00000000..53f1c49b --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts @@ -0,0 +1,34 @@ +import type { RecentProjectsCachePort } from '../../../core/application/ports/RecentProjectsCachePort'; + +interface CacheEntry { + value: T; + expiresAt: number; +} + +export class InMemoryRecentProjectsCache implements RecentProjectsCachePort { + readonly #entries = new Map>(); + + async get(key: string): Promise { + const entry = this.#entries.get(key); + if (!entry) { + return null; + } + + if (entry.expiresAt <= Date.now()) { + return null; + } + + return entry.value; + } + + async getStale(key: string): Promise { + return this.#entries.get(key)?.value ?? null; + } + + async set(key: string, value: T, ttlMs: number): Promise { + this.#entries.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); + } +} diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts new file mode 100644 index 00000000..56985538 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -0,0 +1,218 @@ +import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; +const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; +const DEFAULT_INITIALIZE_TIMEOUT_MS = 6_000; +const MIN_SESSION_OVERHEAD_TIMEOUT_MS = 1_500; +const SUPPRESSED_NOTIFICATION_METHODS = [ + 'thread/started', + 'thread/status/changed', + 'thread/archived', + 'thread/unarchived', + 'thread/closed', + 'thread/name/updated', + 'turn/started', + 'turn/completed', + 'item/agentMessage/delta', + 'item/agentReasoning/delta', + 'item/execCommandOutputDelta', +]; + +interface ThreadListResponse { + data?: CodexThreadSummary[]; +} + +interface CodexGitInfo { + branch?: string | null; + originUrl?: string | null; + sha?: string | null; +} + +export interface CodexThreadSummary { + id: string; + createdAt?: number; + updatedAt?: number; + cwd?: string | null; + source?: unknown; + modelProvider?: string | null; + gitInfo?: CodexGitInfo | null; + name?: string | null; + path?: string | null; +} + +export interface CodexThreadSegmentResult { + threads: CodexThreadSummary[]; + error?: string; +} + +export interface CodexRecentThreadsResult { + live: CodexThreadSegmentResult; + archived: CodexThreadSegmentResult; +} + +interface ThreadListSessionOptions { + binaryPath: string; + requestTimeoutMs: number; + initializeTimeoutMs: number; + totalTimeoutMs: number; + label: string; +} + +export class CodexAppServerClient { + constructor(private readonly rpcClient: JsonRpcStdioClient) {} + + async listRecentLiveThreads( + binaryPath: string, + options: { + limit: number; + requestTimeoutMs?: number; + initializeTimeoutMs?: number; + totalTimeoutMs?: number; + } + ): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const initializeTimeoutMs = Math.max( + options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS, + requestTimeoutMs + ); + const totalTimeoutMs = Math.max( + options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, + initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + ); + + return this.#withThreadListSession( + { + binaryPath, + requestTimeoutMs, + initializeTimeoutMs, + totalTimeoutMs, + label: 'codex app-server thread/list live', + }, + async (session) => { + const live = await session.request( + 'thread/list', + { + archived: false, + limit: options.limit, + sortKey: 'updated_at', + }, + requestTimeoutMs + ); + + return { + threads: live.data ?? [], + }; + } + ); + } + + async listRecentThreads( + binaryPath: string, + options: { + limit: number; + liveRequestTimeoutMs?: number; + archivedRequestTimeoutMs?: number; + initializeTimeoutMs?: number; + totalTimeoutMs?: number; + } + ): Promise { + const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs); + const initializeTimeoutMs = Math.max( + options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS, + sessionRequestTimeoutMs + ); + const totalTimeoutMs = Math.max( + options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, + initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + ); + + return this.#withThreadListSession( + { + binaryPath, + requestTimeoutMs: sessionRequestTimeoutMs, + initializeTimeoutMs, + totalTimeoutMs, + label: 'codex app-server thread/list', + }, + async (session) => { + const [live, archived] = await Promise.allSettled([ + session.request( + 'thread/list', + { + archived: false, + limit: options.limit, + sortKey: 'updated_at', + }, + liveRequestTimeoutMs + ), + session.request( + 'thread/list', + { + archived: true, + limit: options.limit, + sortKey: 'updated_at', + }, + archivedRequestTimeoutMs + ), + ]); + + return { + live: + live.status === 'fulfilled' + ? { threads: live.value.data ?? [] } + : { + threads: [], + error: live.reason instanceof Error ? live.reason.message : String(live.reason), + }, + archived: + archived.status === 'fulfilled' + ? { threads: archived.value.data ?? [] } + : { + threads: [], + error: + archived.reason instanceof Error + ? archived.reason.message + : String(archived.reason), + }, + }; + } + ); + } + + async #withThreadListSession( + options: ThreadListSessionOptions, + handler: (session: JsonRpcSession) => Promise + ): Promise { + return this.rpcClient.withSession( + { + binaryPath: options.binaryPath, + args: ['app-server'], + requestTimeoutMs: options.requestTimeoutMs, + totalTimeoutMs: options.totalTimeoutMs, + label: options.label, + }, + async (session) => { + await session.request( + 'initialize', + { + clientInfo: { + name: 'claude-agent-teams-ui', + title: 'Claude Agent Teams UI', + version: '0.1.0', + }, + capabilities: { + experimentalApi: false, + optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS, + }, + }, + options.initializeTimeoutMs + ); + + await session.notify('initialized'); + return handler(session); + } + ); + } +} diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts new file mode 100644 index 00000000..7b7184bf --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts @@ -0,0 +1,120 @@ +import { constants as fsConstants } from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import path from 'node:path'; + +const CACHE_VERIFY_TTL_MS = 30_000; + +let cachedBinaryPath: string | null | undefined; +let cacheVerifiedAt = 0; +let resolveInFlight: Promise | null = null; + +async function fileExists(filePath: string): Promise { + try { + await fsp.access(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function expandWindowsExtensions(candidate: string): string[] { + if (process.platform !== 'win32') { + return [candidate]; + } + + const pathext = process.env.PATHEXT?.split(';').filter(Boolean) ?? [ + '.EXE', + '.CMD', + '.BAT', + '.COM', + ]; + const hasKnownExtension = pathext.some((ext) => + candidate.toLowerCase().endsWith(ext.toLowerCase()) + ); + + if (hasKnownExtension) { + return [candidate]; + } + + return [candidate, ...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`)]; +} + +async function verifyBinary(candidate: string): Promise { + const expandedCandidates = expandWindowsExtensions(candidate); + + if (path.isAbsolute(candidate) || candidate.includes(path.sep)) { + for (const expandedCandidate of expandedCandidates) { + if (await fileExists(expandedCandidate)) { + return expandedCandidate; + } + } + return null; + } + + const pathEntries = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean); + for (const pathEntry of pathEntries) { + for (const expandedCandidate of expandedCandidates) { + const resolvedCandidate = path.join(pathEntry, expandedCandidate); + if (await fileExists(resolvedCandidate)) { + return resolvedCandidate; + } + } + } + + return null; +} + +export class CodexBinaryResolver { + static clearCache(): void { + cachedBinaryPath = undefined; + cacheVerifiedAt = 0; + resolveInFlight = null; + } + + static async resolve(): Promise { + if (cachedBinaryPath !== undefined) { + if (cachedBinaryPath === null) { + return null; + } + + if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { + return cachedBinaryPath; + } + + const verified = await verifyBinary(cachedBinaryPath); + if (verified) { + cacheVerifiedAt = Date.now(); + return verified; + } + + cachedBinaryPath = undefined; + cacheVerifiedAt = 0; + } + + if (!resolveInFlight) { + resolveInFlight = CodexBinaryResolver.runResolve().finally(() => { + resolveInFlight = null; + }); + } + + return resolveInFlight; + } + + private static async runResolve(): Promise { + const override = process.env.CODEX_CLI_PATH?.trim(); + const candidates = override ? [override, 'codex'] : ['codex']; + + for (const candidate of candidates) { + const resolved = await verifyBinary(candidate); + if (resolved) { + cachedBinaryPath = resolved; + cacheVerifiedAt = Date.now(); + return resolved; + } + } + + cachedBinaryPath = null; + cacheVerifiedAt = Date.now(); + return null; + } +} diff --git a/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts b/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts new file mode 100644 index 00000000..afd3dc6a --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts @@ -0,0 +1,211 @@ +import { once } from 'node:events'; +import readline from 'node:readline'; + +import { killProcessTree, spawnCli } from '@main/utils/childProcess'; + +import type { LoggerPort } from '../../../core/application/ports/LoggerPort'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; +const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; + +interface JsonRpcErrorPayload { + code?: number; + message?: string; +} + +interface JsonRpcResponse { + id?: number; + result?: T; + error?: JsonRpcErrorPayload; +} + +export interface JsonRpcSession { + request(method: string, params?: unknown, timeoutMs?: number): Promise; + notify(method: string, params?: unknown): Promise; +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }) as Promise; +} + +export class JsonRpcStdioClient { + constructor(private readonly logger: LoggerPort) {} + + async withSession( + options: { + binaryPath: string; + args: string[]; + requestTimeoutMs?: number; + totalTimeoutMs?: number; + label: string; + }, + handler: (session: JsonRpcSession) => Promise + ): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS; + + return withTimeout( + this.#runSession(options.binaryPath, options.args, requestTimeoutMs, handler), + totalTimeoutMs, + options.label + ); + } + + async #runSession( + binaryPath: string, + args: string[], + requestTimeoutMs: number, + handler: (session: JsonRpcSession) => Promise + ): Promise { + const child = spawnCli(binaryPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + const lineReader = readline.createInterface({ input: child.stdout! }); + child.stderr?.on('data', () => { + // Keep stderr drained so process warnings do not block the pipe. + }); + + const pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeoutId: ReturnType; + } + >(); + + let nextRequestId = 1; + + const rejectAll = (error: Error): void => { + for (const [id, entry] of pending) { + clearTimeout(entry.timeoutId); + entry.reject(error); + pending.delete(id); + } + }; + + lineReader.on('line', (line) => { + let message: JsonRpcResponse; + try { + message = JSON.parse(line) as JsonRpcResponse; + } catch (error) { + this.logger.warn('json-rpc stdio emitted non-json line', { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + if (typeof message.id !== 'number') { + return; + } + + const entry = pending.get(message.id); + if (!entry) { + return; + } + + clearTimeout(entry.timeoutId); + pending.delete(message.id); + + if (message.error) { + entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error')); + return; + } + + entry.resolve(message.result); + }); + + child.once('error', (error) => { + rejectAll(error instanceof Error ? error : new Error(String(error))); + }); + + child.once('exit', (code, signal) => { + if (pending.size === 0) { + return; + } + + rejectAll( + new Error( + `JSON-RPC process exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'null'})` + ) + ); + }); + + const session: JsonRpcSession = { + request: ( + method: string, + params?: unknown, + timeoutMs = requestTimeoutMs + ): Promise => + new Promise((resolve, reject) => { + if (!child.stdin) { + reject(new Error('JSON-RPC stdin is not available')); + return; + } + + const id = nextRequestId++; + const timeoutId = setTimeout(() => { + pending.delete(id); + reject(new Error(`JSON-RPC request timed out: ${method}`)); + }, timeoutMs); + + pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId }); + + child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => { + if (!error) { + return; + } + + clearTimeout(timeoutId); + pending.delete(id); + reject(error instanceof Error ? error : new Error(String(error))); + }); + }), + + notify: async (method: string, params?: unknown): Promise => { + if (!child.stdin) { + throw new Error('JSON-RPC stdin is not available'); + } + + await new Promise((resolve, reject) => { + child.stdin!.write(`${JSON.stringify({ method, params })}\n`, (error) => { + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + resolve(); + }); + }); + }, + }; + + try { + return await handler(session); + } finally { + rejectAll(new Error('JSON-RPC session closed')); + lineReader.close(); + if (child.stdin && !child.stdin.destroyed) { + child.stdin.end(); + } + killProcessTree(child); + try { + await once(child, 'close'); + } catch { + this.logger.warn('json-rpc close wait failed'); + } + } + } +} diff --git a/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts b/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts new file mode 100644 index 00000000..f7dc39d3 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts @@ -0,0 +1,20 @@ +import { gitIdentityResolver } from '@main/services/parsing/GitIdentityResolver'; + +export interface RecentProjectIdentity { + id: string; + name?: string; +} + +export class RecentProjectIdentityResolver { + async resolve(projectPath: string): Promise { + const identity = await gitIdentityResolver.resolveIdentity(projectPath); + if (!identity) { + return null; + } + + return { + id: identity.id, + name: identity.name, + }; + } +} diff --git a/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts b/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts new file mode 100644 index 00000000..5115440e --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts @@ -0,0 +1,10 @@ +import path from 'path'; + +export function normalizeIdentityPath(projectPath: string): string { + let normalized = path.normalize(projectPath); + while (normalized.length > 1 && (normalized.endsWith('/') || normalized.endsWith('\\'))) { + normalized = normalized.slice(0, -1); + } + + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} diff --git a/src/features/recent-projects/preload/createRecentProjectsBridge.ts b/src/features/recent-projects/preload/createRecentProjectsBridge.ts new file mode 100644 index 00000000..f5494bd9 --- /dev/null +++ b/src/features/recent-projects/preload/createRecentProjectsBridge.ts @@ -0,0 +1,11 @@ +import { + GET_DASHBOARD_RECENT_PROJECTS, + type RecentProjectsElectronApi, +} from '@features/recent-projects/contracts'; +import { ipcRenderer } from 'electron'; + +export function createRecentProjectsBridge(): RecentProjectsElectronApi { + return { + getDashboardRecentProjects: () => ipcRenderer.invoke(GET_DASHBOARD_RECENT_PROJECTS), + }; +} diff --git a/src/features/recent-projects/preload/index.ts b/src/features/recent-projects/preload/index.ts new file mode 100644 index 00000000..c68458b2 --- /dev/null +++ b/src/features/recent-projects/preload/index.ts @@ -0,0 +1 @@ +export { createRecentProjectsBridge } from './createRecentProjectsBridge'; diff --git a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts new file mode 100644 index 00000000..58b0cd79 --- /dev/null +++ b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts @@ -0,0 +1,130 @@ +import { formatProjectPath } from '@renderer/utils/pathDisplay'; +import { normalizePath, type TaskStatusCounts } from '@renderer/utils/pathNormalize'; +import { formatDistanceToNow } from 'date-fns'; + +import { sortDashboardProviderIds } from '../utils/projectDecorations'; + +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { TeamSummary } from '@shared/types'; + +export interface RecentProjectCardModel { + id: string; + project: DashboardRecentProject; + name: string; + formattedPath: string; + lastActivityLabel: string; + providerIds: DashboardRecentProject['providerIds']; + primaryBranch?: string; + taskCounts?: TaskStatusCounts; + tasksLoading: boolean; + activeTeams?: TeamSummary[]; + additionalPathCount: number; + pathSummary?: { + badgeLabel: string; + description: string; + paths: { + label: string; + fullPath: string; + }[]; + }; +} + +interface RecentProjectsSectionAdapterInput { + projects: DashboardRecentProject[]; + taskCountsByProject: Map; + activeTeamsByProject: Map; + tasksLoading: boolean; +} + +function sumTaskCounts( + project: DashboardRecentProject, + taskCountsByProject: Map +): TaskStatusCounts | undefined { + const total = project.associatedPaths.reduce( + (counts, currentPath) => { + const next = taskCountsByProject.get(normalizePath(currentPath)); + if (!next) { + return counts; + } + + return { + pending: counts.pending + next.pending, + inProgress: counts.inProgress + next.inProgress, + completed: counts.completed + next.completed, + }; + }, + { pending: 0, inProgress: 0, completed: 0 } + ); + + return total.pending > 0 || total.inProgress > 0 || total.completed > 0 ? total : undefined; +} + +function collectActiveTeams( + project: DashboardRecentProject, + activeTeamsByProject: Map +): TeamSummary[] | undefined { + const seen = new Set(); + const activeTeams: TeamSummary[] = []; + + for (const projectPath of project.associatedPaths) { + const teams = activeTeamsByProject.get(normalizePath(projectPath)); + if (!teams) { + continue; + } + + for (const team of teams) { + if (seen.has(team.teamName)) { + continue; + } + + seen.add(team.teamName); + activeTeams.push(team); + } + } + + return activeTeams.length > 0 ? activeTeams : undefined; +} + +function buildPathSummary( + project: DashboardRecentProject +): RecentProjectCardModel['pathSummary'] | undefined { + const orderedPaths = [project.primaryPath, ...project.associatedPaths].filter(Boolean); + const uniquePaths = Array.from(new Set(orderedPaths)); + + if (uniquePaths.length <= 1) { + return undefined; + } + + return { + badgeLabel: `${uniquePaths.length} paths`, + description: 'This card merges recent activity from related worktrees and project paths.', + paths: uniquePaths.map((fullPath, index) => ({ + label: index === 0 ? 'Primary path' : `Related path ${index}`, + fullPath, + })), + }; +} + +export function adaptRecentProjectsSection({ + projects, + taskCountsByProject, + activeTeamsByProject, + tasksLoading, +}: RecentProjectsSectionAdapterInput): RecentProjectCardModel[] { + return projects.map((project) => ({ + id: project.id, + project, + name: project.name, + formattedPath: formatProjectPath(project.primaryPath), + lastActivityLabel: formatDistanceToNow(new Date(project.mostRecentActivity), { + addSuffix: true, + }), + providerIds: sortDashboardProviderIds(project.providerIds), + primaryBranch: project.primaryBranch, + taskCounts: sumTaskCounts(project, taskCountsByProject), + tasksLoading, + activeTeams: collectActiveTeams(project, activeTeamsByProject), + additionalPathCount: Math.max(0, project.associatedPaths.length - 1), + pathSummary: buildPathSummary(project), + })); +} diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts new file mode 100644 index 00000000..8f755deb --- /dev/null +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -0,0 +1,128 @@ +import { useCallback } from 'react'; + +import { + type DashboardRecentProject, + type DashboardRecentProjectOpenTarget, +} from '@features/recent-projects/contracts'; +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers'; +import { createLogger } from '@shared/utils/logger'; +import { useShallow } from 'zustand/react/shallow'; + +import { + buildSyntheticRepositoryGroup, + findMatchingWorktree, + type WorktreeMatch, +} from '../utils/navigation'; +import { recordRecentProjectOpenPaths } from '../utils/recentProjectOpenHistory'; + +const logger = createLogger('Feature:RecentProjects:open'); + +export function useOpenRecentProject(): { + openRecentProject: (project: DashboardRecentProject) => Promise; + openProjectPath: (projectPath: string) => Promise; + selectProjectFolder: () => Promise; +} { + const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore( + useShallow((state) => ({ + repositoryGroups: state.repositoryGroups, + fetchRepositoryGroups: state.fetchRepositoryGroups, + openTeamsTab: state.openTeamsTab, + })) + ); + + const navigateToMatch = useCallback( + (match: WorktreeMatch): void => { + useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId)); + void useStore.getState().fetchSessionsInitial(match.worktreeId); + openTeamsTab(); + }, + [openTeamsTab] + ); + + const openSyntheticPath = useCallback( + async (path: string, associatedPaths: readonly string[]): Promise => { + const candidatePaths = associatedPaths.length > 0 ? associatedPaths : [path]; + + const initialMatch = findMatchingWorktree(repositoryGroups, candidatePaths); + if (initialMatch) { + navigateToMatch(initialMatch); + return; + } + + await fetchRepositoryGroups(); + const refreshedGroups = useStore.getState().repositoryGroups; + const refreshedMatch = findMatchingWorktree(refreshedGroups, candidatePaths); + if (refreshedMatch) { + navigateToMatch(refreshedMatch); + return; + } + + await api.config.addCustomProjectPath(path); + + useStore.setState((state) => ({ + repositoryGroups: [buildSyntheticRepositoryGroup(path), ...state.repositoryGroups], + })); + + const encodedId = path.replace(/[/\\]/g, '-'); + navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); + }, + [fetchRepositoryGroups, navigateToMatch, repositoryGroups] + ); + + const openTarget = useCallback( + async ( + target: DashboardRecentProjectOpenTarget, + associatedPaths: readonly string[] + ): Promise => { + if (target.type === 'existing-worktree') { + navigateToMatch({ + repoId: target.repositoryId, + worktreeId: target.worktreeId, + }); + return; + } + + await openSyntheticPath(target.path, associatedPaths); + }, + [navigateToMatch, openSyntheticPath] + ); + + const openRecentProject = useCallback( + async (project: DashboardRecentProject): Promise => { + try { + await openTarget(project.openTarget, project.associatedPaths); + recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]); + } catch (error) { + logger.error('Failed to open recent project', error); + } + }, + [openTarget] + ); + + const openProjectPath = useCallback(async (projectPath: string): Promise => { + try { + await api.openPath(projectPath, projectPath); + } catch (error) { + logger.error('Failed to open project path', error); + } + }, []); + + const selectProjectFolder = useCallback(async (): Promise => { + try { + const selectedPaths = await api.config.selectFolders(); + const selectedPath = selectedPaths[0]; + if (!selectedPath) { + return; + } + + await openSyntheticPath(selectedPath, [selectedPath]); + recordRecentProjectOpenPaths([selectedPath]); + } catch (error) { + logger.error('Failed to select project folder', error); + } + }, [openSyntheticPath]); + + return { openRecentProject, openProjectPath, selectProjectFolder }; +} diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts new file mode 100644 index 00000000..baa2f48b --- /dev/null +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { type DashboardRecentProject } from '@features/recent-projects/contracts'; +import { api, isElectronMode } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize'; +import { useShallow } from 'zustand/react/shallow'; + +import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; +import { + sortRecentProjectsByDisplayPriority, + subscribeRecentProjectOpenHistory, +} from '../utils/recentProjectOpenHistory'; +import { + getRecentProjectsClientSnapshot, + loadRecentProjectsWithClientCache, +} from '../utils/recentProjectsClientCache'; + +import { useOpenRecentProject } from './useOpenRecentProject'; + +import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter'; +import type { TeamSummary } from '@shared/types'; + +const INITIAL_RECENT_PROJECTS = 11; +const LOAD_MORE_STEP = 8; +const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500; +const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000; +const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3; + +function matchesSearch(project: DashboardRecentProject, query: string): boolean { + if (!query) { + return true; + } + + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + return ( + project.name.toLowerCase().includes(normalizedQuery) || + project.primaryPath.toLowerCase().includes(normalizedQuery) || + project.associatedPaths.some((projectPath) => + projectPath.toLowerCase().includes(normalizedQuery) + ) || + project.primaryBranch?.toLowerCase().includes(normalizedQuery) === true + ); +} + +export function useRecentProjectsSection( + searchQuery: string, + maxProjects = INITIAL_RECENT_PROJECTS +): { + cards: RecentProjectCardModel[]; + loading: boolean; + error: string | null; + canLoadMore: boolean; + isElectron: boolean; + loadMore: () => void; + reload: () => Promise; + openRecentProject: (project: DashboardRecentProject) => Promise; + openProjectPath: (projectPath: string) => Promise; + selectProjectFolder: () => Promise; +} { + const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } = + useStore( + useShallow((state) => ({ + globalTasks: state.globalTasks, + globalTasksInitialized: state.globalTasksInitialized, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + teams: state.teams, + })) + ); + const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); + const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); + const [recentProjects, setRecentProjects] = useState( + initialSnapshot?.payload.projects ?? [] + ); + const [recentProjectsDegraded, setRecentProjectsDegraded] = useState( + initialSnapshot?.payload.degraded ?? false + ); + const [degradedRefreshCount, setDegradedRefreshCount] = useState( + initialSnapshot?.payload.degraded ? 1 : 0 + ); + const [loading, setLoading] = useState(initialSnapshot == null); + const [error, setError] = useState(null); + const [visibleProjects, setVisibleProjects] = useState(maxProjects); + const [aliveTeams, setAliveTeams] = useState([]); + const [openHistoryVersion, setOpenHistoryVersion] = useState(0); + const hasFetchedTasksRef = useRef(globalTasksInitialized); + const recentProjectsRef = useRef( + initialSnapshot?.payload.projects ?? [] + ); + + useEffect(() => { + recentProjectsRef.current = recentProjects; + }, [recentProjects]); + + const reload = useCallback(async (options?: { force?: boolean }): Promise => { + const hasVisibleProjects = + recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null; + + if (!hasVisibleProjects) { + setLoading(true); + } + setError(null); + try { + const payload = await loadRecentProjectsWithClientCache( + () => api.getDashboardRecentProjects(), + options + ); + setRecentProjects(payload.projects); + setRecentProjectsDegraded(payload.degraded); + setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0)); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const snapshot = getRecentProjectsClientSnapshot(); + if (snapshot && !snapshot.isStale) { + return; + } + + void reload({ force: snapshot != null }); + }, [reload]); + + useEffect(() => { + if (!recentProjectsDegraded) { + return; + } + + const delayMs = + degradedRefreshCount <= DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT + ? DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS + : DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS; + + const timer = window.setTimeout(() => { + void reload({ force: true }); + }, delayMs); + + return () => { + window.clearTimeout(timer); + }; + }, [degradedRefreshCount, recentProjectsDegraded, reload]); + + useEffect(() => { + if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) { + hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized; + return; + } + + hasFetchedTasksRef.current = true; + void fetchAllTasks(); + }, [fetchAllTasks, globalTasksInitialized, recentProjects.length]); + + useEffect(() => { + let cancelled = false; + + void api.teams + .aliveList() + .then((teamNames) => { + if (!cancelled) { + setAliveTeams(teamNames); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [teams]); + + useEffect(() => { + if (!searchQuery.trim()) { + setVisibleProjects(maxProjects); + } + }, [maxProjects, searchQuery]); + + useEffect( + () => subscribeRecentProjectOpenHistory(() => setOpenHistoryVersion((current) => current + 1)), + [] + ); + + const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); + + const activeTeamsByProject = useMemo(() => { + const aliveSet = new Set(aliveTeams); + const teamsByProject = new Map(); + + for (const team of teams) { + if (!team.projectPath || !aliveSet.has(team.teamName)) { + continue; + } + + const key = normalizePath(team.projectPath); + const existing = teamsByProject.get(key); + if (existing) { + existing.push(team); + } else { + teamsByProject.set(key, [team]); + } + } + + return teamsByProject; + }, [aliveTeams, teams]); + + const decoratedCards = useMemo( + () => + adaptRecentProjectsSection({ + projects: sortRecentProjectsByDisplayPriority(recentProjects), + taskCountsByProject, + activeTeamsByProject, + tasksLoading: globalTasksLoading, + }), + [ + activeTeamsByProject, + globalTasksLoading, + openHistoryVersion, + recentProjects, + taskCountsByProject, + ] + ); + + const filteredCards = useMemo( + () => decoratedCards.filter((card) => matchesSearch(card.project, searchQuery)), + [decoratedCards, searchQuery] + ); + + const cards = useMemo(() => { + if (searchQuery.trim()) { + return filteredCards; + } + + return filteredCards.slice(0, visibleProjects); + }, [filteredCards, searchQuery, visibleProjects]); + + return { + cards, + loading, + error, + canLoadMore: !searchQuery.trim() && filteredCards.length > visibleProjects, + isElectron: isElectronMode(), + loadMore: () => setVisibleProjects((current) => current + LOAD_MORE_STEP), + reload, + openRecentProject, + openProjectPath, + selectProjectFolder, + }; +} diff --git a/src/features/recent-projects/renderer/index.ts b/src/features/recent-projects/renderer/index.ts new file mode 100644 index 00000000..995500fc --- /dev/null +++ b/src/features/recent-projects/renderer/index.ts @@ -0,0 +1,2 @@ +export { RecentProjectsSection } from './ui/RecentProjectsSection'; +export { recordRecentProjectOpenPaths } from './utils/recentProjectOpenHistory'; diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx new file mode 100644 index 00000000..61fdbd3f --- /dev/null +++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx @@ -0,0 +1,221 @@ +import { useMemo, useState } from 'react'; + +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { projectColor } from '@renderer/utils/projectColor'; +import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react'; + +import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter'; + +interface RecentProjectCardProps { + card: RecentProjectCardModel; + onClick: () => void; + onOpenPath: () => void; +} + +export const RecentProjectCard = ({ + card, + onClick, + onOpenPath, +}: Readonly): React.JSX.Element => { + const color = useMemo(() => projectColor(card.name), [card.name]); + const [isHovered, setIsHovered] = useState(false); + + return ( + + ); +}; diff --git a/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx b/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx new file mode 100644 index 00000000..e351eac2 --- /dev/null +++ b/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx @@ -0,0 +1,169 @@ +import { Button } from '@renderer/components/ui/button'; +import { FolderGit2, FolderOpen, Search } from 'lucide-react'; + +import { useRecentProjectsSection } from '../hooks/useRecentProjectsSection'; + +import { RecentProjectCard } from './RecentProjectCard'; + +interface RecentProjectsSectionProps { + searchQuery: string; +} + +const titleWidths = [60, 66, 50, 55, 75, 45, 40, 65]; +const pathWidths = [80, 75, 85, 66, 70, 80, 60, 72]; + +function SelectProjectFolderCard({ + onClick, +}: Readonly<{ + onClick: () => void; +}>): React.JSX.Element { + return ( + + ); +} + +export const RecentProjectsSection = ({ + searchQuery, +}: Readonly): React.JSX.Element => { + const { + cards, + loading, + error, + canLoadMore, + isElectron, + loadMore, + reload, + openRecentProject, + openProjectPath, + selectProjectFolder, + } = useRecentProjectsSection(searchQuery); + + if (loading) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (error && cards.length === 0) { + return ( +
+
+ +
+
+

Failed to load projects

+

{error}

+
+ +
+ ); + } + + if (cards.length === 0 && searchQuery.trim()) { + return ( +
+
+ +
+

No projects found

+

No matches for "{searchQuery}"

+
+ ); + } + + if (cards.length === 0) { + return ( +
+
+ +
+

No recent projects found

+

+ Recent Claude and Codex activity will appear here. +

+
+ ); + } + + return ( +
+
+ {!searchQuery.trim() && isElectron && ( + void selectProjectFolder()} /> + )} + {cards.map((card) => ( + void openRecentProject(card.project)} + onOpenPath={() => void openProjectPath(card.project.primaryPath)} + /> + ))} +
+ + {canLoadMore && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/features/recent-projects/renderer/utils/navigation.ts b/src/features/recent-projects/renderer/utils/navigation.ts new file mode 100644 index 00000000..3dff676a --- /dev/null +++ b/src/features/recent-projects/renderer/utils/navigation.ts @@ -0,0 +1,51 @@ +import { normalizePath } from '@renderer/utils/pathNormalize'; + +import type { RepositoryGroup } from '@renderer/types/data'; + +export interface WorktreeMatch { + repoId: string; + worktreeId: string; +} + +export function findMatchingWorktree( + groups: RepositoryGroup[], + candidatePaths: readonly string[] +): WorktreeMatch | null { + const normalizedPaths = new Set(candidatePaths.map((projectPath) => normalizePath(projectPath))); + + for (const repo of groups) { + for (const worktree of repo.worktrees) { + if (normalizedPaths.has(normalizePath(worktree.path))) { + return { repoId: repo.id, worktreeId: worktree.id }; + } + } + } + + return null; +} + +export function buildSyntheticRepositoryGroup(selectedPath: string): RepositoryGroup { + const encodedId = selectedPath.replace(/[/\\]/g, '-'); + const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath; + const now = Date.now(); + + return { + id: encodedId, + identity: null, + worktrees: [ + { + id: encodedId, + path: selectedPath, + name: folderName, + isMainWorktree: true, + source: 'unknown', + sessions: [], + totalSessions: 0, + createdAt: now, + }, + ], + name: folderName, + mostRecentSession: undefined, + totalSessions: 0, + }; +} diff --git a/src/features/recent-projects/renderer/utils/projectDecorations.ts b/src/features/recent-projects/renderer/utils/projectDecorations.ts new file mode 100644 index 00000000..e22cd68b --- /dev/null +++ b/src/features/recent-projects/renderer/utils/projectDecorations.ts @@ -0,0 +1,11 @@ +import type { DashboardProviderId } from '@features/recent-projects/contracts'; + +const PROVIDER_ORDER: DashboardProviderId[] = ['anthropic', 'codex', 'gemini']; + +export function sortDashboardProviderIds( + providerIds: readonly DashboardProviderId[] +): DashboardProviderId[] { + return [...providerIds].sort( + (left, right) => PROVIDER_ORDER.indexOf(left) - PROVIDER_ORDER.indexOf(right) + ); +} diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts new file mode 100644 index 00000000..25e60cab --- /dev/null +++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts @@ -0,0 +1,263 @@ +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; + +const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history'; +const RECENT_PROJECT_OPEN_HISTORY_EVENT = 'recent-projects:open-history-changed'; +const OPEN_PRIORITY_WINDOW_MS = 1000 * 60 * 60 * 48; +const MAX_HISTORY_ENTRIES = 120; + +interface RecentProjectOpenHistoryEntry { + path: string; + openedAt: number; +} + +interface RecentProjectOpenHistoryState { + version: 1; + entries: RecentProjectOpenHistoryEntry[]; +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function normalizeHistoryPath(projectPath: string): string | null { + let normalizedPath = projectPath.trim().replace(/\\/g, '/'); + if (!normalizedPath) { + return null; + } + if (normalizedPath !== '/' && !/^[A-Za-z]:\/$/.test(normalizedPath)) { + while (normalizedPath.endsWith('/')) { + normalizedPath = normalizedPath.slice(0, -1); + } + } + return normalizedPath; +} + +function foldHistoryPath(projectPath: string): string { + return projectPath.toLowerCase(); +} + +function readHistoryState(): RecentProjectOpenHistoryState { + if (!canUseLocalStorage()) { + return { version: 1, entries: [] }; + } + + try { + const raw = window.localStorage.getItem(RECENT_PROJECT_OPEN_HISTORY_KEY); + if (!raw) { + return { version: 1, entries: [] }; + } + + const parsed = JSON.parse(raw) as Partial; + const entries = Array.isArray(parsed.entries) ? parsed.entries : []; + return { + version: 1, + entries: entries + .filter( + (entry): entry is RecentProjectOpenHistoryEntry => + !!entry && + typeof entry.path === 'string' && + typeof entry.openedAt === 'number' && + Number.isFinite(entry.openedAt) + ) + .map((entry) => ({ + path: entry.path, + openedAt: entry.openedAt, + })), + }; + } catch { + return { version: 1, entries: [] }; + } +} + +function pruneEntries( + entries: readonly RecentProjectOpenHistoryEntry[] +): RecentProjectOpenHistoryEntry[] { + const byPath = new Map(); + + for (const entry of entries) { + const normalizedPath = normalizeHistoryPath(entry.path); + if (!normalizedPath) { + continue; + } + byPath.set(normalizedPath, Math.max(byPath.get(normalizedPath) ?? 0, entry.openedAt)); + } + + return Array.from(byPath.entries()) + .map(([historyPath, openedAt]) => ({ path: historyPath, openedAt })) + .sort((left, right) => right.openedAt - left.openedAt) + .slice(0, MAX_HISTORY_ENTRIES); +} + +function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]): void { + if (!canUseLocalStorage()) { + return; + } + + const nextState: RecentProjectOpenHistoryState = { + version: 1, + entries: pruneEntries(entries), + }; + + try { + window.localStorage.setItem(RECENT_PROJECT_OPEN_HISTORY_KEY, JSON.stringify(nextState)); + window.dispatchEvent(new CustomEvent(RECENT_PROJECT_OPEN_HISTORY_EVENT)); + } catch { + // Best-effort persistence only. + } +} + +interface HistoryLookup { + exact: Map; + folded: Map< + string, + { + openedAt: number; + exactPaths: Set; + } + >; +} + +function createHistoryLookup(): HistoryLookup { + const exact = new Map(); + const folded = new Map }>(); + + for (const entry of readHistoryState().entries) { + const normalizedPath = normalizeHistoryPath(entry.path); + if (!normalizedPath) { + continue; + } + + exact.set(normalizedPath, Math.max(exact.get(normalizedPath) ?? 0, entry.openedAt)); + + const foldedKey = foldHistoryPath(normalizedPath); + const existingFolded = folded.get(foldedKey); + if (existingFolded) { + existingFolded.openedAt = Math.max(existingFolded.openedAt, entry.openedAt); + existingFolded.exactPaths.add(normalizedPath); + } else { + folded.set(foldedKey, { + openedAt: entry.openedAt, + exactPaths: new Set([normalizedPath]), + }); + } + } + + return { exact, folded }; +} + +function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): number { + const normalizedPath = normalizeHistoryPath(projectPath); + if (!normalizedPath) { + return 0; + } + + const exactMatch = lookup.exact.get(normalizedPath); + if (exactMatch != null) { + return exactMatch; + } + + const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath)); + if (!foldedMatch || foldedMatch.exactPaths.size !== 1) { + return 0; + } + + return foldedMatch.openedAt; +} + +function getProjectLastOpenedAtFromLookup( + lookup: HistoryLookup, + project: Pick +): number { + return [project.primaryPath, ...project.associatedPaths].reduce( + (latest, projectPath) => Math.max(latest, resolveHistoryOpenedAt(lookup, projectPath)), + 0 + ); +} + +export function recordRecentProjectOpenPaths( + projectPaths: readonly string[], + openedAt: number = Date.now() +): void { + const normalizedPaths = Array.from( + new Set( + projectPaths + .map((projectPath) => normalizeHistoryPath(projectPath)) + .filter((projectPath): projectPath is string => Boolean(projectPath)) + ) + ); + + if (normalizedPaths.length === 0) { + return; + } + + const existing = readHistoryState().entries; + writeHistoryEntries([ + ...existing, + ...normalizedPaths.map((projectPath) => ({ + path: projectPath, + openedAt, + })), + ]); +} + +export function getRecentProjectLastOpenedAt( + project: Pick +): number { + const historyLookup = createHistoryLookup(); + return getProjectLastOpenedAtFromLookup(historyLookup, project); +} + +export function sortRecentProjectsByDisplayPriority( + projects: readonly DashboardRecentProject[], + now: number = Date.now() +): DashboardRecentProject[] { + const historyLookup = createHistoryLookup(); + + const isPriorityOpen = (openedAt: number): boolean => + openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS; + + return [...projects].sort((left, right) => { + const leftOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, left); + const rightOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, right); + const leftPriority = isPriorityOpen(leftOpenedAt); + const rightPriority = isPriorityOpen(rightOpenedAt); + + if (leftPriority !== rightPriority) { + return leftPriority ? -1 : 1; + } + + if (leftPriority && rightPriority && leftOpenedAt !== rightOpenedAt) { + return rightOpenedAt - leftOpenedAt; + } + + if (left.mostRecentActivity !== right.mostRecentActivity) { + return right.mostRecentActivity - left.mostRecentActivity; + } + + if (leftOpenedAt !== rightOpenedAt) { + return rightOpenedAt - leftOpenedAt; + } + + return left.name.localeCompare(right.name); + }); +} + +export function subscribeRecentProjectOpenHistory(listener: () => void): () => void { + if (typeof window === 'undefined') { + return () => undefined; + } + + const handleChange = (): void => listener(); + window.addEventListener(RECENT_PROJECT_OPEN_HISTORY_EVENT, handleChange); + return () => { + window.removeEventListener(RECENT_PROJECT_OPEN_HISTORY_EVENT, handleChange); + }; +} + +export function resetRecentProjectOpenHistoryForTests(): void { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.removeItem(RECENT_PROJECT_OPEN_HISTORY_KEY); +} diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts new file mode 100644 index 00000000..40cdbf9e --- /dev/null +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -0,0 +1,78 @@ +import { normalizeDashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; + +import type { + DashboardRecentProjectsPayload, + DashboardRecentProjectsPayloadLike, +} from '@features/recent-projects/contracts'; + +const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000; +const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500; + +let cachedPayload: DashboardRecentProjectsPayloadLike = null; +let cachedAt = 0; +let inFlightLoad: Promise | null = null; + +export interface RecentProjectsClientSnapshot { + payload: DashboardRecentProjectsPayload; + fetchedAt: number; + isStale: boolean; +} + +export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null { + const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload); + if (!normalizedPayload) { + return null; + } + + if (cachedPayload !== normalizedPayload) { + cachedPayload = normalizedPayload; + } + + const ttlMs = normalizedPayload.degraded + ? RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS + : RECENT_PROJECTS_CLIENT_CACHE_TTL_MS; + + return { + payload: normalizedPayload, + fetchedAt: cachedAt, + isStale: Date.now() - cachedAt > ttlMs, + }; +} + +export async function loadRecentProjectsWithClientCache( + loader: () => Promise, + options?: { force?: boolean } +): Promise { + const force = options?.force ?? false; + const snapshot = getRecentProjectsClientSnapshot(); + + if (!force && snapshot && !snapshot.isStale) { + return snapshot.payload; + } + + if (inFlightLoad) { + return inFlightLoad; + } + + const request = loader() + .then((payloadLike) => { + const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike); + cachedPayload = normalizedPayload; + cachedAt = Date.now(); + return normalizedPayload ?? { projects: [], degraded: true }; + }) + .finally(() => { + if (inFlightLoad === request) { + inFlightLoad = null; + } + }); + + inFlightLoad = request; + return request; +} + +export function __resetRecentProjectsClientCacheForTests(): void { + cachedPayload = null; + cachedAt = 0; + inFlightLoad = null; +} diff --git a/src/features/tmux-installer/contracts/api.ts b/src/features/tmux-installer/contracts/api.ts new file mode 100644 index 00000000..54be18b6 --- /dev/null +++ b/src/features/tmux-installer/contracts/api.ts @@ -0,0 +1,11 @@ +import type { TmuxInstallerProgress, TmuxInstallerSnapshot, TmuxStatus } from './dto'; + +export interface TmuxAPI { + getStatus: () => Promise; + getInstallerSnapshot: () => Promise; + install: () => Promise; + cancelInstall: () => Promise; + submitInstallerInput: (input: string) => Promise; + invalidateStatus: () => Promise; + onProgress: (callback: (event: unknown, data: TmuxInstallerProgress) => void) => () => void; +} diff --git a/src/features/tmux-installer/contracts/channels.ts b/src/features/tmux-installer/contracts/channels.ts new file mode 100644 index 00000000..0171c493 --- /dev/null +++ b/src/features/tmux-installer/contracts/channels.ts @@ -0,0 +1,7 @@ +export const TMUX_GET_STATUS = 'tmux:getStatus'; +export const TMUX_GET_INSTALLER_SNAPSHOT = 'tmux:getInstallerSnapshot'; +export const TMUX_INSTALL = 'tmux:install'; +export const TMUX_CANCEL_INSTALL = 'tmux:cancelInstall'; +export const TMUX_SUBMIT_INSTALLER_INPUT = 'tmux:submitInstallerInput'; +export const TMUX_INVALIDATE_STATUS = 'tmux:invalidateStatus'; +export const TMUX_INSTALLER_PROGRESS = 'tmux:progress'; diff --git a/src/features/tmux-installer/contracts/dto.ts b/src/features/tmux-installer/contracts/dto.ts new file mode 100644 index 00000000..88bbd776 --- /dev/null +++ b/src/features/tmux-installer/contracts/dto.ts @@ -0,0 +1,109 @@ +export type TmuxPlatform = 'darwin' | 'linux' | 'win32' | 'unknown'; + +export type TmuxInstallStrategy = + | 'homebrew' + | 'macports' + | 'apt' + | 'dnf' + | 'yum' + | 'zypper' + | 'pacman' + | 'wsl' + | 'manual' + | 'unknown'; + +export type TmuxInstallerPhase = + | 'idle' + | 'checking' + | 'preparing' + | 'requesting_privileges' + | 'pending_external_elevation' + | 'waiting_for_external_step' + | 'installing' + | 'verifying' + | 'needs_restart' + | 'needs_manual_step' + | 'completed' + | 'error' + | 'cancelled'; + +export interface TmuxInstallHint { + title: string; + description: string; + command?: string; + url?: string; +} + +export interface TmuxAutoInstallCapability { + supported: boolean; + strategy: TmuxInstallStrategy; + packageManagerLabel?: string | null; + requiresTerminalInput: boolean; + requiresAdmin: boolean; + requiresRestart: boolean; + mayOpenExternalWindow?: boolean; + reasonIfUnsupported?: string | null; + manualHints: TmuxInstallHint[]; +} + +export interface TmuxWslStatus { + wslInstalled: boolean; + rebootRequired: boolean; + distroName: string | null; + distroVersion: 1 | 2 | null; + distroBootstrapped: boolean; + innerPackageManager: TmuxInstallStrategy | null; + tmuxAvailableInsideWsl: boolean; + tmuxVersion: string | null; + tmuxBinaryPath: string | null; + statusDetail: string | null; +} + +export interface TmuxWslPreference { + preferredDistroName: string | null; + source: 'persisted' | 'default' | 'manual' | null; +} + +export interface TmuxBinaryProbe { + available: boolean; + version: string | null; + binaryPath: string | null; + error: string | null; +} + +export interface TmuxEffectiveAvailability { + available: boolean; + location: 'host' | 'wsl' | null; + version: string | null; + binaryPath: string | null; + runtimeReady: boolean; + detail: string | null; +} + +export interface TmuxStatus { + platform: TmuxPlatform; + nativeSupported: boolean; + checkedAt: string; + host: TmuxBinaryProbe; + effective: TmuxEffectiveAvailability; + error: string | null; + autoInstall: TmuxAutoInstallCapability; + wsl?: TmuxWslStatus | null; + wslPreference?: TmuxWslPreference | null; +} + +export interface TmuxInstallerSnapshot { + phase: TmuxInstallerPhase; + strategy: TmuxInstallStrategy | null; + message: string | null; + detail: string | null; + error: string | null; + canCancel: boolean; + acceptsInput: boolean; + inputPrompt: string | null; + inputSecret: boolean; + logs: string[]; + updatedAt: string; +} + +export type TmuxInstallerProgress = TmuxInstallerSnapshot; diff --git a/src/features/tmux-installer/contracts/index.ts b/src/features/tmux-installer/contracts/index.ts new file mode 100644 index 00000000..69f32f5a --- /dev/null +++ b/src/features/tmux-installer/contracts/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; diff --git a/src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts b/src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts new file mode 100644 index 00000000..3c79ee2b --- /dev/null +++ b/src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts @@ -0,0 +1,5 @@ +export interface TmuxInstallerRunnerPort { + install(): Promise; + cancel(): Promise; + submitInput(input: string): Promise; +} diff --git a/src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts b/src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts new file mode 100644 index 00000000..2198ebae --- /dev/null +++ b/src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts @@ -0,0 +1,5 @@ +import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts'; + +export interface TmuxInstallerSnapshotPort { + getSnapshot(): TmuxInstallerSnapshot; +} diff --git a/src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts b/src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts new file mode 100644 index 00000000..50e2d8a6 --- /dev/null +++ b/src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts @@ -0,0 +1,6 @@ +import type { TmuxStatus } from '@features/tmux-installer/contracts'; + +export interface TmuxStatusSourcePort { + getStatus(): Promise; + invalidateStatus(): void; +} diff --git a/src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts b/src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts new file mode 100644 index 00000000..7d16c630 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts @@ -0,0 +1,13 @@ +import type { TmuxInstallerRunnerPort } from '../ports/TmuxInstallerRunnerPort'; + +export class CancelTmuxInstallUseCase { + readonly #runner: TmuxInstallerRunnerPort; + + constructor(runner: TmuxInstallerRunnerPort) { + this.#runner = runner; + } + + execute(): Promise { + return this.#runner.cancel(); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts b/src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts new file mode 100644 index 00000000..2678a4b1 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts @@ -0,0 +1,14 @@ +import type { TmuxInstallerSnapshotPort } from '../ports/TmuxInstallerSnapshotPort'; +import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts'; + +export class GetTmuxInstallerSnapshotUseCase { + readonly #snapshotPort: TmuxInstallerSnapshotPort; + + constructor(snapshotPort: TmuxInstallerSnapshotPort) { + this.#snapshotPort = snapshotPort; + } + + execute(): TmuxInstallerSnapshot { + return this.#snapshotPort.getSnapshot(); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts b/src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts new file mode 100644 index 00000000..915c84e9 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts @@ -0,0 +1,14 @@ +import type { TmuxStatusSourcePort } from '../ports/TmuxStatusSourcePort'; +import type { TmuxStatus } from '@features/tmux-installer/contracts'; + +export class GetTmuxStatusUseCase { + readonly #statusSource: TmuxStatusSourcePort; + + constructor(statusSource: TmuxStatusSourcePort) { + this.#statusSource = statusSource; + } + + execute(): Promise { + return this.#statusSource.getStatus(); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts b/src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts new file mode 100644 index 00000000..b2d1e7c2 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts @@ -0,0 +1,13 @@ +import type { TmuxInstallerRunnerPort } from '../ports/TmuxInstallerRunnerPort'; + +export class InstallTmuxUseCase { + readonly #runner: TmuxInstallerRunnerPort; + + constructor(runner: TmuxInstallerRunnerPort) { + this.#runner = runner; + } + + execute(): Promise { + return this.#runner.install(); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase.ts b/src/features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase.ts new file mode 100644 index 00000000..059d9412 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase.ts @@ -0,0 +1,13 @@ +import type { TmuxInstallerRunnerPort } from '../ports/TmuxInstallerRunnerPort'; + +export class SubmitTmuxInstallerInputUseCase { + readonly #runner: TmuxInstallerRunnerPort; + + constructor(runner: TmuxInstallerRunnerPort) { + this.#runner = runner; + } + + execute(input: string): Promise { + return this.#runner.submitInput(input); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/__tests__/tmuxInstallerUseCases.test.ts b/src/features/tmux-installer/core/application/use-cases/__tests__/tmuxInstallerUseCases.test.ts new file mode 100644 index 00000000..106471ce --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/__tests__/tmuxInstallerUseCases.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CancelTmuxInstallUseCase } from '../CancelTmuxInstallUseCase'; +import { GetTmuxInstallerSnapshotUseCase } from '../GetTmuxInstallerSnapshotUseCase'; +import { GetTmuxStatusUseCase } from '../GetTmuxStatusUseCase'; +import { InstallTmuxUseCase } from '../InstallTmuxUseCase'; + +import type { TmuxInstallerRunnerPort } from '../../ports/TmuxInstallerRunnerPort'; +import type { TmuxInstallerSnapshotPort } from '../../ports/TmuxInstallerSnapshotPort'; +import type { TmuxStatusSourcePort } from '../../ports/TmuxStatusSourcePort'; +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +describe('tmux installer use cases', () => { + it('delegates status loading to the status source port', async () => { + const status: TmuxStatus = { + platform: 'linux', + nativeSupported: true, + checkedAt: new Date().toISOString(), + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'detail', + }, + error: null, + autoInstall: { + supported: true, + strategy: 'apt', + packageManagerLabel: 'APT', + requiresTerminalInput: false, + requiresAdmin: true, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [], + }, + }; + const getStatusMock = vi.fn().mockResolvedValue(status); + const statusSource: TmuxStatusSourcePort = { + getStatus: getStatusMock, + invalidateStatus: vi.fn(), + }; + + const result = await new GetTmuxStatusUseCase(statusSource).execute(); + + expect(result).toBe(status); + expect(getStatusMock).toHaveBeenCalledTimes(1); + }); + + it('delegates install and cancel orchestration to the runner port', async () => { + const installMock = vi.fn().mockResolvedValue(undefined); + const cancelMock = vi.fn().mockResolvedValue(undefined); + const runner: TmuxInstallerRunnerPort = { + install: installMock, + cancel: cancelMock, + submitInput: vi.fn().mockResolvedValue(undefined), + }; + + await new InstallTmuxUseCase(runner).execute(); + await new CancelTmuxInstallUseCase(runner).execute(); + + expect(installMock).toHaveBeenCalledTimes(1); + expect(cancelMock).toHaveBeenCalledTimes(1); + }); + + it('returns the snapshot from the snapshot port unchanged', () => { + const snapshot: TmuxInstallerSnapshot = { + phase: 'installing', + strategy: 'homebrew', + message: 'Installing...', + detail: null, + error: null, + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: ['line 1'], + updatedAt: new Date().toISOString(), + }; + const getSnapshotMock = vi.fn().mockReturnValue(snapshot); + const snapshotPort: TmuxInstallerSnapshotPort = { + getSnapshot: getSnapshotMock, + }; + + const result = new GetTmuxInstallerSnapshotUseCase(snapshotPort).execute(); + + expect(result).toBe(snapshot); + expect(getSnapshotMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxAutoInstallCapability.test.ts b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxAutoInstallCapability.test.ts new file mode 100644 index 00000000..5a62b58a --- /dev/null +++ b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxAutoInstallCapability.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTmuxAutoInstallCapability } from '../buildTmuxAutoInstallCapability'; + +describe('buildTmuxAutoInstallCapability', () => { + it('supports Homebrew installs on macOS without extra terminal input', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'darwin', + strategy: 'homebrew', + packageManagerLabel: 'Homebrew', + nonInteractivePrivilegeAvailable: true, + }); + + expect(capability).toMatchObject({ + supported: true, + strategy: 'homebrew', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + }); + expect(capability.manualHints.some((hint) => hint.command === 'brew install tmux')).toBe(true); + }); + + it('falls back to manual terminal install when sudo cannot run non-interactively', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'linux', + strategy: 'apt', + packageManagerLabel: 'APT', + nonInteractivePrivilegeAvailable: false, + }); + + expect(capability).toMatchObject({ + supported: false, + strategy: 'apt', + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: false, + }); + expect(capability.reasonIfUnsupported).toContain('Administrator privileges are required'); + }); + + it('keeps auto-install enabled when interactive terminal input is available', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'linux', + strategy: 'apt', + packageManagerLabel: 'APT', + nonInteractivePrivilegeAvailable: false, + interactiveTerminalAvailable: true, + }); + + expect(capability).toMatchObject({ + supported: true, + strategy: 'apt', + requiresTerminalInput: true, + requiresAdmin: true, + }); + }); + + it('keeps immutable Linux hosts manual-only in this iteration', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'linux', + strategy: 'apt', + packageManagerLabel: 'APT', + immutableHost: true, + nonInteractivePrivilegeAvailable: true, + }); + + expect(capability).toMatchObject({ + supported: false, + strategy: 'manual', + requiresAdmin: true, + requiresRestart: false, + }); + expect(capability.reasonIfUnsupported).toContain('Immutable Linux hosts'); + }); + + it('marks Windows as a WSL follow-up flow for now', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'win32', + strategy: 'wsl', + packageManagerLabel: 'WSL', + nonInteractivePrivilegeAvailable: false, + }); + + expect(capability).toMatchObject({ + supported: false, + strategy: 'wsl', + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: true, + mayOpenExternalWindow: true, + }); + expect(capability.reasonIfUnsupported).toContain('not wired'); + }); +}); diff --git a/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts new file mode 100644 index 00000000..2f9f3f23 --- /dev/null +++ b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTmuxEffectiveAvailability } from '../buildTmuxEffectiveAvailability'; + +describe('buildTmuxEffectiveAvailability', () => { + it('marks host tmux as runtime-ready on native platforms', () => { + const result = buildTmuxEffectiveAvailability({ + platform: 'linux', + nativeSupported: true, + host: { + available: true, + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + error: null, + }, + wsl: null, + }); + + expect(result.available).toBe(true); + expect(result.location).toBe('host'); + expect(result.runtimeReady).toBe(true); + }); + + it('prefers WSL tmux on Windows when it is available', () => { + const result = buildTmuxEffectiveAvailability({ + platform: 'win32', + nativeSupported: false, + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: 'Ubuntu', + distroVersion: 2, + distroBootstrapped: true, + innerPackageManager: 'apt', + tmuxAvailableInsideWsl: true, + tmuxVersion: 'tmux 3.4', + tmuxBinaryPath: '/usr/bin/tmux', + statusDetail: 'tmux is available in WSL.', + }, + }); + + expect(result.available).toBe(true); + expect(result.location).toBe('wsl'); + expect(result.runtimeReady).toBe(true); + expect(result.version).toBe('tmux 3.4'); + }); + + it('keeps Windows host tmux non-runtime-ready without WSL tmux', () => { + const result = buildTmuxEffectiveAvailability({ + platform: 'win32', + nativeSupported: false, + host: { + available: true, + version: 'tmux 3.4', + binaryPath: 'C:\\tmux.exe', + error: null, + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: 'Ubuntu', + distroVersion: 2, + distroBootstrapped: true, + innerPackageManager: 'apt', + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'tmux is missing in WSL.', + }, + }); + + expect(result.available).toBe(true); + expect(result.location).toBe('host'); + expect(result.runtimeReady).toBe(false); + }); +}); diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts new file mode 100644 index 00000000..1f232e6a --- /dev/null +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts @@ -0,0 +1,192 @@ +import type { + TmuxAutoInstallCapability, + TmuxInstallHint, + TmuxInstallStrategy, + TmuxPlatform, +} from '@features/tmux-installer/contracts'; + +interface BuildTmuxAutoInstallCapabilityInput { + platform: TmuxPlatform; + strategy: TmuxInstallStrategy; + packageManagerLabel?: string | null; + immutableHost?: boolean; + nonInteractivePrivilegeAvailable?: boolean; + interactiveTerminalAvailable?: boolean; +} + +const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing'; +const TMUX_README_URL = 'https://github.com/tmux/tmux/blob/master/README'; +const HOMEBREW_TMUX_URL = 'https://formulae.brew.sh/formula/tmux'; +const MACPORTS_TMUX_URL = 'https://ports.macports.org/port/tmux/'; +const MICROSOFT_WSL_INSTALL_URL = 'https://learn.microsoft.com/en-us/windows/wsl/install'; + +function buildManualHints(platform: TmuxPlatform): TmuxInstallHint[] { + if (platform === 'darwin') { + return [ + { + title: 'Homebrew', + description: 'Recommended install path on macOS.', + command: 'brew install tmux', + }, + { + title: 'MacPorts', + description: 'Alternative macOS package manager.', + command: 'sudo port install tmux', + }, + { + title: 'tmux guide', + description: 'Official installation guide.', + url: OFFICIAL_TMUX_INSTALL_URL, + }, + { title: 'Homebrew', description: 'tmux package page.', url: HOMEBREW_TMUX_URL }, + { title: 'MacPorts', description: 'tmux port page.', url: MACPORTS_TMUX_URL }, + ]; + } + + if (platform === 'linux') { + return [ + { title: 'APT', description: 'Debian/Ubuntu', command: 'sudo apt install tmux' }, + { title: 'DNF', description: 'Fedora/RHEL', command: 'sudo dnf install tmux' }, + { title: 'YUM', description: 'Older RHEL/CentOS', command: 'sudo yum install tmux' }, + { title: 'Zypper', description: 'openSUSE/SLES', command: 'sudo zypper install tmux' }, + { title: 'Pacman', description: 'Arch Linux', command: 'sudo pacman -S tmux' }, + { + title: 'tmux guide', + description: 'Official installation guide.', + url: OFFICIAL_TMUX_INSTALL_URL, + }, + ]; + } + + if (platform === 'win32') { + return [ + { + title: 'Install WSL', + description: 'Install Windows Subsystem for Linux.', + command: 'wsl --install --no-distribution', + }, + { + title: 'Install Ubuntu', + description: 'Recommended WSL distro for the tmux runtime path.', + command: 'wsl --install -d Ubuntu --no-launch', + }, + { + title: 'Install tmux in WSL', + description: 'Run this inside Ubuntu or another Linux distro.', + command: 'sudo apt install tmux', + }, + { title: 'tmux README', description: 'tmux upstream platform notes.', url: TMUX_README_URL }, + { + title: 'tmux guide', + description: 'Official installation guide.', + url: OFFICIAL_TMUX_INSTALL_URL, + }, + { + title: 'Microsoft WSL', + description: 'Official WSL installation docs.', + url: MICROSOFT_WSL_INSTALL_URL, + }, + ]; + } + + return [ + { + title: 'tmux guide', + description: 'Official installation guide.', + url: OFFICIAL_TMUX_INSTALL_URL, + }, + ]; +} + +export function buildTmuxAutoInstallCapability( + input: BuildTmuxAutoInstallCapabilityInput +): TmuxAutoInstallCapability { + const manualHints = buildManualHints(input.platform); + const requiresAdmin = + input.strategy === 'macports' || + input.strategy === 'apt' || + input.strategy === 'dnf' || + input.strategy === 'yum' || + input.strategy === 'zypper' || + input.strategy === 'pacman' || + input.strategy === 'wsl'; + + if (input.platform === 'win32') { + return { + supported: false, + strategy: 'wsl', + packageManagerLabel: 'WSL', + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: true, + mayOpenExternalWindow: true, + reasonIfUnsupported: 'Windows WSL wizard is planned but not wired in this iteration yet.', + manualHints, + }; + } + + if (input.platform === 'linux' && input.immutableHost) { + return { + supported: false, + strategy: 'manual', + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: false, + requiresAdmin: true, + requiresRestart: false, + reasonIfUnsupported: 'Immutable Linux hosts are manual-only in this iteration.', + manualHints, + }; + } + + if (input.strategy === 'manual' || input.strategy === 'unknown') { + return { + supported: false, + strategy: input.strategy, + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: false, + requiresAdmin, + requiresRestart: false, + reasonIfUnsupported: 'No supported package manager was detected for automatic installation.', + manualHints, + }; + } + + if (requiresAdmin && !input.nonInteractivePrivilegeAvailable) { + if (input.interactiveTerminalAvailable) { + return { + supported: true, + strategy: input.strategy, + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: false, + reasonIfUnsupported: null, + manualHints, + }; + } + + return { + supported: false, + strategy: input.strategy, + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: false, + reasonIfUnsupported: + 'Administrator privileges are required. Run the manual install command in a terminal.', + manualHints, + }; + } + + return { + supported: true, + strategy: input.strategy, + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: false, + requiresAdmin, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints, + }; +} diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts new file mode 100644 index 00000000..cb4a0bf0 --- /dev/null +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts @@ -0,0 +1,93 @@ +import type { + TmuxBinaryProbe, + TmuxEffectiveAvailability, + TmuxPlatform, + TmuxWslStatus, +} from '@features/tmux-installer/contracts'; + +interface BuildTmuxEffectiveAvailabilityInput { + platform: TmuxPlatform; + nativeSupported: boolean; + host: TmuxBinaryProbe; + wsl: TmuxWslStatus | null; +} + +export function buildTmuxEffectiveAvailability( + input: BuildTmuxEffectiveAvailabilityInput +): TmuxEffectiveAvailability { + if (input.platform === 'win32') { + if (input.wsl?.tmuxAvailableInsideWsl) { + return { + available: true, + location: 'wsl', + version: input.wsl.tmuxVersion, + binaryPath: input.wsl.tmuxBinaryPath, + runtimeReady: input.wsl.distroBootstrapped, + detail: input.wsl.distroBootstrapped + ? 'tmux is available inside WSL for the persistent teammate runtime.' + : 'tmux is installed inside WSL, but the Linux distro still needs first-launch setup.', + }; + } + + if (input.host.available) { + return { + available: true, + location: 'host', + version: input.host.version, + binaryPath: input.host.binaryPath, + runtimeReady: false, + detail: + 'tmux was found on Windows, but the app currently relies on a WSL-backed tmux runtime for the most reliable teammate path.', + }; + } + + if (!input.wsl?.wslInstalled) { + return { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: + input.wsl?.statusDetail ?? + 'You can keep using the app, but Windows needs WSL before tmux can improve teammate reliability.', + }; + } + + return { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: + input.wsl?.statusDetail ?? + 'WSL is available, but tmux is not ready there yet. Finish the Linux setup, install tmux, then re-check.', + }; + } + + if (input.host.available) { + return { + available: true, + location: 'host', + version: input.host.version, + binaryPath: input.host.binaryPath, + runtimeReady: input.nativeSupported, + detail: 'tmux is available for the persistent teammate runtime.', + }; + } + + return { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: + input.platform === 'darwin' + ? 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.' + : input.platform === 'linux' + ? 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.' + : 'You can keep using the app, but tmux improves persistent teammate reliability.', + }; +} diff --git a/src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts b/src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts new file mode 100644 index 00000000..378daf17 --- /dev/null +++ b/src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts @@ -0,0 +1,84 @@ +import { + TMUX_CANCEL_INSTALL, + TMUX_GET_INSTALLER_SNAPSHOT, + TMUX_GET_STATUS, + TMUX_INSTALL, + TMUX_INVALIDATE_STATUS, + TMUX_SUBMIT_INSTALLER_INPUT, +} from '@features/tmux-installer/contracts'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; + +import type { TmuxInstallerFeatureFacade } from '../../../composition/createTmuxInstallerFeature'; +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; +import type { IpcResult } from '@shared/types'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const logger = createLogger('Feature:tmux-installer:ipc'); + +export function registerTmuxInstallerIpc( + ipcMain: IpcMain, + feature: TmuxInstallerFeatureFacade +): void { + ipcMain.handle( + TMUX_GET_STATUS, + (_event: IpcMainInvokeEvent): Promise> => + withIpcResult(() => feature.getStatus()) + ); + ipcMain.handle( + TMUX_GET_INSTALLER_SNAPSHOT, + (_event: IpcMainInvokeEvent): IpcResult => + withSyncIpcResult(() => feature.getInstallerSnapshot()) + ); + ipcMain.handle( + TMUX_INSTALL, + (_event: IpcMainInvokeEvent): Promise> => withIpcResult(() => feature.install()) + ); + ipcMain.handle( + TMUX_CANCEL_INSTALL, + (_event: IpcMainInvokeEvent): Promise> => + withIpcResult(() => feature.cancelInstall()) + ); + ipcMain.handle( + TMUX_SUBMIT_INSTALLER_INPUT, + (_event: IpcMainInvokeEvent, input: string): Promise> => + withIpcResult(() => feature.submitInstallerInput(input)) + ); + ipcMain.handle( + TMUX_INVALIDATE_STATUS, + (_event: IpcMainInvokeEvent): IpcResult => + withSyncIpcResult(() => { + feature.invalidateStatus(); + return undefined; + }) + ); + logger.info('tmux installer IPC handlers registered'); +} + +export function removeTmuxInstallerIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(TMUX_GET_STATUS); + ipcMain.removeHandler(TMUX_GET_INSTALLER_SNAPSHOT); + ipcMain.removeHandler(TMUX_INSTALL); + ipcMain.removeHandler(TMUX_CANCEL_INSTALL); + ipcMain.removeHandler(TMUX_SUBMIT_INSTALLER_INPUT); + ipcMain.removeHandler(TMUX_INVALIDATE_STATUS); + logger.info('tmux installer IPC handlers removed'); +} + +async function withIpcResult(work: () => Promise): Promise> { + try { + return { success: true, data: await work() }; + } catch (error) { + const message = getErrorMessage(error); + return { success: false, error: message }; + } +} + +function withSyncIpcResult(work: () => T): IpcResult { + try { + return { success: true, data: work() }; + } catch (error) { + const message = getErrorMessage(error); + return { success: false, error: message }; + } +} diff --git a/src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts b/src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts new file mode 100644 index 00000000..64a1c999 --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts @@ -0,0 +1,17 @@ +import { TMUX_INSTALLER_PROGRESS } from '@features/tmux-installer/contracts'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; + +import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts'; +import type { BrowserWindow } from 'electron'; + +export class TmuxInstallerProgressPresenter { + #mainWindow: BrowserWindow | null = null; + + setMainWindow(window: BrowserWindow | null): void { + this.#mainWindow = window; + } + + present(snapshot: TmuxInstallerSnapshot): void { + safeSendToRenderer(this.#mainWindow, TMUX_INSTALLER_PROGRESS, snapshot); + } +} diff --git a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts new file mode 100644 index 00000000..cadabecd --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts @@ -0,0 +1,714 @@ +import { TmuxCommandRunner } from '@features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner'; +import { TmuxInstallStrategyResolver } from '@features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver'; +import { TmuxInstallTerminalSession } from '@features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession'; +import { TmuxWslService } from '@features/tmux-installer/main/infrastructure/wsl/TmuxWslService'; +import { WindowsElevatedStepRunner } from '@features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner'; +import { getErrorMessage } from '@shared/utils/errorHandling'; + +import type { TmuxInstallerProgressPresenter } from '../presenters/TmuxInstallerProgressPresenter'; +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; +import type { TmuxInstallerRunnerPort } from '@features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort'; +import type { TmuxInstallerSnapshotPort } from '@features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort'; +import type { TmuxStatusSourcePort } from '@features/tmux-installer/core/application/ports/TmuxStatusSourcePort'; +import type { TmuxInstallPlan } from '@features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver'; + +const MAX_LOG_LINES = 400; +const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch']; +const RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu'; +const WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS = 2_000; +const WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS = 6; +const RESTART_REQUIRED_PATTERNS = ['restart', 'reboot', 'перезагруз', 'требуется перезагрузка']; + +class TmuxInstallCancelledError extends Error { + constructor() { + super('tmux installation cancelled'); + this.name = 'TmuxInstallCancelledError'; + } +} + +export class TmuxInstallerRunnerAdapter + implements TmuxInstallerRunnerPort, TmuxInstallerSnapshotPort +{ + readonly #statusSource: TmuxStatusSourcePort; + readonly #strategyResolver: TmuxInstallStrategyResolver; + readonly #commandRunner: TmuxCommandRunner; + readonly #terminalSession: TmuxInstallTerminalSession; + readonly #wslService: TmuxWslService; + readonly #windowsElevatedStepRunner: WindowsElevatedStepRunner; + readonly #presenter: TmuxInstallerProgressPresenter; + readonly #sleep: (ms: number) => Promise; + #cancelRequested = false; + #snapshot: TmuxInstallerSnapshot = { + phase: 'idle', + strategy: null, + message: null, + detail: null, + error: null, + canCancel: false, + logs: [], + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + updatedAt: new Date().toISOString(), + }; + + constructor( + statusSource: TmuxStatusSourcePort, + presenter: TmuxInstallerProgressPresenter, + strategyResolver = new TmuxInstallStrategyResolver(), + commandRunner = new TmuxCommandRunner(), + terminalSession = new TmuxInstallTerminalSession(), + wslService = new TmuxWslService(), + windowsElevatedStepRunner = new WindowsElevatedStepRunner(), + sleep: (ms: number) => Promise = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + ) { + this.#statusSource = statusSource; + this.#presenter = presenter; + this.#strategyResolver = strategyResolver; + this.#commandRunner = commandRunner; + this.#terminalSession = terminalSession; + this.#wslService = wslService; + this.#windowsElevatedStepRunner = windowsElevatedStepRunner; + this.#sleep = sleep; + } + + getSnapshot(): TmuxInstallerSnapshot { + return { ...this.#snapshot, logs: [...this.#snapshot.logs] }; + } + + async install(): Promise { + if (this.#snapshot.canCancel) { + throw new Error('tmux installation is already in progress'); + } + this.#cancelRequested = false; + + const currentStatus = await this.#statusSource.getStatus(); + if (currentStatus.effective.runtimeReady) { + this.#setSnapshot({ + phase: 'completed', + strategy: currentStatus.autoInstall.strategy, + message: 'tmux is already installed', + detail: currentStatus.effective.detail, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + resetLogs: true, + }); + return; + } + + if (currentStatus.platform === 'win32') { + await this.#installOnWindows(currentStatus); + return; + } + + const plan = await this.#strategyResolver.resolve(); + if (!plan.capability.supported || !plan.command) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: plan.capability.strategy, + message: 'Automatic install is not available in this environment', + detail: plan.capability.reasonIfUnsupported ?? null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + resetLogs: true, + }); + return; + } + + try { + await this.#runResolvedPlan(plan); + } catch (error) { + if (this.#isCancelledError(error) || this.#cancelRequested) { + return; + } + this.#setSnapshot({ + phase: 'error', + strategy: plan.capability.strategy, + message: 'tmux installation failed', + detail: null, + error: getErrorMessage(error), + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + throw error; + } + } + + async cancel(): Promise { + if (!this.#snapshot.canCancel) { + return; + } + + this.#cancelRequested = true; + this.#commandRunner.cancel(); + this.#terminalSession.cancel(); + this.#setSnapshot({ + phase: 'cancelled', + strategy: this.#snapshot.strategy, + message: 'tmux installation cancelled', + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + } + + async submitInput(input: string): Promise { + if (!this.#snapshot.acceptsInput) { + throw new Error('tmux installer is not waiting for terminal input right now'); + } + + this.#terminalSession.writeLine(input); + } + + async #installOnWindows(currentStatus: TmuxStatus): Promise { + this.#setSnapshot({ + phase: 'preparing', + strategy: 'wsl', + message: 'Preparing the Windows WSL tmux setup...', + detail: + 'The app can keep working without tmux, but WSL-backed tmux gives the most reliable persistent teammate path on Windows.', + error: null, + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + resetLogs: true, + }); + + try { + let status = currentStatus; + + if (!status.wsl?.wslInstalled) { + status = await this.#installWindowsWslCore(status); + } + + if (status.wsl?.rebootRequired) { + this.#setSnapshot({ + phase: 'needs_restart', + strategy: 'wsl', + message: 'Restart Windows before continuing with tmux setup', + detail: + status.wsl.statusDetail ?? + 'WSL was installed, but Windows still needs a restart before a distro and tmux can be configured.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return; + } + + if (!status.wsl?.wslInstalled) { + return; + } + + if (!status.wsl?.distroName) { + status = await this.#installWindowsDistro(); + if (!status.wsl?.distroName) { + return; + } + } + + if (status.wsl?.distroName) { + await this.#wslService.persistPreferredDistro(status.wsl.distroName); + } + + if (!status.wsl?.distroBootstrapped) { + this.#setSnapshot({ + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: `Finish the first Linux setup in ${status.wsl?.distroName ?? 'your WSL distro'}`, + detail: status.wsl?.distroName + ? `Open ${status.wsl.distroName} once, create the Linux user/password, then click Re-check or Install tmux again.` + : 'Open your WSL distro once, finish the initial Linux user setup, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return; + } + + const plan = await this.#strategyResolver.resolve(); + if (!plan.capability.supported || !plan.command) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: plan.capability.strategy, + message: 'Automatic tmux install is not available inside WSL right now', + detail: plan.capability.reasonIfUnsupported ?? status.wsl?.statusDetail ?? null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return; + } + + await this.#runResolvedPlan(plan); + } catch (error) { + if (this.#isCancelledError(error) || this.#cancelRequested) { + return; + } + this.#setSnapshot({ + phase: 'error', + strategy: 'wsl', + message: 'Windows tmux setup failed', + detail: null, + error: getErrorMessage(error), + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + throw error; + } + } + + async #installWindowsWslCore(currentStatus: TmuxStatus): Promise { + this.#appendLog('Starting the elevated WSL core install step...'); + this.#setSnapshot({ + phase: 'pending_external_elevation', + strategy: 'wsl', + message: 'Install WSL', + detail: + 'An administrator PowerShell window may open. Accept it to install the Windows Subsystem for Linux.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + + const elevationResult = await this.#windowsElevatedStepRunner.runWslCoreInstall(); + if (elevationResult.detail) { + this.#appendLog(elevationResult.detail); + } + + const immediateRebootRequired = + elevationResult.restartRequired || this.#looksLikeRestartRequired(elevationResult.detail); + if (immediateRebootRequired) { + const rebootStatus = this.#markWindowsStatusAsRebootRequired( + currentStatus, + elevationResult.detail + ); + this.#statusSource.invalidateStatus(); + this.#setSnapshot({ + phase: 'needs_restart', + strategy: 'wsl', + message: 'Restart Windows before continuing with tmux setup', + detail: + elevationResult.detail ?? + rebootStatus.wsl?.statusDetail ?? + 'WSL was installed, but Windows still needs a restart before tmux setup can continue.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return rebootStatus; + } + + this.#setSnapshot({ + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Checking WSL after the administrator step...', + detail: 'The app is refreshing the WSL status after the elevated install flow.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + const status = await this.#refreshStatus(); + + if (elevationResult.outcome === 'elevated_cancelled' && !status.wsl?.wslInstalled) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: 'wsl', + message: 'WSL install was cancelled', + detail: + 'The administrator step was cancelled before WSL finished installing. Try again or install WSL manually, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + + const rebootRequired = + elevationResult.restartRequired || + status.wsl?.rebootRequired || + this.#looksLikeRestartRequired(elevationResult.detail); + if (rebootRequired) { + const rebootStatus = status.wsl + ? { + ...status, + wsl: { + ...status.wsl, + rebootRequired: true, + statusDetail: elevationResult.detail ?? status.wsl.statusDetail, + }, + } + : status; + this.#setSnapshot({ + phase: 'needs_restart', + strategy: 'wsl', + message: 'Restart Windows before continuing with tmux setup', + detail: + elevationResult.detail ?? + status.wsl?.statusDetail ?? + 'WSL was installed, but Windows still needs a restart before tmux setup can continue.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return rebootStatus; + } + + if (!status.wsl?.wslInstalled) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: 'wsl', + message: 'WSL still is not ready', + detail: + status.wsl?.statusDetail ?? + 'The app could not confirm that WSL is ready after the administrator step. Continue manually from the Microsoft WSL guide, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + } + + return status; + } + + async #installWindowsDistro(): Promise { + const distroCommand = { + command: 'wsl.exe', + args: ['--install', '-d', RECOMMENDED_WSL_DISTRO_NAME, '--no-launch'], + env: process.env, + cwd: process.cwd(), + requiresPty: false, + displayCommand: `wsl --install -d ${RECOMMENDED_WSL_DISTRO_NAME} --no-launch`, + } satisfies NonNullable; + + const fallbackDistroCommand = { + command: 'wsl.exe', + args: ['--install', '--web-download', '-d', RECOMMENDED_WSL_DISTRO_NAME, '--no-launch'], + env: process.env, + cwd: process.cwd(), + requiresPty: false, + displayCommand: `wsl --install --web-download -d ${RECOMMENDED_WSL_DISTRO_NAME} --no-launch`, + } satisfies NonNullable; + + const initialResult = await this.#runCommand({ + ...distroCommand, + }); + if (initialResult.exitCode !== 0) { + this.#appendLog('Retrying WSL distro install with --web-download...'); + const fallbackResult = await this.#runCommand(fallbackDistroCommand); + if (fallbackResult.exitCode !== 0) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: 'wsl', + message: 'Ubuntu install needs a manual WSL step', + detail: + 'The app could not install Ubuntu automatically. Try the Microsoft WSL flow manually, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return this.#refreshStatus(); + } + } + + await this.#wslService.persistPreferredDistro(RECOMMENDED_WSL_DISTRO_NAME); + + this.#setSnapshot({ + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Checking the installed WSL distro...', + detail: + 'If Ubuntu was just installed, it may still need its first Linux user setup before tmux can be installed there.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + const status = await this.#waitForWindowsDistroStatus(); + if (status.wsl?.rebootRequired) { + this.#setSnapshot({ + phase: 'needs_restart', + strategy: 'wsl', + message: 'Restart Windows before continuing with tmux setup', + detail: + status.wsl.statusDetail ?? + 'Windows still needs a restart before the installed WSL distro can be finalized.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + if (!status.wsl?.distroName) { + this.#setSnapshot({ + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Finish Ubuntu setup in WSL', + detail: + 'Ubuntu installation was started, but Windows has not exposed the distro to the app yet. Wait a moment, then click Re-check. If Ubuntu appears in the Start menu, open it once and complete the first Linux user setup.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + return status; + } + + async #waitForWindowsDistroStatus(): Promise { + let status = await this.#refreshStatus(); + for (let attempt = 0; attempt < WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS; attempt += 1) { + if (status.wsl?.distroName || status.wsl?.rebootRequired) { + return status; + } + await this.#sleep(WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS); + status = await this.#refreshStatus(); + } + return status; + } + + #looksLikeRestartRequired(value: string | null | undefined): boolean { + const lowered = value?.toLowerCase() ?? ''; + return RESTART_REQUIRED_PATTERNS.some((pattern) => lowered.includes(pattern)); + } + + #markWindowsStatusAsRebootRequired( + status: TmuxStatus, + detail: string | null | undefined + ): TmuxStatus { + return { + ...status, + autoInstall: { + ...status.autoInstall, + requiresRestart: true, + }, + wsl: { + wslInstalled: status.wsl?.wslInstalled ?? false, + rebootRequired: true, + distroName: status.wsl?.distroName ?? null, + distroVersion: status.wsl?.distroVersion ?? null, + distroBootstrapped: status.wsl?.distroBootstrapped ?? false, + innerPackageManager: status.wsl?.innerPackageManager ?? null, + tmuxAvailableInsideWsl: status.wsl?.tmuxAvailableInsideWsl ?? false, + tmuxVersion: status.wsl?.tmuxVersion ?? null, + tmuxBinaryPath: status.wsl?.tmuxBinaryPath ?? null, + statusDetail: + detail ?? + status.wsl?.statusDetail ?? + 'Windows still needs a restart before tmux setup can continue.', + }, + }; + } + + async #runResolvedPlan(plan: TmuxInstallPlan, resetLogs = true): Promise { + this.#setSnapshot({ + phase: 'preparing', + strategy: plan.capability.strategy, + message: `Preparing ${plan.capability.packageManagerLabel ?? plan.capability.strategy} install...`, + detail: null, + error: null, + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + resetLogs, + }); + + const initialResult = await this.#runCommand(plan.command!); + if ( + initialResult.exitCode !== 0 && + plan.retryWithUpdateCommand && + this.#shouldRetryWithUpdate(this.#snapshot.logs) + ) { + this.#appendLog('Retrying after refreshing package metadata...'); + const updateResult = await this.#runCommand(plan.retryWithUpdateCommand); + if (updateResult.exitCode !== 0) { + throw new Error('Package metadata refresh failed'); + } + const retryResult = await this.#runCommand(plan.command!); + if (retryResult.exitCode !== 0) { + throw new Error('tmux install command failed'); + } + } else if (initialResult.exitCode !== 0) { + throw new Error('tmux install command failed'); + } + + this.#setSnapshot({ + phase: 'verifying', + strategy: plan.capability.strategy, + message: 'Verifying tmux installation...', + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + + const verifiedStatus = await this.#refreshStatus(); + if (!verifiedStatus.effective.runtimeReady) { + throw new Error('tmux verification failed after install'); + } + + if (verifiedStatus.platform === 'win32' && verifiedStatus.wsl?.distroName) { + await this.#wslService.persistPreferredDistro(verifiedStatus.wsl.distroName); + } + + this.#setSnapshot({ + phase: 'completed', + strategy: plan.capability.strategy, + message: 'tmux installed successfully', + detail: verifiedStatus.effective.detail, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + } + + async #runCommand(spec: NonNullable): Promise<{ exitCode: number }> { + if (spec.requiresPty) { + this.#setSnapshot({ + phase: 'requesting_privileges', + strategy: this.#snapshot.strategy, + message: spec.displayCommand ?? [spec.command, ...spec.args].join(' '), + detail: + 'The installer is running in an interactive terminal. Enter your password below if sudo prompts for it.', + error: null, + canCancel: true, + acceptsInput: true, + inputPrompt: 'Enter password if prompted', + inputSecret: true, + }); + const result = await this.#terminalSession.run(spec, { + onLine: (line) => this.#appendLog(line), + }); + this.#throwIfCancelled(); + this.#setSnapshot({ + phase: 'installing', + strategy: this.#snapshot.strategy, + message: spec.displayCommand ?? [spec.command, ...spec.args].join(' '), + detail: 'Interactive install finished. Verifying tmux...', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return result; + } + + this.#setSnapshot({ + phase: 'installing', + strategy: this.#snapshot.strategy, + message: spec.displayCommand ?? [spec.command, ...spec.args].join(' '), + detail: null, + error: null, + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + const result = await this.#commandRunner.run(spec, { + onLine: (line) => this.#appendLog(line), + }); + this.#throwIfCancelled(); + return result; + } + + async #refreshStatus(): Promise { + this.#statusSource.invalidateStatus(); + return this.#statusSource.getStatus(); + } + + #throwIfCancelled(): void { + if (this.#cancelRequested) { + throw new TmuxInstallCancelledError(); + } + } + + #isCancelledError(error: unknown): error is TmuxInstallCancelledError { + return error instanceof TmuxInstallCancelledError; + } + + #shouldRetryWithUpdate(logs: string[]): boolean { + const combined = logs.join('\n').toLowerCase(); + return RETRY_WITH_UPDATE_PATTERNS.some((pattern) => combined.includes(pattern)); + } + + #appendLog(line: string): void { + const nextLogs = [...this.#snapshot.logs, line].slice(-MAX_LOG_LINES); + this.#setSnapshot({ + phase: this.#snapshot.phase, + strategy: this.#snapshot.strategy, + message: this.#snapshot.message, + detail: this.#snapshot.detail, + error: this.#snapshot.error, + canCancel: this.#snapshot.canCancel, + acceptsInput: this.#snapshot.acceptsInput, + inputPrompt: this.#snapshot.inputPrompt, + inputSecret: this.#snapshot.inputSecret, + logs: nextLogs, + }); + } + + #setSnapshot( + next: Omit & + Partial> & { resetLogs?: boolean } + ): void { + this.#snapshot = { + phase: next.phase, + strategy: next.strategy, + message: next.message, + detail: next.detail, + error: next.error, + canCancel: next.canCancel, + acceptsInput: next.acceptsInput, + inputPrompt: next.inputPrompt, + inputSecret: next.inputSecret, + logs: next.resetLogs ? [] : (next.logs ?? this.#snapshot.logs), + updatedAt: new Date().toISOString(), + }; + this.#presenter.present(this.#snapshot); + } +} diff --git a/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts new file mode 100644 index 00000000..e4d1d935 --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts @@ -0,0 +1,644 @@ +import path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { TmuxInstallerRunnerAdapter } from '../TmuxInstallerRunnerAdapter'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +const CHECKED_AT = new Date().toISOString(); + +function createBaseStatus(overrides: Partial = {}): TmuxStatus { + return { + platform: 'linux', + nativeSupported: true, + checkedAt: CHECKED_AT, + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'tmux is not installed yet.', + }, + error: null, + autoInstall: { + supported: true, + strategy: 'apt', + packageManagerLabel: 'APT', + requiresTerminalInput: false, + requiresAdmin: true, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [], + }, + ...overrides, + }; +} + +function createPresenter(): { present: ReturnType } { + return { + present: vi.fn(), + }; +} + +async function waitForSnapshot( + readSnapshot: () => TmuxInstallerSnapshot, + predicate: (snapshot: TmuxInstallerSnapshot) => boolean +): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + const snapshot = readSnapshot(); + if (predicate(snapshot)) { + return snapshot; + } + await Promise.resolve(); + } + + return readSnapshot(); +} + +describe('TmuxInstallerRunnerAdapter', () => { + it('clears stale logs when a later install call exits early as already ready', async () => { + const presenter = createPresenter(); + const initialStatus = createBaseStatus(); + const readyStatus = createBaseStatus({ + host: { + available: true, + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + error: null, + }, + effective: { + available: true, + location: 'host', + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + runtimeReady: true, + detail: 'tmux is available for the persistent teammate runtime.', + }, + }); + let statusCallCount = 0; + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + return statusCallCount === 1 ? initialStatus : readyStatus; + }), + invalidateStatus: vi.fn(), + }; + const commandRunner = { + run: vi.fn(async (_spec, options: { onLine: (line: string) => void }) => { + options.onLine('apt-get could not find tmux'); + return { exitCode: 1 }; + }), + cancel: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => ({ + capability: initialStatus.autoInstall, + command: { + command: 'sudo', + args: ['-n', 'apt-get', 'install', '-y', 'tmux'], + env: process.env, + cwd: process.cwd(), + requiresPty: false, + displayCommand: 'sudo -n apt-get install -y tmux', + }, + retryWithUpdateCommand: null, + })), + } as never, + commandRunner as never + ); + + await expect(runner.install()).rejects.toThrow('tmux install command failed'); + expect(runner.getSnapshot().logs).toContain('apt-get could not find tmux'); + + await expect(runner.install()).resolves.toBeUndefined(); + + const snapshot = runner.getSnapshot(); + expect(snapshot.phase).toBe('completed'); + expect(snapshot.logs).toEqual([]); + }); + + it('preserves leading and trailing spaces when sending installer input', async () => { + const presenter = createPresenter(); + const initialStatus = createBaseStatus(); + const verifiedStatus = createBaseStatus({ + host: { + available: true, + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + error: null, + }, + effective: { + available: true, + location: 'host', + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + runtimeReady: true, + detail: 'tmux is available for the persistent teammate runtime.', + }, + }); + let statusCallCount = 0; + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + return statusCallCount === 1 ? initialStatus : verifiedStatus; + }), + invalidateStatus: vi.fn(), + }; + const strategyResolver = { + resolve: vi.fn(async () => ({ + capability: initialStatus.autoInstall, + command: { + command: 'sudo', + args: ['apt-get', 'install', '-y', 'tmux'], + env: process.env, + cwd: process.cwd(), + requiresPty: true, + displayCommand: 'sudo apt-get install -y tmux', + }, + retryWithUpdateCommand: null, + })), + }; + const resolveTerminalRunRef: { current: ((result: { exitCode: number }) => void) | null } = { + current: null, + }; + const terminalSession = { + run: vi.fn( + () => + new Promise<{ exitCode: number }>((resolve) => { + resolveTerminalRunRef.current = resolve; + }) + ), + writeLine: vi.fn((input: string) => { + resolveTerminalRunRef.current?.({ exitCode: 0 }); + return input; + }), + cancel: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + strategyResolver as never, + { run: vi.fn(), cancel: vi.fn() } as never, + terminalSession as never + ); + + const installPromise = runner.install(); + await Promise.resolve(); + await Promise.resolve(); + + expect(runner.getSnapshot().acceptsInput).toBe(true); + + await runner.submitInput(' secret with spaces '); + await expect(installPromise).resolves.toBeUndefined(); + + expect(terminalSession.writeLine).toHaveBeenCalledWith(' secret with spaces '); + }); + + it('keeps cancelled installs in cancelled state instead of overwriting them with error', async () => { + const presenter = createPresenter(); + const statusSource = { + getStatus: vi.fn(async () => createBaseStatus()), + invalidateStatus: vi.fn(), + }; + const resolveCommandRunRef: { current: ((result: { exitCode: number }) => void) | null } = { + current: null, + }; + const commandRunner = { + run: vi.fn( + () => + new Promise<{ exitCode: number }>((resolve) => { + resolveCommandRunRef.current = resolve; + }) + ), + cancel: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => ({ + capability: createBaseStatus().autoInstall, + command: { + command: 'sudo', + args: ['-n', 'apt-get', 'install', '-y', 'tmux'], + env: process.env, + cwd: process.cwd(), + requiresPty: false, + displayCommand: 'sudo -n apt-get install -y tmux', + }, + retryWithUpdateCommand: null, + })), + } as never, + commandRunner as never + ); + + const installPromise = runner.install(); + await waitForSnapshot( + () => runner.getSnapshot(), + (snapshot) => snapshot.canCancel + ); + await runner.cancel(); + resolveCommandRunRef.current?.({ exitCode: 1 }); + + await expect(installPromise).resolves.toBeUndefined(); + + expect(commandRunner.cancel).toHaveBeenCalledOnce(); + expect(runner.getSnapshot().phase).toBe('cancelled'); + }); + + it('pins Ubuntu as the preferred distro before re-checking after WSL distro install', async () => { + const presenter = createPresenter(); + let preferredDistroName: string | null = null; + let statusCallCount = 0; + const initialStatus = createBaseStatus({ + platform: 'win32', + nativeSupported: false, + autoInstall: { + supported: true, + strategy: 'wsl', + packageManagerLabel: 'WSL', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints: [], + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'No distro is configured yet.', + }, + wslPreference: null, + }); + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + if (statusCallCount === 1) { + return initialStatus; + } + + return createBaseStatus({ + platform: 'win32', + nativeSupported: false, + autoInstall: initialStatus.autoInstall, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: + preferredDistroName === 'Ubuntu' + ? 'Ubuntu still needs its first Linux user setup.' + : 'Debian still needs its first Linux user setup.', + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: preferredDistroName === 'Ubuntu' ? 'Ubuntu' : 'Debian', + distroVersion: 2, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: + preferredDistroName === 'Ubuntu' + ? 'Ubuntu still needs its first Linux user setup.' + : 'Debian still needs its first Linux user setup.', + }, + wslPreference: preferredDistroName + ? { + preferredDistroName, + source: 'persisted', + } + : null, + }); + }), + invalidateStatus: vi.fn(), + }; + const commandRunner = { + run: vi.fn(async () => ({ exitCode: 0 })), + cancel: vi.fn(), + }; + const wslService = { + persistPreferredDistro: vi.fn(async (nextPreferredDistroName: string | null) => { + preferredDistroName = nextPreferredDistroName; + }), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => { + throw new Error('resolve() should not be reached before distro bootstrap completes'); + }), + } as never, + commandRunner as never, + { + run: vi.fn(), + writeLine: vi.fn(), + cancel: vi.fn(), + } as never, + wslService as never, + { + runWslCoreInstall: vi.fn(), + } as never + ); + + await expect(runner.install()).resolves.toBeUndefined(); + + expect(wslService.persistPreferredDistro).toHaveBeenCalledWith('Ubuntu'); + expect(runner.getSnapshot().phase).toBe('waiting_for_external_step'); + expect(runner.getSnapshot().message).toContain('Ubuntu'); + }); + + it('keeps Windows distro install in an external-step state when Ubuntu is still being provisioned', async () => { + const presenter = createPresenter(); + const initialStatus = createBaseStatus({ + platform: 'win32', + nativeSupported: false, + autoInstall: { + supported: true, + strategy: 'wsl', + packageManagerLabel: 'WSL', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints: [], + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'No distro is configured yet.', + }, + wslPreference: null, + }); + const pendingStatus = createBaseStatus({ + platform: 'win32', + nativeSupported: false, + autoInstall: initialStatus.autoInstall, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'WSL is available, but no Linux distribution is installed yet.', + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'WSL is available, but no Linux distribution is installed yet.', + }, + wslPreference: { + preferredDistroName: 'Ubuntu', + source: 'persisted', + }, + }); + let statusCallCount = 0; + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + return statusCallCount === 1 ? initialStatus : pendingStatus; + }), + invalidateStatus: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => { + throw new Error('resolve() should not run while distro is still provisioning'); + }), + } as never, + { + run: vi.fn(async () => ({ exitCode: 0 })), + cancel: vi.fn(), + } as never, + { + run: vi.fn(), + writeLine: vi.fn(), + cancel: vi.fn(), + } as never, + { + persistPreferredDistro: vi.fn(async () => undefined), + } as never, + { + runWslCoreInstall: vi.fn(), + } as never, + async () => undefined + ); + + await expect(runner.install()).resolves.toBeUndefined(); + + expect(statusCallCount).toBeGreaterThan(2); + expect(runner.getSnapshot().phase).toBe('waiting_for_external_step'); + expect(runner.getSnapshot().message).toContain('Finish Ubuntu setup'); + expect(runner.getSnapshot().detail).toContain('Wait a moment, then click Re-check'); + }); + + it('switches Windows WSL setup into needs_restart when the elevated step reports a localized reboot message', async () => { + const presenter = createPresenter(); + const initialStatus = createBaseStatus({ + platform: 'win32', + autoInstall: { + supported: true, + strategy: 'wsl', + packageManagerLabel: 'WSL', + requiresTerminalInput: false, + requiresAdmin: true, + requiresRestart: true, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints: [], + }, + wsl: { + wslInstalled: false, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'WSL is not installed yet.', + }, + }); + const refreshedStatus = createBaseStatus({ + platform: 'win32', + autoInstall: initialStatus.autoInstall, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'WSL is available, but no Linux distribution is installed yet.', + }, + }); + let statusCallCount = 0; + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + return statusCallCount === 1 ? initialStatus : refreshedStatus; + }), + invalidateStatus: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => { + throw new Error('resolve() should not run before restart'); + }), + } as never, + { + run: vi.fn(), + cancel: vi.fn(), + } as never, + { + run: vi.fn(), + writeLine: vi.fn(), + cancel: vi.fn(), + } as never, + { + persistPreferredDistro: vi.fn(async () => undefined), + } as never, + { + runWslCoreInstall: vi.fn(async () => ({ + outcome: 'elevated_succeeded', + detail: 'WSL core installation command completed.', + restartRequired: true, + featureStates: [ + { + featureName: 'VirtualMachinePlatform', + state: 'EnablePending', + restartRequired: 'Possible', + }, + ], + resultFilePath: path.join(process.cwd(), 'tmp', 'result.json'), + })), + } as never + ); + + await expect(runner.install()).resolves.toBeUndefined(); + + expect(statusCallCount).toBe(1); + expect(runner.getSnapshot().phase).toBe('needs_restart'); + expect(runner.getSnapshot().message).toContain('Restart Windows'); + expect(runner.getSnapshot().detail).toContain('WSL core installation command completed'); + }); + + it('switches immediately into needs_restart when the elevated step only returns a localized restart message', async () => { + const presenter = createPresenter(); + const initialStatus = createBaseStatus({ + platform: 'win32', + autoInstall: { + supported: true, + strategy: 'wsl', + packageManagerLabel: 'WSL', + requiresTerminalInput: false, + requiresAdmin: true, + requiresRestart: true, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints: [], + }, + wsl: { + wslInstalled: false, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'WSL is not installed yet.', + }, + }); + const statusSource = { + getStatus: vi.fn(async () => initialStatus), + invalidateStatus: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => { + throw new Error('resolve() should not run before restart'); + }), + } as never, + { + run: vi.fn(), + cancel: vi.fn(), + } as never, + { + run: vi.fn(), + writeLine: vi.fn(), + cancel: vi.fn(), + } as never, + { + persistPreferredDistro: vi.fn(async () => undefined), + } as never, + { + runWslCoreInstall: vi.fn(async () => ({ + outcome: 'elevated_unknown_outcome', + detail: + 'Требуемая операция выполнена успешно. Чтобы сделанные изменения вступили в силу, следует перезагрузить систему.', + restartRequired: false, + featureStates: [], + resultFilePath: null, + })), + } as never + ); + + await expect(runner.install()).resolves.toBeUndefined(); + + expect(statusSource.getStatus).toHaveBeenCalledTimes(1); + expect(runner.getSnapshot().phase).toBe('needs_restart'); + expect(runner.getSnapshot().detail).toContain('перезагрузить систему'); + }); +}); diff --git a/src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts b/src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts new file mode 100644 index 00000000..fd2ae6eb --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts @@ -0,0 +1,268 @@ +import { execFile } from 'node:child_process'; + +import { buildTmuxEffectiveAvailability } from '@features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability'; +import { TmuxInstallStrategyResolver } from '@features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver'; +import { TmuxPackageManagerResolver } from '@features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver'; +import { TmuxPlatformResolver } from '@features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver'; +import { TmuxWslService } from '@features/tmux-installer/main/infrastructure/wsl/TmuxWslService'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; +import { getErrorMessage } from '@shared/utils/errorHandling'; + +import type { + TmuxAutoInstallCapability, + TmuxBinaryProbe, + TmuxStatus, + TmuxWslPreference, + TmuxWslStatus, +} from '@features/tmux-installer/contracts'; +import type { TmuxStatusSourcePort } from '@features/tmux-installer/core/application/ports/TmuxStatusSourcePort'; + +const STATUS_CACHE_TTL_MS = 10_000; + +export class TmuxStatusSourceAdapter implements TmuxStatusSourcePort { + readonly #platformResolver: TmuxPlatformResolver; + readonly #packageManagerResolver: TmuxPackageManagerResolver; + readonly #strategyResolver: TmuxInstallStrategyResolver; + readonly #wslService: TmuxWslService; + #cacheVersion = 0; + #cachedStatus: { value: TmuxStatus; expiresAt: number } | null = null; + #inFlightStatus: Promise | null = null; + + constructor( + platformResolver = new TmuxPlatformResolver(), + packageManagerResolver = new TmuxPackageManagerResolver(), + strategyResolver = new TmuxInstallStrategyResolver(platformResolver, packageManagerResolver), + wslService = new TmuxWslService() + ) { + this.#platformResolver = platformResolver; + this.#packageManagerResolver = packageManagerResolver; + this.#strategyResolver = strategyResolver; + this.#wslService = wslService; + } + + async getStatus(): Promise { + const cachedStatus = this.#cachedStatus; + if (cachedStatus && cachedStatus.expiresAt > Date.now()) { + return this.#cloneStatus(cachedStatus.value); + } + + if (this.#inFlightStatus) { + const status = await this.#inFlightStatus; + return this.#cloneStatus(status); + } + + const cacheVersion = this.#cacheVersion; + const statusPromise = this.#probeStatus() + .then((status) => { + if (cacheVersion === this.#cacheVersion) { + this.#cachedStatus = { + value: status, + expiresAt: Date.now() + STATUS_CACHE_TTL_MS, + }; + } + return status; + }) + .finally(() => { + if (this.#inFlightStatus === statusPromise) { + this.#inFlightStatus = null; + } + }); + + this.#inFlightStatus = statusPromise; + const status = await statusPromise; + return this.#cloneStatus(status); + } + + invalidateStatus(): void { + this.#cacheVersion += 1; + this.#cachedStatus = null; + this.#inFlightStatus = null; + } + + async #probeStatus(): Promise { + const resolvedPlatform = await this.#platformResolver.resolve(); + const checkedAt = new Date().toISOString(); + await resolveInteractiveShellEnv(); + const env = buildEnrichedEnv(); + const plan = await this.#strategyResolver.resolve(); + + const host = await this.#probeHostTmux(env, resolvedPlatform.platform); + const wslProbe = resolvedPlatform.platform === 'win32' ? await this.#wslService.probe() : null; + const effective = buildTmuxEffectiveAvailability({ + platform: resolvedPlatform.platform, + nativeSupported: resolvedPlatform.nativeSupported, + host, + wsl: wslProbe?.status ?? null, + }); + const autoInstall = this.#refineCapabilityForStatus( + resolvedPlatform.platform, + plan.capability, + wslProbe?.status ?? null, + wslProbe?.preference ?? null + ); + + return { + platform: resolvedPlatform.platform, + nativeSupported: resolvedPlatform.nativeSupported, + checkedAt, + host, + effective: { + ...effective, + detail: this.#strategyResolver.buildStatusDetail({ + platform: resolvedPlatform.platform, + effective, + autoInstall, + wsl: wslProbe?.status ?? null, + }), + }, + error: this.#resolveStatusError(host, wslProbe?.status ?? null, effective.available), + autoInstall, + wsl: wslProbe?.status ?? null, + wslPreference: wslProbe?.preference ?? null, + }; + } + + async #probeHostTmux( + env: NodeJS.ProcessEnv, + platform: TmuxStatus['platform'] + ): Promise { + try { + const { stdout, stderr } = await this.#execFileAsync('tmux', ['-V'], env, 3_000); + return { + available: true, + version: (stdout || stderr).trim() || null, + binaryPath: await this.#packageManagerResolver.resolveTmuxBinary(env, platform), + error: null, + }; + } catch (error) { + const missing = + typeof error === 'object' && + error !== null && + 'code' in error && + ((error as { code?: string }).code === 'ENOENT' || + (error as { code?: string }).code === 'ENOEXEC'); + return { + available: false, + version: null, + binaryPath: null, + error: missing ? null : getErrorMessage(error), + }; + } + } + + #resolveStatusError( + host: TmuxBinaryProbe, + wslStatus: TmuxWslStatus | null, + effectiveAvailable: boolean + ): string | null { + if (effectiveAvailable) { + return null; + } + if (wslStatus) { + return host.error ?? null; + } + return host.error ?? null; + } + + #refineCapabilityForStatus( + platform: TmuxStatus['platform'], + capability: TmuxAutoInstallCapability, + wslStatus: TmuxWslStatus | null, + preference: TmuxWslPreference | null + ): TmuxAutoInstallCapability { + if (platform !== 'win32' || capability.strategy !== 'wsl') { + return capability; + } + + const manualHints = [...capability.manualHints]; + const distroName = preference?.preferredDistroName ?? wslStatus?.distroName ?? null; + if (distroName && wslStatus?.innerPackageManager) { + const command = this.#buildWslInstallCommand(distroName, wslStatus.innerPackageManager); + if ( + !manualHints.some( + (hint) => hint.command === command || hint.title === `Install tmux in ${distroName}` + ) + ) { + manualHints.unshift({ + title: `Install tmux in ${distroName}`, + description: 'Run this from PowerShell or Windows Terminal.', + command, + }); + } + } + if (distroName && wslStatus && !wslStatus.distroBootstrapped) { + manualHints.unshift({ + title: `Open ${distroName}`, + description: 'Finish the first Linux user setup inside this WSL distro, then re-check.', + command: `wsl -d ${distroName}`, + }); + } + + return { + ...capability, + requiresRestart: Boolean(wslStatus?.rebootRequired) || capability.requiresRestart, + reasonIfUnsupported: !wslStatus?.wslInstalled + ? 'WSL is not installed yet. Install WSL first, then continue with tmux.' + : !wslStatus.distroName + ? (wslStatus.statusDetail ?? 'WSL is installed, but no Linux distro is configured yet.') + : !wslStatus.distroBootstrapped + ? `${wslStatus.distroName} still needs its first Linux user setup before tmux can be installed there.` + : capability.reasonIfUnsupported, + manualHints, + }; + } + + #buildWslInstallCommand( + distroName: string, + strategy: NonNullable + ): string { + if (strategy === 'apt') { + return `wsl -d ${distroName} -- sh -lc "sudo apt-get install -y tmux"`; + } + if (strategy === 'dnf') { + return `wsl -d ${distroName} -- sh -lc "sudo dnf install -y tmux"`; + } + if (strategy === 'yum') { + return `wsl -d ${distroName} -- sh -lc "sudo yum install -y tmux"`; + } + if (strategy === 'zypper') { + return `wsl -d ${distroName} -- sh -lc "sudo zypper --non-interactive install tmux"`; + } + if (strategy === 'pacman') { + return `wsl -d ${distroName} -- sh -lc "sudo pacman -S --noconfirm tmux"`; + } + return 'wsl -d -- sh -lc "sudo apt-get install -y tmux"'; + } + + #execFileAsync( + command: string, + args: string[], + env: NodeJS.ProcessEnv, + timeout: number + ): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, { env, timeout }, (error, stdout, stderr) => { + if (error) { + reject(error instanceof Error ? error : new Error('tmux status probe failed')); + return; + } + resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); + } + + #cloneStatus(status: TmuxStatus): TmuxStatus { + return { + ...status, + host: { ...status.host }, + effective: { ...status.effective }, + autoInstall: { + ...status.autoInstall, + manualHints: status.autoInstall.manualHints.map((hint) => ({ ...hint })), + }, + wsl: status.wsl ? { ...status.wsl } : status.wsl, + wslPreference: status.wslPreference ? { ...status.wslPreference } : status.wslPreference, + }; + } +} diff --git a/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts b/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts new file mode 100644 index 00000000..8099e5bd --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TmuxStatusSourceAdapter } from '../TmuxStatusSourceAdapter'; + +import type { TmuxAutoInstallCapability, TmuxStatus } from '@features/tmux-installer/contracts'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + execFile: vi.fn(), + }; +}); + +vi.mock('@main/utils/shellEnv', () => ({ + resolveInteractiveShellEnv: vi.fn(async () => {}), +})); + +vi.mock('@main/utils/cliEnv', () => ({ + buildEnrichedEnv: vi.fn(() => ({})), +})); + +const baseCapability: TmuxAutoInstallCapability = { + supported: true, + strategy: 'homebrew', + packageManagerLabel: 'Homebrew', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [], +}; + +describe('TmuxStatusSourceAdapter', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('does not reuse or recache a stale in-flight probe after invalidateStatus()', async () => { + const childProcess = await import('node:child_process'); + type ExecFileCallback = ( + error: Error | null, + stdout: string | Buffer, + stderr: string | Buffer + ) => void; + const firstCallbackRef: { current: ExecFileCallback | null } = { + current: null, + }; + + const execFileMock = vi.mocked(childProcess.execFile); + execFileMock.mockImplementation((( + _command: string, + _args: readonly string[] | null | undefined, + _options: unknown, + callback: ExecFileCallback + ) => { + if (!firstCallbackRef.current) { + firstCallbackRef.current = callback; + return {} as never; + } + + callback(null, 'tmux second\n', ''); + return {} as never; + }) as never); + + const adapter = new TmuxStatusSourceAdapter( + { + resolve: vi.fn(async () => ({ platform: 'darwin', nativeSupported: true })), + } as never, + { + resolveTmuxBinary: vi.fn(async () => '/usr/bin/tmux'), + } as never, + { + resolve: vi.fn(async () => ({ + capability: baseCapability, + command: null, + retryWithUpdateCommand: null, + })), + buildStatusDetail: vi.fn( + ({ effective }: { effective: TmuxStatus['effective'] }) => effective.detail + ), + } as never, + {} as never + ); + + const firstStatusPromise = adapter.getStatus(); + adapter.invalidateStatus(); + const secondStatus = await adapter.getStatus(); + + expect(secondStatus.host.version).toBe('tmux second'); + + firstCallbackRef.current?.(null, 'tmux first\n', ''); + await firstStatusPromise; + await Promise.resolve(); + + const cachedStatus = await adapter.getStatus(); + expect(cachedStatus.host.version).toBe('tmux second'); + expect(execFileMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts b/src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts new file mode 100644 index 00000000..68cac213 --- /dev/null +++ b/src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts @@ -0,0 +1,81 @@ +import { CancelTmuxInstallUseCase } from '@features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase'; +import { GetTmuxInstallerSnapshotUseCase } from '@features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase'; +import { GetTmuxStatusUseCase } from '@features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase'; +import { InstallTmuxUseCase } from '@features/tmux-installer/core/application/use-cases/InstallTmuxUseCase'; +import { SubmitTmuxInstallerInputUseCase } from '@features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase'; + +import { TmuxInstallerProgressPresenter } from '../adapters/output/presenters/TmuxInstallerProgressPresenter'; +import { TmuxInstallerRunnerAdapter } from '../adapters/output/runtime/TmuxInstallerRunnerAdapter'; +import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter'; + +import { invalidateTmuxRuntimeStatusCache } from './runtimeSupport'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; +import type { BrowserWindow } from 'electron'; + +export interface TmuxInstallerFeatureFacade { + getStatus(): Promise; + getInstallerSnapshot(): TmuxInstallerSnapshot; + install(): Promise; + cancelInstall(): Promise; + submitInstallerInput(input: string): Promise; + invalidateStatus(): void; + setMainWindow(window: BrowserWindow | null): void; +} + +class TmuxInstallerFeatureFacadeImpl implements TmuxInstallerFeatureFacade { + readonly #presenter: TmuxInstallerProgressPresenter; + readonly #statusSource: TmuxStatusSourceAdapter; + readonly #runner: TmuxInstallerRunnerAdapter; + readonly #getStatusUseCase: GetTmuxStatusUseCase; + readonly #getSnapshotUseCase: GetTmuxInstallerSnapshotUseCase; + readonly #installUseCase: InstallTmuxUseCase; + readonly #cancelUseCase: CancelTmuxInstallUseCase; + readonly #submitInputUseCase: SubmitTmuxInstallerInputUseCase; + + constructor() { + this.#presenter = new TmuxInstallerProgressPresenter(); + this.#statusSource = new TmuxStatusSourceAdapter(); + this.#runner = new TmuxInstallerRunnerAdapter(this.#statusSource, this.#presenter); + this.#getStatusUseCase = new GetTmuxStatusUseCase(this.#statusSource); + this.#getSnapshotUseCase = new GetTmuxInstallerSnapshotUseCase(this.#runner); + this.#installUseCase = new InstallTmuxUseCase(this.#runner); + this.#cancelUseCase = new CancelTmuxInstallUseCase(this.#runner); + this.#submitInputUseCase = new SubmitTmuxInstallerInputUseCase(this.#runner); + } + + getStatus(): Promise { + return this.#getStatusUseCase.execute(); + } + + getInstallerSnapshot(): TmuxInstallerSnapshot { + return this.#getSnapshotUseCase.execute(); + } + + install(): Promise { + return this.#installUseCase.execute().finally(() => { + invalidateTmuxRuntimeStatusCache(); + }); + } + + cancelInstall(): Promise { + return this.#cancelUseCase.execute(); + } + + submitInstallerInput(input: string): Promise { + return this.#submitInputUseCase.execute(input); + } + + invalidateStatus(): void { + this.#statusSource.invalidateStatus(); + invalidateTmuxRuntimeStatusCache(); + } + + setMainWindow(window: BrowserWindow | null): void { + this.#presenter.setMainWindow(window); + } +} + +export function createTmuxInstallerFeature(): TmuxInstallerFeatureFacade { + return new TmuxInstallerFeatureFacadeImpl(); +} diff --git a/src/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts new file mode 100644 index 00000000..ee6c41dc --- /dev/null +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -0,0 +1,24 @@ +import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter'; +import { TmuxPlatformCommandExecutor } from '../infrastructure/runtime/TmuxPlatformCommandExecutor'; + +const runtimeStatusSource = new TmuxStatusSourceAdapter(); +const runtimeCommandExecutor = new TmuxPlatformCommandExecutor(); + +export async function isTmuxRuntimeReadyForCurrentPlatform(): Promise { + const status = await runtimeStatusSource.getStatus(); + return status.effective.available && status.effective.runtimeReady; +} + +export function invalidateTmuxRuntimeStatusCache(): void { + runtimeStatusSource.invalidateStatus(); +} + +export async function killTmuxPaneForCurrentPlatform(paneId: string): Promise { + await runtimeCommandExecutor.killPane(paneId); + invalidateTmuxRuntimeStatusCache(); +} + +export function killTmuxPaneForCurrentPlatformSync(paneId: string): void { + runtimeCommandExecutor.killPaneSync(paneId); + invalidateTmuxRuntimeStatusCache(); +} diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts new file mode 100644 index 00000000..deddfc10 --- /dev/null +++ b/src/features/tmux-installer/main/index.ts @@ -0,0 +1,12 @@ +export { + registerTmuxInstallerIpc, + removeTmuxInstallerIpc, +} from './adapters/input/ipc/registerTmuxInstallerIpc'; +export type { TmuxInstallerFeatureFacade } from './composition/createTmuxInstallerFeature'; +export { createTmuxInstallerFeature } from './composition/createTmuxInstallerFeature'; +export { + invalidateTmuxRuntimeStatusCache, + isTmuxRuntimeReadyForCurrentPlatform, + killTmuxPaneForCurrentPlatform, + killTmuxPaneForCurrentPlatformSync, +} from './composition/runtimeSupport'; diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts new file mode 100644 index 00000000..46d567be --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts @@ -0,0 +1,114 @@ +import { spawn } from 'node:child_process'; + +import { killProcessTree } from '@main/utils/childProcess'; + +import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput'; + +import type { ChildProcessByStdio } from 'node:child_process'; +import type { Readable } from 'node:stream'; + +export interface TmuxCommandSpec { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd?: string; +} + +interface RunCommandOptions { + onLine: (line: string) => void; +} + +export class TmuxCommandRunner { + #activeChild: ChildProcessByStdio | null = null; + + get activeChild(): ChildProcessByStdio | null { + return this.#activeChild; + } + + async run(spec: TmuxCommandSpec, options: RunCommandOptions): Promise<{ exitCode: number }> { + return new Promise((resolve, reject) => { + const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: spec.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.#activeChild = child; + const platform = process.platform; + + const createBufferedLineWriter = (): { + push: (chunk: Buffer | string) => void; + flush: () => void; + } => { + let pending = ''; + let pendingBytes: Buffer = Buffer.alloc(0); + + const emitLine = (line: string): void => { + const normalizedLine = line.replace(/\r$/, ''); + if (normalizedLine.trim()) { + options.onLine(normalizedLine); + } + }; + + return { + push: (chunk: Buffer | string): void => { + let decodedChunk = ''; + if (typeof chunk === 'string') { + decodedChunk = chunk; + } else { + let nextBuffer = + pendingBytes.length > 0 ? Buffer.concat([pendingBytes, chunk]) : chunk; + if (platform === 'win32' && nextBuffer.length % 2 === 1) { + pendingBytes = nextBuffer.subarray(nextBuffer.length - 1); + nextBuffer = nextBuffer.subarray(0, nextBuffer.length - 1); + } else { + pendingBytes = Buffer.alloc(0); + } + if (nextBuffer.length > 0) { + decodedChunk = decodeInstallerProcessOutput(nextBuffer, platform); + } + } + pending += decodedChunk; + const normalizedPending = pending.replace(/\r(?!\n)/g, '\n'); + const lines = normalizedPending.split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) { + emitLine(line); + } + }, + flush: (): void => { + if (pendingBytes.length > 0) { + pending += decodeInstallerProcessOutput(pendingBytes, platform); + pendingBytes = Buffer.alloc(0); + } + if (!pending) { + return; + } + emitLine(pending.trimEnd()); + pending = ''; + }, + }; + }; + + const stdoutWriter = createBufferedLineWriter(); + const stderrWriter = createBufferedLineWriter(); + + child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(chunk)); + child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(chunk)); + child.on('error', (error) => { + this.#activeChild = null; + reject(error); + }); + child.on('close', (exitCode) => { + stdoutWriter.flush(); + stderrWriter.flush(); + this.#activeChild = null; + resolve({ exitCode: exitCode ?? 0 }); + }); + }); + } + + cancel(): void { + killProcessTree(this.#activeChild); + this.#activeChild = null; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts new file mode 100644 index 00000000..ab99dc20 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts @@ -0,0 +1,443 @@ +import { buildTmuxAutoInstallCapability } from '@features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv'; + +import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver'; +import { TmuxPlatformResolver } from '../platform/TmuxPlatformResolver'; +import { TmuxWslService } from '../wsl/TmuxWslService'; + +import { TmuxInstallTerminalSession } from './TmuxInstallTerminalSession'; + +import type { + TmuxAutoInstallCapability, + TmuxEffectiveAvailability, + TmuxInstallStrategy, + TmuxWslStatus, +} from '@features/tmux-installer/contracts'; + +export interface TmuxInstallPlan { + capability: TmuxAutoInstallCapability; + command: { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; + requiresPty: boolean; + displayCommand?: string | null; + } | null; + retryWithUpdateCommand: { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; + requiresPty: boolean; + displayCommand?: string | null; + } | null; +} + +export class TmuxInstallStrategyResolver { + readonly #platformResolver: TmuxPlatformResolver; + readonly #packageManagerResolver: TmuxPackageManagerResolver; + readonly #wslService: TmuxWslService; + + constructor( + platformResolver = new TmuxPlatformResolver(), + packageManagerResolver = new TmuxPackageManagerResolver(), + wslService = new TmuxWslService() + ) { + this.#platformResolver = platformResolver; + this.#packageManagerResolver = packageManagerResolver; + this.#wslService = wslService; + } + + async resolve(): Promise { + await resolveInteractiveShellEnv(); + const env = buildEnrichedEnv(); + const cwd = getShellPreferredHome(); + const resolvedPlatform = await this.#platformResolver.resolve(); + + if (resolvedPlatform.platform === 'darwin') { + const manager = await this.#packageManagerResolver.resolveForMac(env); + const canRunNonInteractiveSudo = + manager.strategy === 'macports' + ? await this.#packageManagerResolver.canRunNonInteractiveSudo(env) + : true; + const interactiveTerminalAvailable = TmuxInstallTerminalSession.isSupported(); + const capability = buildTmuxAutoInstallCapability({ + platform: resolvedPlatform.platform, + strategy: manager.strategy, + packageManagerLabel: manager.label, + nonInteractivePrivilegeAvailable: canRunNonInteractiveSudo, + interactiveTerminalAvailable, + }); + return { + capability, + command: this.#buildCommand(manager.strategy, env, cwd, { + requiresPty: manager.strategy === 'macports' && !canRunNonInteractiveSudo, + }), + retryWithUpdateCommand: null, + }; + } + + if (resolvedPlatform.platform === 'linux') { + const manager = await this.#packageManagerResolver.resolveForLinux( + env, + resolvedPlatform.linux + ); + const canRunNonInteractiveSudo = + manager.strategy === 'manual' + ? false + : await this.#packageManagerResolver.canRunNonInteractiveSudo(env); + const interactiveTerminalAvailable = TmuxInstallTerminalSession.isSupported(); + const capability = buildTmuxAutoInstallCapability({ + platform: resolvedPlatform.platform, + strategy: manager.strategy, + packageManagerLabel: manager.label, + immutableHost: resolvedPlatform.linux?.immutableHost ?? false, + nonInteractivePrivilegeAvailable: canRunNonInteractiveSudo, + interactiveTerminalAvailable, + }); + return { + capability, + command: this.#buildCommand(manager.strategy, env, cwd, { + requiresPty: manager.strategy !== 'manual' && !canRunNonInteractiveSudo, + }), + retryWithUpdateCommand: + manager.strategy === 'apt' && canRunNonInteractiveSudo + ? { + command: 'sudo', + args: ['-n', 'apt-get', 'update'], + env, + cwd, + requiresPty: false, + } + : null, + }; + } + + if (resolvedPlatform.platform === 'win32') { + const wslProbe = await this.#wslService.probe(); + const interactiveTerminalAvailable = TmuxInstallTerminalSession.isSupported(); + if ( + wslProbe.status.wslInstalled && + !wslProbe.status.rebootRequired && + wslProbe.status.distroBootstrapped && + wslProbe.status.distroName && + wslProbe.status.innerPackageManager && + interactiveTerminalAvailable + ) { + return { + capability: this.#buildWindowsCapability(wslProbe.status, interactiveTerminalAvailable), + command: this.#buildWslCommand( + wslProbe.status.distroName, + wslProbe.status.innerPackageManager, + env, + cwd + ), + retryWithUpdateCommand: null, + }; + } + + return { + capability: this.#buildWindowsCapability(wslProbe.status, interactiveTerminalAvailable), + command: null, + retryWithUpdateCommand: null, + }; + } + + const capability = buildTmuxAutoInstallCapability({ + platform: resolvedPlatform.platform, + strategy: 'manual', + packageManagerLabel: null, + nonInteractivePrivilegeAvailable: false, + }); + return { + capability, + command: null, + retryWithUpdateCommand: null, + }; + } + + buildStatusDetail(input: { + platform: 'darwin' | 'linux' | 'win32' | 'unknown'; + effective: TmuxEffectiveAvailability; + autoInstall: TmuxAutoInstallCapability; + wsl: TmuxWslStatus | null; + }): string | null { + if (input.effective.detail) { + return input.effective.detail; + } + + if (input.effective.available) { + return input.effective.location === 'wsl' + ? 'tmux is available inside WSL on Windows.' + : 'tmux is available for persistent teammate runtime.'; + } + + if (input.platform === 'darwin') { + return 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.'; + } + if (input.platform === 'linux') { + return 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.'; + } + if (input.platform === 'win32') { + return ( + input.wsl?.statusDetail ?? + 'You can keep using the app, but tmux on Windows goes through WSL for the best teammate experience.' + ); + } + return 'You can keep using the app, but tmux improves persistent teammate reliability.'; + } + + #buildCommand( + strategy: TmuxInstallStrategy, + env: NodeJS.ProcessEnv, + cwd: string, + options: { requiresPty: boolean } + ): TmuxInstallPlan['command'] { + if (strategy === 'homebrew') { + return { command: 'brew', args: ['install', 'tmux'], env, cwd, requiresPty: false }; + } + if (strategy === 'macports') { + return { + command: 'sudo', + args: options.requiresPty ? ['port', 'install', 'tmux'] : ['-n', 'port', 'install', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'apt') { + return { + command: 'sudo', + args: options.requiresPty + ? ['apt-get', 'install', '-y', 'tmux'] + : ['-n', 'apt-get', 'install', '-y', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'dnf') { + return { + command: 'sudo', + args: options.requiresPty + ? ['dnf', 'install', '-y', 'tmux'] + : ['-n', 'dnf', 'install', '-y', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'yum') { + return { + command: 'sudo', + args: options.requiresPty + ? ['yum', 'install', '-y', 'tmux'] + : ['-n', 'yum', 'install', '-y', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'zypper') { + return { + command: 'sudo', + args: options.requiresPty + ? ['zypper', '--non-interactive', 'install', 'tmux'] + : ['-n', 'zypper', '--non-interactive', 'install', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'pacman') { + return { + command: 'sudo', + args: options.requiresPty + ? ['pacman', '-S', '--noconfirm', 'tmux'] + : ['-n', 'pacman', '-S', '--noconfirm', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + return null; + } + + #buildWslCommand( + distroName: string, + strategy: TmuxInstallStrategy, + env: NodeJS.ProcessEnv, + cwd: string + ): TmuxInstallPlan['command'] { + return { + command: 'wsl.exe', + args: ['-d', distroName, '--', 'sh', '-lc', this.#buildWslInstallShellCommand(strategy)], + env, + cwd, + requiresPty: true, + displayCommand: this.#buildWslDisplayCommand(distroName, strategy), + }; + } + + #buildWslInstallShellCommand(strategy: TmuxInstallStrategy): string { + if (strategy === 'apt') { + return 'sudo apt-get install -y tmux'; + } + if (strategy === 'dnf') { + return 'sudo dnf install -y tmux'; + } + if (strategy === 'yum') { + return 'sudo yum install -y tmux'; + } + if (strategy === 'zypper') { + return 'sudo zypper --non-interactive install tmux'; + } + if (strategy === 'pacman') { + return 'sudo pacman -S --noconfirm tmux'; + } + return 'sudo apt-get install -y tmux'; + } + + #buildWslDisplayCommand(distroName: string, strategy: TmuxInstallStrategy): string { + return `wsl -d ${distroName} -- sh -lc "${this.#buildWslInstallShellCommand(strategy)}"`; + } + + #buildWindowsCapability( + status: TmuxWslStatus, + interactiveTerminalAvailable: boolean + ): TmuxAutoInstallCapability { + const baseCapability = buildTmuxAutoInstallCapability({ + platform: 'win32', + strategy: 'wsl', + packageManagerLabel: 'WSL', + nonInteractivePrivilegeAvailable: false, + interactiveTerminalAvailable, + }); + const manualHints = [...baseCapability.manualHints]; + + if (status.distroName && status.innerPackageManager) { + this.#prependUniqueHint(manualHints, { + title: `Install tmux in ${status.distroName}`, + description: + 'The app can run this inside WSL and forward Linux terminal input if sudo prompts for the distro password.', + command: this.#buildWslDisplayCommand(status.distroName, status.innerPackageManager), + }); + } + + if (status.wslInstalled && !status.distroName) { + this.#prependUniqueHint(manualHints, { + title: 'Install Ubuntu', + description: 'Recommended WSL distro for the tmux runtime path.', + command: 'wsl --install -d Ubuntu --no-launch', + }); + } + + if (!status.wslInstalled) { + return { + ...baseCapability, + supported: true, + requiresAdmin: true, + requiresRestart: false, + requiresTerminalInput: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints, + }; + } + + if (status.rebootRequired) { + return { + ...baseCapability, + supported: false, + requiresAdmin: false, + requiresRestart: true, + mayOpenExternalWindow: false, + reasonIfUnsupported: + 'WSL was installed, but Windows still needs a restart before tmux setup can continue.', + manualHints, + }; + } + + if (!status.distroName) { + return { + ...baseCapability, + supported: true, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints, + }; + } + + if (!status.distroBootstrapped) { + return { + ...baseCapability, + supported: false, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: `${status.distroName} still needs its first Linux user setup before tmux can be installed there.`, + manualHints, + }; + } + + if (!status.innerPackageManager) { + return { + ...baseCapability, + supported: false, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: `${status.distroName} is available in WSL, but the app could not determine its package manager.`, + manualHints, + }; + } + + if (!interactiveTerminalAvailable) { + return { + ...baseCapability, + supported: false, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: true, + mayOpenExternalWindow: false, + reasonIfUnsupported: + 'Interactive installer terminal support is unavailable in this build, so WSL tmux install must be finished manually.', + manualHints, + }; + } + + return { + ...baseCapability, + supported: true, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: true, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints, + }; + } + + #prependUniqueHint( + manualHints: TmuxAutoInstallCapability['manualHints'], + nextHint: TmuxAutoInstallCapability['manualHints'][number] + ): void { + if ( + manualHints.some( + (hint) => + hint.title === nextHint.title || + (hint.command && nextHint.command && hint.command === nextHint.command) + ) + ) { + return; + } + manualHints.unshift(nextHint); + } +} diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts new file mode 100644 index 00000000..791cb393 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@shared/utils/logger'; + +import type { TmuxCommandSpec } from './TmuxCommandRunner'; +import type { IPty } from 'node-pty'; +import type * as NodePty from 'node-pty'; + +const logger = createLogger('Feature:tmux-installer:pty'); + +type NodePtyModule = typeof NodePty; + +let nodePty: NodePtyModule | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon + nodePty = require('node-pty') as NodePtyModule; +} catch { + logger.warn('node-pty not available - interactive tmux installer terminal input disabled'); +} + +interface RunTerminalOptions { + onLine: (line: string) => void; + onChunk?: (chunk: string) => void; +} + +export class TmuxInstallTerminalSession { + #pty: IPty | null = null; + + static isSupported(): boolean { + return nodePty !== null; + } + + async run(spec: TmuxCommandSpec, options: RunTerminalOptions): Promise<{ exitCode: number }> { + if (!nodePty) { + throw new Error('Interactive tmux installer terminal is unavailable in this build.'); + } + + return new Promise((resolve) => { + const pty = nodePty.spawn(spec.command, spec.args, { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: spec.cwd, + env: spec.env as Record, + }); + this.#pty = pty; + + let pending = ''; + const emitLine = (line: string): void => { + const normalized = line.replace(/\r$/, ''); + if (normalized.trim()) { + options.onLine(normalized); + } + }; + + pty.onData((chunk) => { + options.onChunk?.(chunk); + pending += chunk; + const normalizedPending = pending.replace(/\r/g, '\n'); + const lines = normalizedPending.split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) { + emitLine(line); + } + }); + pty.onExit(({ exitCode }) => { + if (pending.trim()) { + emitLine(pending.trimEnd()); + } + this.#pty = null; + resolve({ exitCode }); + }); + }); + } + + writeLine(input: string): void { + if (!this.#pty) { + throw new Error('Interactive tmux installer terminal is not running.'); + } + this.#pty.write(`${input}\r`); + } + + cancel(): void { + if (!this.#pty) { + return; + } + this.#pty.kill(); + this.#pty = null; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts b/src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts new file mode 100644 index 00000000..48e71cca --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts @@ -0,0 +1,123 @@ +import { execFile } from 'node:child_process'; + +import type { LinuxPlatformInfo } from './TmuxPlatformResolver'; +import type { TmuxInstallStrategy } from '@features/tmux-installer/contracts'; + +interface ResolveBinaryResult { + path: string | null; + label: string | null; + strategy: TmuxInstallStrategy; +} + +export class TmuxPackageManagerResolver { + async resolveForMac(env: NodeJS.ProcessEnv): Promise { + const brewPath = await this.#resolveBinary('brew', env); + if (brewPath) { + return { path: brewPath, label: 'Homebrew', strategy: 'homebrew' }; + } + + const portPath = await this.#resolveBinary('port', env); + if (portPath) { + return { path: portPath, label: 'MacPorts', strategy: 'macports' }; + } + + return { path: null, label: null, strategy: 'manual' }; + } + + async resolveForLinux( + env: NodeJS.ProcessEnv, + linuxInfo: LinuxPlatformInfo | null + ): Promise { + const preferredStrategies: { + binary: string; + label: string; + strategy: TmuxInstallStrategy; + }[] = + linuxInfo?.distroId === 'arch' + ? [{ binary: 'pacman', label: 'Pacman', strategy: 'pacman' }] + : linuxInfo?.distroId === 'fedora' + ? [{ binary: 'dnf', label: 'DNF', strategy: 'dnf' }] + : linuxInfo?.distroId === 'opensuse-tumbleweed' || + linuxInfo?.distroId === 'opensuse-leap' || + linuxInfo?.distroId === 'sles' + ? [{ binary: 'zypper', label: 'Zypper', strategy: 'zypper' }] + : [{ binary: 'apt-get', label: 'APT', strategy: 'apt' }]; + + const candidates = [ + ...preferredStrategies, + { binary: 'apt-get', label: 'APT', strategy: 'apt' as const }, + { binary: 'dnf', label: 'DNF', strategy: 'dnf' as const }, + { binary: 'yum', label: 'YUM', strategy: 'yum' as const }, + { binary: 'zypper', label: 'Zypper', strategy: 'zypper' as const }, + { binary: 'pacman', label: 'Pacman', strategy: 'pacman' as const }, + ]; + + for (const candidate of candidates) { + const binaryPath = await this.#resolveBinary(candidate.binary, env); + if (binaryPath) { + return { path: binaryPath, label: candidate.label, strategy: candidate.strategy }; + } + } + + return { path: null, label: null, strategy: 'manual' }; + } + + async resolveTmuxBinary( + env: NodeJS.ProcessEnv, + platform: 'darwin' | 'linux' | 'win32' | 'unknown' + ): Promise { + const locator = platform === 'win32' ? 'where' : 'which'; + return this.#resolveBinaryWithLocator(locator, 'tmux', env); + } + + async canRunNonInteractiveSudo(env: NodeJS.ProcessEnv): Promise { + try { + await this.#execFileAsync('sudo', ['-n', 'true'], env, 2_000); + return true; + } catch { + return false; + } + } + + async #resolveBinary(command: string, env: NodeJS.ProcessEnv): Promise { + return this.#resolveBinaryWithLocator( + process.platform === 'win32' ? 'where' : 'which', + command, + env + ); + } + + async #resolveBinaryWithLocator( + locator: string, + command: string, + env: NodeJS.ProcessEnv + ): Promise { + try { + const { stdout } = await this.#execFileAsync(locator, [command], env, 2_000); + const firstLine = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return firstLine ?? null; + } catch { + return null; + } + } + + #execFileAsync( + command: string, + args: string[], + env: NodeJS.ProcessEnv, + timeout: number + ): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, { env, timeout }, (error, stdout, stderr) => { + if (error) { + reject(error instanceof Error ? error : new Error(`Failed to run locator ${command}`)); + return; + } + resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); + } +} diff --git a/src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts b/src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts new file mode 100644 index 00000000..d691caf3 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts @@ -0,0 +1,72 @@ +import { promises as fsp } from 'node:fs'; + +import type { TmuxPlatform } from '@features/tmux-installer/contracts'; + +export interface LinuxPlatformInfo { + distroId: string | null; + immutableHost: boolean; +} + +export interface ResolvedTmuxPlatform { + platform: TmuxPlatform; + nativeSupported: boolean; + linux: LinuxPlatformInfo | null; +} + +export class TmuxPlatformResolver { + async resolve(): Promise { + const platform = this.#mapPlatform(process.platform); + if (platform !== 'linux') { + return { + platform, + nativeSupported: platform === 'darwin', + linux: null, + }; + } + + return { + platform, + nativeSupported: true, + linux: await this.#resolveLinuxInfo(), + }; + } + + #mapPlatform(platform: NodeJS.Platform): TmuxPlatform { + if (platform === 'darwin' || platform === 'linux' || platform === 'win32') { + return platform; + } + return 'unknown'; + } + + async #resolveLinuxInfo(): Promise { + let distroId: string | null = null; + try { + const content = await fsp.readFile('/etc/os-release', 'utf8'); + distroId = + content + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('ID=')) + ?.slice(3) + .replace(/(^"|"$)/g, '') ?? null; + } catch { + distroId = null; + } + + const immutableHost = + (await this.#exists('/run/ostree-booted')) || + (await this.#exists('/usr/bin/rpm-ostree')) || + distroId === 'opensuse-microos'; + + return { distroId, immutableHost }; + } + + async #exists(path: string): Promise { + try { + await fsp.access(path); + return true; + } catch { + return false; + } + } +} diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts new file mode 100644 index 00000000..948c86ba --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -0,0 +1,109 @@ +import { execFile, execFileSync } from 'node:child_process'; + +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; + +import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver'; +import { TmuxWslService } from '../wsl/TmuxWslService'; + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export class TmuxPlatformCommandExecutor { + readonly #wslService: TmuxWslService; + readonly #packageManagerResolver: TmuxPackageManagerResolver; + + constructor( + wslService = new TmuxWslService(), + packageManagerResolver = new TmuxPackageManagerResolver() + ) { + this.#wslService = wslService; + this.#packageManagerResolver = packageManagerResolver; + } + + async execTmux(args: string[], timeout = 5_000): Promise { + if (process.platform === 'win32') { + return this.#wslService.execTmux(args, null, timeout); + } + + await resolveInteractiveShellEnv(); + const env = buildEnrichedEnv(); + const executable = await this.#resolveNativeTmuxExecutable(env); + return new Promise((resolve) => { + execFile(executable, args, { env, timeout }, (error, stdout, stderr) => { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + resolve({ + exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, + stdout: String(stdout), + stderr: String(stderr) || (error instanceof Error ? error.message : ''), + }); + }); + }); + } + + async killPane(paneId: string): Promise { + const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000); + if (result.exitCode !== 0) { + throw new Error(result.stderr || `Failed to kill tmux pane ${paneId}`); + } + } + + killPaneSync(paneId: string): void { + if (process.platform === 'win32') { + const preferredDistro = this.#wslService.getPersistedPreferredDistroSync(); + const candidates = this.#getWslExecutableCandidates(); + let lastError: Error | null = null; + const distroAttempts = preferredDistro ? [preferredDistro, null] : [null]; + for (const distroName of distroAttempts) { + for (const executable of candidates) { + try { + execFileSync( + executable, + [...(distroName ? ['-d', distroName] : []), '-e', 'tmux', 'kill-pane', '-t', paneId], + { + stdio: 'ignore', + windowsHide: true, + } + ); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + } + throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`); + } + + // eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used + execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' }); + } + + #getWslExecutableCandidates(): string[] { + const candidates = new Set(); + const windir = process.env.WINDIR; + if (windir) { + candidates.add(`${windir}\\System32\\wsl.exe`); + candidates.add(`${windir}\\Sysnative\\wsl.exe`); + } + candidates.add('wsl.exe'); + return [...candidates]; + } + + async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise { + const platform = + process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32' + ? process.platform + : 'unknown'; + const executable = await this.#packageManagerResolver.resolveTmuxBinary(env, platform); + if (!executable) { + throw new Error('tmux executable could not be resolved for the current platform.'); + } + return executable; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts new file mode 100644 index 00000000..314fdbdc --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + execFile: vi.fn(), + execFileSync: vi.fn(), + }; +}); + +import * as childProcess from 'node:child_process'; + +import { TmuxPlatformCommandExecutor } from '../TmuxPlatformCommandExecutor'; + +function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + writable: true, + }); +} + +const originalPlatform = process.platform; +const originalWindir = process.env.WINDIR; + +describe('TmuxPlatformCommandExecutor', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + setPlatform(originalPlatform); + if (originalWindir === undefined) { + delete process.env.WINDIR; + } else { + process.env.WINDIR = originalWindir; + } + }); + + it('falls back to plain wsl.exe for sync cleanup when WINDIR is missing', () => { + setPlatform('win32'); + delete process.env.WINDIR; + + const execFileSyncMock = childProcess.execFileSync as unknown as Mock; + execFileSyncMock.mockImplementation((command: string) => { + if (command === 'wsl.exe') { + return Buffer.from(''); + } + throw new Error(`Unexpected command: ${command}`); + }); + + const executor = new TmuxPlatformCommandExecutor( + { + getPersistedPreferredDistroSync: () => null, + } as never, + {} as never + ); + + expect(() => executor.killPaneSync('%1')).not.toThrow(); + expect(execFileSyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-e', 'tmux', 'kill-pane', '-t', '%1'], + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true, + }) + ); + }); +}); diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts new file mode 100644 index 00000000..288061c1 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeInstallerProcessOutput } from '../decodeInstallerProcessOutput'; + +describe('decodeInstallerProcessOutput', () => { + it('decodes cp866 Windows console output with Cyrillic text', () => { + const buffer = Buffer.from([0x8f, 0xe0, 0xa8, 0xa2, 0xa5, 0xe2, 0x20, 0x8c, 0xa8, 0xe0]); + + expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('Привет Мир'); + }); + + it('keeps utf8 output readable on non-Windows platforms', () => { + const buffer = Buffer.from('tmux is available\n', 'utf8'); + + expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('tmux is available\n'); + }); + + it('keeps utf8 Cyrillic output readable on non-Windows platforms', () => { + const buffer = Buffer.from('Привет мир\n', 'utf8'); + + expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('Привет мир\n'); + }); + + it('keeps utf8 output readable on Windows too', () => { + const buffer = Buffer.from('tmux is available\n', 'utf8'); + + expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('tmux is available\n'); + }); + + it('keeps utf8 Cyrillic output readable on Windows too', () => { + const buffer = Buffer.from('Привет мир\n', 'utf8'); + + expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('Привет мир\n'); + }); + + it('decodes utf16le output when it contains a BOM', () => { + const utf16le = Buffer.from('\uFEFFWSL core installation command completed.', 'utf16le'); + + expect(decodeInstallerProcessOutput(utf16le, 'win32')).toContain('WSL core installation'); + }); + + it('decodes utf16le Cyrillic output without a BOM', () => { + const utf16le = Buffer.from( + 'Требуемая операция выполнена успешно. Чтобы заданные изменения вступили в силу, следует перезагрузить систему.', + 'utf16le' + ); + + expect(decodeInstallerProcessOutput(utf16le, 'win32')).toContain( + 'Требуемая операция выполнена успешно' + ); + }); +}); diff --git a/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts b/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts new file mode 100644 index 00000000..95455a2f --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts @@ -0,0 +1,134 @@ +const UTF8_DECODER = new TextDecoder('utf-8'); +const UTF16LE_DECODER = new TextDecoder('utf-16le'); +const IBM866_DECODER = new TextDecoder('ibm866'); +const WINDOWS_1251_DECODER = new TextDecoder('windows-1251'); +const TEXT_ENCODER = new TextEncoder(); + +export function decodeInstallerProcessOutput( + output: string | Buffer, + platform: NodeJS.Platform = process.platform +): string { + if (typeof output === 'string') { + return stripNulls(output); + } + if (output.length === 0) { + return ''; + } + + const utf16le = stripNulls(UTF16LE_DECODER.decode(output)); + if (hasUtf16LeBom(output) || looksLikeUtf16Le(output)) { + return utf16le; + } + + const utf8 = stripNulls(UTF8_DECODER.decode(output)); + if (platform !== 'win32') { + return utf8; + } + if (isExactUtf8RoundTrip(output, utf8)) { + return utf8; + } + + const candidates = [ + utf8, + stripNulls(IBM866_DECODER.decode(output)), + stripNulls(WINDOWS_1251_DECODER.decode(output)), + ]; + if (platform === 'win32') { + candidates.push(utf16le); + } + + return candidates + .slice(1) + .reduce( + (best, candidate) => + scoreDecodedText(candidate) > scoreDecodedText(best) ? candidate : best, + candidates[0] ?? utf16le + ); +} + +function isExactUtf8RoundTrip(buffer: Buffer, decoded: string): boolean { + const encoded = TEXT_ENCODER.encode(decoded); + if (encoded.length !== buffer.length) { + return false; + } + for (let index = 0; index < encoded.length; index += 1) { + if (encoded[index] !== buffer[index]) { + return false; + } + } + return true; +} + +function stripNulls(value: string): string { + return value.replace(/\0/g, ''); +} + +function hasUtf16LeBom(buffer: Buffer): boolean { + return buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; +} + +function looksLikeUtf16Le(buffer: Buffer): boolean { + const sampleSize = Math.min(buffer.length, 512); + if (sampleSize < 2) { + return false; + } + + let pairs = 0; + let nullsAtOddIndex = 0; + let likelyUtf16OddBytes = 0; + for (let i = 0; i + 1 < sampleSize; i += 2) { + pairs += 1; + const oddByte = buffer[i + 1]; + const evenByte = buffer[i]; + if (oddByte === 0) { + nullsAtOddIndex += 1; + } + if (oddByte === 0x04 || oddByte === 0x05) { + likelyUtf16OddBytes += 1; + continue; + } + if (oddByte === 0x00 && evenByte >= 0x20 && evenByte <= 0x7e) { + likelyUtf16OddBytes += 1; + } + } + + return pairs > 0 && (nullsAtOddIndex / pairs >= 0.3 || likelyUtf16OddBytes / pairs >= 0.3); +} + +function scoreDecodedText(value: string): number { + let score = 0; + + for (const char of value) { + const codePoint = char.codePointAt(0) ?? 0; + if (char === '\uFFFD') { + score -= 25; + continue; + } + if (char === '\n' || char === '\r' || char === '\t') { + score += 0.5; + continue; + } + if (codePoint >= 0x20 && codePoint <= 0x7e) { + score += 1; + continue; + } + if ( + (codePoint >= 0x0400 && codePoint <= 0x04ff) || + (codePoint >= 0x0500 && codePoint <= 0x052f) + ) { + score += 4; + continue; + } + if (codePoint >= 0x2500 && codePoint <= 0x257f) { + score += 0.4; + continue; + } + if (codePoint < 0x20) { + score -= 5; + continue; + } + score += 0.1; + } + + return score; +} diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts new file mode 100644 index 00000000..d2d7326f --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts @@ -0,0 +1,79 @@ +import { mkdirSync, readFileSync } from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import path from 'node:path'; + +import { app } from 'electron'; + +interface PersistedTmuxWslPreference { + preferredDistroName?: unknown; +} + +type ResolveUserDataPath = () => string; + +export class TmuxWslPreferenceStore { + readonly #resolveUserDataPath: ResolveUserDataPath; + + constructor(resolveUserDataPath: ResolveUserDataPath = () => app.getPath('userData')) { + this.#resolveUserDataPath = resolveUserDataPath; + } + + async getPreferredDistro(): Promise { + try { + const raw = await fsp.readFile(this.#getFilePath(), 'utf8'); + return this.#parsePreferredDistro(raw); + } catch { + return null; + } + } + + getPreferredDistroSync(): string | null { + try { + const raw = readFileSync(this.#getFilePath(), 'utf8'); + return this.#parsePreferredDistro(raw); + } catch { + return null; + } + } + + async setPreferredDistro(preferredDistroName: string): Promise { + const nextValue = preferredDistroName.trim(); + if (!nextValue) { + await this.clearPreferredDistro(); + return; + } + + const filePath = this.#getFilePath(); + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile( + filePath, + JSON.stringify({ preferredDistroName: nextValue }, null, 2), + 'utf8' + ); + } + + async clearPreferredDistro(): Promise { + try { + await fsp.unlink(this.#getFilePath()); + } catch { + // ignore missing file + } + } + + #getFilePath(): string { + const userDataPath = this.#resolveUserDataPath(); + const dirPath = path.join(userDataPath, 'tmux-installer'); + mkdirSync(dirPath, { recursive: true }); + return path.join(dirPath, 'wsl-preference.json'); + } + + #parsePreferredDistro(raw: string): string | null { + try { + const parsed = JSON.parse(raw) as PersistedTmuxWslPreference; + return typeof parsed.preferredDistroName === 'string' && parsed.preferredDistroName.trim() + ? parsed.preferredDistroName.trim() + : null; + } catch { + return null; + } + } +} diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts new file mode 100644 index 00000000..edbd8a4d --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -0,0 +1,545 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; + +import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput'; + +import { TmuxWslPreferenceStore } from './TmuxWslPreferenceStore'; + +import type { + TmuxInstallStrategy, + TmuxWslPreference, + TmuxWslStatus, +} from '@features/tmux-installer/contracts'; + +interface ExecWslResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface WslVerboseDistroEntry { + name: string; + isDefault: boolean; + version: 1 | 2 | null; +} + +interface WindowsOptionalFeatureState { + FeatureName?: string | null; + State?: string | null; + RestartRequired?: string | boolean | null; +} + +interface WindowsOptionalFeatureProbe { + restartPending: boolean; + hasConfiguredWslFeature: boolean; +} + +type ExecFileCallback = ( + error: Error | null, + stdout: string | Buffer, + stderr: string | Buffer +) => void; + +type ExecFileLike = ( + command: string, + args: string[], + options: { + timeout: number; + windowsHide: boolean; + maxBuffer: number; + encoding: 'buffer'; + }, + callback: ExecFileCallback +) => void; + +export interface TmuxWslProbeResult { + preference: TmuxWslPreference | null; + status: TmuxWslStatus; +} + +const MAX_BUFFER_BYTES = 1024 * 1024; +const WSL_NOT_AVAILABLE_DETAIL = 'WSL is not available on this Windows machine yet.'; +const WINDOWS_WSL_FEATURE_NAMES = ['Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform']; +const POWERSHELL_FEATURE_QUERY = [ + '$features = Get-WindowsOptionalFeature -Online -FeatureName "Microsoft-Windows-Subsystem-Linux","VirtualMachinePlatform"', + '$features | Select-Object FeatureName, State, RestartRequired | ConvertTo-Json -Compress', +].join('; '); + +export class TmuxWslService { + readonly #execFile: ExecFileLike; + readonly #preferenceStore: TmuxWslPreferenceStore; + + constructor( + execFileImpl: ExecFileLike = execFile as ExecFileLike, + preferenceStore = new TmuxWslPreferenceStore() + ) { + this.#execFile = execFileImpl; + this.#preferenceStore = preferenceStore; + } + + async probe(): Promise { + const statusProbe = await this.#run(['--status'], 4_000); + const distroListProbe = await this.#run(['--list', '--quiet'], 4_000); + const featureProbe = await this.#queryWindowsOptionalFeatures(); + const persistedPreferredDistro = await this.#preferenceStore.getPreferredDistro(); + const wslInstalled = + statusProbe.exitCode === 0 || + distroListProbe.exitCode === 0 || + featureProbe?.hasConfiguredWslFeature === true; + const rebootRequired = + featureProbe?.restartPending === true || + this.#looksLikeRestartRequired(`${statusProbe.stdout}\n${statusProbe.stderr}`); + + if (!wslInstalled) { + if (persistedPreferredDistro) { + await this.#preferenceStore.clearPreferredDistro(); + } + return { + preference: null, + status: { + wslInstalled: false, + rebootRequired, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: this.#firstNonEmpty( + statusProbe.stderr, + statusProbe.stdout, + WSL_NOT_AVAILABLE_DETAIL + ), + }, + }; + } + + const distros = this.#parseWslDistros(distroListProbe.stdout); + if (distros.length === 0) { + if (persistedPreferredDistro) { + await this.#preferenceStore.clearPreferredDistro(); + } + return { + preference: null, + status: { + wslInstalled: true, + rebootRequired, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: rebootRequired + ? 'WSL was installed, but Windows still needs a restart before a Linux distro can be configured.' + : 'WSL is available, but no Linux distribution is installed yet.', + }, + }; + } + + const verboseProbe = await this.#run(['--list', '--verbose'], 4_000); + const verboseEntries = this.#parseVerboseDistroEntries(verboseProbe.stdout, distros); + const preferredDistro = this.#resolvePreferredDistro({ + distros, + verboseEntries, + persistedPreferredDistro, + }); + const usingPersistedPreference = + Boolean(persistedPreferredDistro) && preferredDistro === persistedPreferredDistro; + if (persistedPreferredDistro && preferredDistro !== persistedPreferredDistro) { + await this.#preferenceStore.clearPreferredDistro(); + } + const preferredVersion = + verboseEntries.find((entry) => entry.name === preferredDistro)?.version ?? null; + + if (!preferredDistro) { + return { + preference: { + preferredDistroName: null, + source: usingPersistedPreference ? 'persisted' : null, + }, + status: { + wslInstalled: true, + rebootRequired, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: + distros.length > 1 + ? 'WSL has multiple Linux distributions, but no default or saved distro target is configured yet.' + : 'WSL is available, but the app could not determine which Linux distribution to target.', + }, + }; + } + + const preference: TmuxWslPreference = { + preferredDistroName: preferredDistro, + source: usingPersistedPreference + ? 'persisted' + : verboseEntries.some((entry) => entry.isDefault) + ? 'default' + : 'manual', + }; + + const bootstrapProbe = await this.#run( + ['-d', preferredDistro, '--', 'sh', '-lc', 'printf ready'], + 5_000 + ); + const distroBootstrapped = bootstrapProbe.exitCode === 0; + if (!distroBootstrapped) { + return { + preference, + status: { + wslInstalled: true, + rebootRequired, + distroName: preferredDistro, + distroVersion: preferredVersion, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: this.#firstNonEmpty( + bootstrapProbe.stderr, + bootstrapProbe.stdout, + `${preferredDistro} is installed in WSL, but its first Linux user setup is not finished yet. Open it once, complete the setup, then re-check.` + ), + }, + }; + } + + const innerPackageManager = await this.#resolveInnerPackageManager(preferredDistro); + const tmuxProbe = await this.#run( + [ + '-d', + preferredDistro, + '--', + 'sh', + '-lc', + 'command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }', + ], + 5_000 + ); + const tmuxLines = tmuxProbe.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + return { + preference, + status: { + wslInstalled: true, + rebootRequired, + distroName: preferredDistro, + distroVersion: preferredVersion, + distroBootstrapped: true, + innerPackageManager, + tmuxAvailableInsideWsl: tmuxProbe.exitCode === 0, + tmuxVersion: tmuxProbe.exitCode === 0 ? (tmuxLines[0] ?? null) : null, + tmuxBinaryPath: tmuxProbe.exitCode === 0 ? (tmuxLines[1] ?? null) : null, + statusDetail: + tmuxProbe.exitCode === 0 + ? `tmux is available inside ${preferredDistro} on Windows through WSL.` + : `tmux is not installed inside the ${preferredDistro} WSL distro yet.`, + }, + }; + } + + async execTmux( + args: string[], + preferredDistroName?: string | null, + timeout = 5_000 + ): Promise { + const distroName = preferredDistroName ?? (await this.probe()).preference?.preferredDistroName; + if (!distroName) { + return { + exitCode: 1, + stdout: '', + stderr: 'No WSL distribution is available for tmux.', + }; + } + + return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout); + } + + getPersistedPreferredDistroSync(): string | null { + return this.#preferenceStore.getPreferredDistroSync(); + } + + async persistPreferredDistro(preferredDistroName: string | null): Promise { + if (!preferredDistroName?.trim()) { + await this.#preferenceStore.clearPreferredDistro(); + return; + } + await this.#preferenceStore.setPreferredDistro(preferredDistroName); + } + + async #resolveInnerPackageManager(distro: string): Promise { + const distroIdProbe = await this.#run( + ['-d', distro, '--', 'sh', '-lc', '. /etc/os-release >/dev/null 2>&1 && printf %s "$ID"'], + 4_000 + ); + const distroId = distroIdProbe.stdout.trim().toLowerCase(); + if (distroId === 'arch') { + return 'pacman'; + } + if (distroId === 'fedora') { + return 'dnf'; + } + if ( + distroId === 'ubuntu' || + distroId === 'debian' || + distroId === 'pop' || + distroId === 'linuxmint' || + distroId === 'kali' + ) { + return 'apt'; + } + if (distroId === 'opensuse-tumbleweed' || distroId === 'opensuse-leap' || distroId === 'sles') { + return 'zypper'; + } + + const candidateChecks: { binary: string; strategy: TmuxInstallStrategy }[] = [ + { binary: 'apt-get', strategy: 'apt' }, + { binary: 'dnf', strategy: 'dnf' }, + { binary: 'yum', strategy: 'yum' }, + { binary: 'zypper', strategy: 'zypper' }, + { binary: 'pacman', strategy: 'pacman' }, + ]; + + for (const candidate of candidateChecks) { + const probe = await this.#run( + ['-d', distro, '--', 'sh', '-lc', `command -v ${candidate.binary} >/dev/null 2>&1`], + 3_000 + ); + if (probe.exitCode === 0) { + return candidate.strategy; + } + } + + return null; + } + + async #run(args: string[], timeout: number): Promise { + const candidates = this.#getExecutableCandidates(); + let lastFailure: ExecWslResult | null = null; + + for (const executable of candidates) { + const result = await this.#exec(executable, args, timeout); + if (result === null) { + continue; + } + lastFailure = result; + if (result.exitCode === 0) { + return result; + } + if (result.exitCode !== 0) { + return result; + } + } + + return ( + lastFailure ?? { + exitCode: 1, + stdout: '', + stderr: WSL_NOT_AVAILABLE_DETAIL, + } + ); + } + + async #exec(executable: string, args: string[], timeout: number): Promise { + return new Promise((resolve) => { + this.#execFile( + executable, + args, + { + timeout, + windowsHide: true, + maxBuffer: MAX_BUFFER_BYTES, + encoding: 'buffer', + }, + (error, stdout, stderr) => { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (errorCode === 'ENOENT') { + resolve(null); + return; + } + resolve({ + exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, + stdout: this.#decodeOutput(stdout), + stderr: this.#decodeOutput(stderr) || (error instanceof Error ? error.message : ''), + }); + } + ); + }); + } + + #getExecutableCandidates(): string[] { + const candidates = new Set(); + const windir = process.env.WINDIR; + if (windir) { + candidates.add(path.join(windir, 'System32', 'wsl.exe')); + candidates.add(path.join(windir, 'Sysnative', 'wsl.exe')); + } + candidates.add('wsl.exe'); + return [...candidates]; + } + + #decodeOutput(output: string | Buffer): string { + return decodeInstallerProcessOutput(output); + } + + #parseWslDistros(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map((line) => line.replace(/\0/g, '').trim()) + .map((line) => line.replace(/^\*\s*/, '').trim()) + .filter(Boolean); + } + + #parseVerboseDistroEntries(stdout: string, distros: string[]): WslVerboseDistroEntry[] { + const sortedDistros = [...distros].sort((left, right) => right.length - left.length); + const entries: WslVerboseDistroEntry[] = []; + + for (const rawLine of stdout.split(/\r?\n/)) { + let line = rawLine.replace(/\0/g, '').trim(); + if (!line) { + continue; + } + + const isDefault = line.startsWith('*'); + if (isDefault) { + line = line.slice(1).trim(); + } + + const matchedName = sortedDistros.find((distro) => line.startsWith(distro)); + if (!matchedName) { + continue; + } + + const lineTokens = line.split(/\s+/); + const versionToken = lineTokens[lineTokens.length - 1]; + const version = versionToken === '1' ? 1 : versionToken === '2' ? 2 : null; + entries.push({ name: matchedName, isDefault, version }); + } + + return entries; + } + + #resolvePreferredDistro(input: { + distros: string[]; + verboseEntries: WslVerboseDistroEntry[]; + persistedPreferredDistro: string | null; + }): string | null { + if (input.persistedPreferredDistro && input.distros.includes(input.persistedPreferredDistro)) { + return input.persistedPreferredDistro; + } + + const defaultDistro = input.verboseEntries.find((entry) => entry.isDefault)?.name ?? null; + if (defaultDistro) { + return defaultDistro; + } + + if (input.distros.length === 1) { + return input.distros[0] ?? null; + } + + return null; + } + + #looksLikeRestartRequired(output: string): boolean { + const lowered = output.toLowerCase(); + return ( + lowered.includes('restart') || + lowered.includes('reboot') || + lowered.includes('перезагруз') || + lowered.includes('требуется перезагрузка') + ); + } + + async #queryWindowsOptionalFeatures(): Promise { + const result = await this.#execPowerShell( + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY], + 6_000 + ); + if (!result || result.exitCode !== 0 || !result.stdout.trim()) { + return null; + } + + try { + const parsed = JSON.parse(result.stdout) as + | WindowsOptionalFeatureState + | WindowsOptionalFeatureState[]; + const features = Array.isArray(parsed) ? parsed : [parsed]; + const relevantFeatures = features.filter((feature) => + WINDOWS_WSL_FEATURE_NAMES.includes(feature.FeatureName ?? '') + ); + if (relevantFeatures.length === 0) { + return null; + } + + return { + restartPending: relevantFeatures.some((feature) => + String(feature.State ?? '') + .toLowerCase() + .includes('pending') + ), + hasConfiguredWslFeature: relevantFeatures.some((feature) => { + const state = String(feature.State ?? '').toLowerCase(); + return state.length > 0 && state !== 'disabled' && state !== 'disabledwithpayloadremoved'; + }), + }; + } catch { + return null; + } + } + + async #execPowerShell(args: string[], timeout: number): Promise { + return new Promise((resolve) => { + this.#execFile( + 'powershell.exe', + args, + { + timeout, + windowsHide: true, + maxBuffer: MAX_BUFFER_BYTES, + encoding: 'buffer', + }, + (error, stdout, stderr) => { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (errorCode === 'ENOENT') { + resolve(null); + return; + } + resolve({ + exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, + stdout: this.#decodeOutput(stdout), + stderr: this.#decodeOutput(stderr) || (error instanceof Error ? error.message : ''), + }); + } + ); + }); + } + + #firstNonEmpty(...values: (string | null | undefined)[]): string { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + return WSL_NOT_AVAILABLE_DETAIL; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts new file mode 100644 index 00000000..74e2092f --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts @@ -0,0 +1,242 @@ +import { execFile } from 'node:child_process'; +import * as fsp from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { createLogger } from '@shared/utils/logger'; + +import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput'; + +const logger = createLogger('Feature:tmux-installer:windows-elevation'); + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface PersistedFeatureState { + featureName?: string | null; + state?: string | null; + restartRequired?: string | boolean | null; +} + +interface PersistedElevationResult { + ok?: boolean; + detail?: string | null; + restartRequired?: boolean | null; + featureStates?: PersistedFeatureState[] | null; + commandExitCode?: number | null; +} + +type ExecFileCallback = ( + error: Error | null, + stdout: string | Buffer, + stderr: string | Buffer +) => void; + +type ExecFileLike = ( + command: string, + args: string[], + options: { + timeout: number; + windowsHide: boolean; + maxBuffer: number; + encoding: 'buffer'; + }, + callback: ExecFileCallback +) => void; + +type MakeTempDir = (prefix: string) => Promise; + +export interface WindowsElevatedStepResult { + outcome: + | 'elevated_succeeded' + | 'elevated_cancelled' + | 'elevated_failed' + | 'elevated_unknown_outcome'; + detail: string | null; + restartRequired: boolean; + featureStates: PersistedFeatureState[]; + resultFilePath: string | null; +} + +const MAX_BUFFER_BYTES = 512 * 1024; + +export class WindowsElevatedStepRunner { + readonly #execFile: ExecFileLike; + readonly #makeTempDir: MakeTempDir; + + constructor( + execFileImpl: ExecFileLike = execFile as ExecFileLike, + makeTempDir: MakeTempDir = (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ) { + this.#execFile = execFileImpl; + this.#makeTempDir = makeTempDir; + } + + async runWslCoreInstall(): Promise { + const tempDir = await this.#makeTempDir('tmux-wsl-install-'); + const resultFilePath = path.join(tempDir, 'result.json'); + const helperScriptPath = path.join(tempDir, 'run-wsl-core-install.ps1'); + const launcherScriptPath = path.join(tempDir, 'launch-wsl-core-install.ps1'); + + await fsp.writeFile( + helperScriptPath, + this.#buildHelperScript(resultFilePath, ['--install', '--no-distribution']), + 'utf8' + ); + await fsp.writeFile(launcherScriptPath, this.#buildLauncherScript(helperScriptPath), 'utf8'); + + const result = await this.#execPowerShellFile(launcherScriptPath, 30 * 60 * 1_000); + const persistedResult = await this.#readPersistedResult(resultFilePath); + + if (persistedResult) { + return { + outcome: persistedResult.ok ? 'elevated_succeeded' : 'elevated_failed', + detail: persistedResult.detail ?? null, + restartRequired: persistedResult.restartRequired === true, + featureStates: persistedResult.featureStates ?? [], + resultFilePath, + }; + } + + if (this.#looksLikeElevationCancelled(result)) { + return { + outcome: 'elevated_cancelled', + detail: 'Administrator permission request was cancelled.', + restartRequired: false, + featureStates: [], + resultFilePath: null, + }; + } + + logger.warn('Windows elevated WSL core install finished without a result file', { + exitCode: result.exitCode, + stderr: result.stderr, + }); + return { + outcome: 'elevated_unknown_outcome', + detail: this.#firstNonEmpty(result.stderr, result.stdout), + restartRequired: false, + featureStates: [], + resultFilePath: null, + }; + } + + async #execPowerShellFile(scriptPath: string, timeout: number): Promise { + return new Promise((resolve) => { + this.#execFile( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], + { + timeout, + windowsHide: true, + maxBuffer: MAX_BUFFER_BYTES, + encoding: 'buffer', + }, + (error, stdout, stderr) => { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + resolve({ + exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, + stdout: decodeInstallerProcessOutput(stdout, 'win32'), + stderr: + decodeInstallerProcessOutput(stderr, 'win32') || + (error instanceof Error ? error.message : ''), + }); + } + ); + }); + } + + async #readPersistedResult(resultFilePath: string): Promise { + try { + const raw = await fsp.readFile(resultFilePath, 'utf8'); + return JSON.parse(this.#stripBom(raw)) as PersistedElevationResult; + } catch { + return null; + } + } + + #buildLauncherScript(helperScriptPath: string): string { + const escapedHelperPath = this.#escapePowerShellSingleQuotedString(helperScriptPath); + return [ + '$ErrorActionPreference = "Stop"', + `$helperScript = '${escapedHelperPath}'`, + '$argumentList = @(', + " '-NoProfile',", + " '-ExecutionPolicy',", + " 'Bypass',", + " '-File',", + ' $helperScript', + ')', + "Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -ArgumentList $argumentList", + '', + ].join('\n'); + } + + #buildHelperScript(resultFilePath: string, wslArgs: string[]): string { + const escapedResultFilePath = this.#escapePowerShellSingleQuotedString(resultFilePath); + const quotedArgs = wslArgs + .map((arg) => `'${this.#escapePowerShellSingleQuotedString(arg)}'`) + .join(', '); + return [ + '$ErrorActionPreference = "Stop"', + `$resultFile = '${escapedResultFilePath}'`, + `$wslArgs = @(${quotedArgs})`, + '$featureNames = @("Microsoft-Windows-Subsystem-Linux", "VirtualMachinePlatform")', + '$result = @{ ok = $false; detail = $null; restartRequired = $false; featureStates = @(); commandExitCode = $null }', + 'try {', + ' & wsl.exe @wslArgs', + ' $result.commandExitCode = $LASTEXITCODE', + ' $features = @(Get-WindowsOptionalFeature -Online -FeatureName $featureNames | Select-Object FeatureName, State, RestartRequired)', + ' $result.featureStates = @($features | ForEach-Object { @{ featureName = $_.FeatureName; state = [string]$_.State; restartRequired = $_.RestartRequired } })', + ' $result.restartRequired = @($features | Where-Object { "$($_.State)" -like "*Pending*" }).Count -gt 0', + ' if ($LASTEXITCODE -eq 0) {', + ' $result.ok = $true', + ' $result.detail = "WSL core installation command completed."', + ' } else {', + ' $result.detail = "wsl.exe exited with code $LASTEXITCODE."', + ' }', + '} catch {', + ' $result.detail = $_.Exception.Message', + '}', + '$result | ConvertTo-Json -Compress | Set-Content -Path $resultFile -Encoding utf8', + 'if ($result.ok) { exit 0 }', + 'exit 1', + '', + ].join('\n'); + } + + #escapePowerShellSingleQuotedString(value: string): string { + return value.replaceAll("'", "''"); + } + + #looksLikeElevationCancelled(result: ExecResult): boolean { + const combined = `${result.stdout}\n${result.stderr}`.toLowerCase(); + return ( + combined.includes('cancelled') || + combined.includes('canceled') || + combined.includes('operation was canceled') || + combined.includes('operation was cancelled') || + combined.includes('1223') + ); + } + + #firstNonEmpty(...values: string[]): string | null { + for (const value of values) { + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + return null; + } + + #stripBom(value: string): string { + return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts new file mode 100644 index 00000000..e1ed78f5 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; + +import { TmuxWslService } from '../TmuxWslService'; + +function createPreferenceStore(initialPreferredDistro: string | null = null): { + getPreferredDistro: () => Promise; + getPreferredDistroSync: () => string | null; + setPreferredDistro: (preferredDistroName: string) => Promise; + clearPreferredDistro: () => Promise; +} { + let preferredDistro = initialPreferredDistro; + return { + async getPreferredDistro() { + return preferredDistro; + }, + getPreferredDistroSync() { + return preferredDistro; + }, + async setPreferredDistro(nextPreferredDistroName: string) { + preferredDistro = nextPreferredDistroName; + }, + async clearPreferredDistro() { + preferredDistro = null; + }, + }; +} + +function createExecFileMock( + handlers: Record< + string, + { error?: NodeJS.ErrnoException | null; stdout?: string | Buffer; stderr?: string | Buffer } + > +): ( + command: string, + args: string[], + options: { + timeout: number; + windowsHide: boolean; + maxBuffer: number; + encoding: 'buffer'; + }, + callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void +) => void { + return (_command, args, _options, callback) => { + const key = args.join(' '); + const result = handlers[key]; + if (!result) { + const error = new Error(`Unexpected WSL command: ${key}`) as NodeJS.ErrnoException; + error.code = 'EFAIL'; + callback(error, '', ''); + return; + } + callback(result.error ?? null, result.stdout ?? '', result.stderr ?? ''); + }; +} + +describe('TmuxWslService', () => { + it('reports missing WSL when status and list commands both fail', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { + error: Object.assign(new Error('wsl missing'), { code: 'EFAIL' }), + stderr: 'WSL is not installed', + }, + '--list --quiet': { + error: Object.assign(new Error('wsl missing'), { code: 'EFAIL' }), + stderr: 'WSL is not installed', + }, + }), + createPreferenceStore() as never + ); + + const result = await service.probe(); + + expect(result.status.wslInstalled).toBe(false); + expect(result.status.statusDetail).toContain('WSL'); + expect(result.preference).toBeNull(); + }); + + it('detects a bootstrapped Ubuntu distro with tmux available', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Distribution: Ubuntu\nDefault Version: 2\n' }, + '--list --quiet': { stdout: 'Ubuntu\n' }, + '--list --verbose': { stdout: '* Ubuntu Running 2\n' }, + '-d Ubuntu -- sh -lc printf ready': { stdout: 'ready' }, + '-d Ubuntu -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': { + stdout: 'ubuntu', + }, + '-d Ubuntu -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }': + { + stdout: 'tmux 3.4\n/usr/bin/tmux\n', + }, + }), + createPreferenceStore() as never + ); + + const result = await service.probe(); + + expect(result.preference?.preferredDistroName).toBe('Ubuntu'); + expect(result.status.wslInstalled).toBe(true); + expect(result.status.distroName).toBe('Ubuntu'); + expect(result.status.distroVersion).toBe(2); + expect(result.status.distroBootstrapped).toBe(true); + expect(result.status.innerPackageManager).toBe('apt'); + expect(result.status.tmuxAvailableInsideWsl).toBe(true); + expect(result.status.tmuxVersion).toBe('tmux 3.4'); + expect(result.status.tmuxBinaryPath).toBe('/usr/bin/tmux'); + }); + + it('prefers the persisted distro over the default WSL marker', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Distribution: Debian\nDefault Version: 2\n' }, + '--list --quiet': { stdout: 'Ubuntu\nDebian\n' }, + '--list --verbose': { stdout: '* Debian Running 2\n Ubuntu Stopped 2\n' }, + '-d Ubuntu -- sh -lc printf ready': { stdout: 'ready' }, + '-d Ubuntu -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': { + stdout: 'ubuntu', + }, + '-d Ubuntu -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }': + { + stdout: 'tmux 3.4\n/usr/bin/tmux\n', + }, + }), + createPreferenceStore('Ubuntu') as never + ); + + const result = await service.probe(); + + expect(result.preference?.preferredDistroName).toBe('Ubuntu'); + expect(result.preference?.source).toBe('persisted'); + expect(result.status.distroName).toBe('Ubuntu'); + }); + + it('clears a stale preferred distro when WSL has no installed distributions', async () => { + const preferenceStore = createPreferenceStore('Ubuntu'); + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Version: 2\n' }, + '--list --quiet': { stdout: '' }, + }), + preferenceStore as never + ); + + const result = await service.probe(); + + expect(result.status.distroName).toBeNull(); + expect(preferenceStore.getPreferredDistroSync()).toBeNull(); + }); + + it('switches preference source away from persisted after clearing a stale distro', async () => { + const preferenceStore = createPreferenceStore('Ubuntu'); + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Distribution: Debian\nDefault Version: 2\n' }, + '--list --quiet': { stdout: 'Debian\n' }, + '--list --verbose': { stdout: '* Debian Running 2\n' }, + '-d Debian -- sh -lc printf ready': { stdout: 'ready' }, + '-d Debian -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': { + stdout: 'debian', + }, + '-d Debian -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }': + { + stdout: 'tmux 3.4\n/usr/bin/tmux\n', + }, + }), + preferenceStore as never + ); + + const result = await service.probe(); + + expect(result.preference?.preferredDistroName).toBe('Debian'); + expect(result.preference?.source).toBe('default'); + expect(preferenceStore.getPreferredDistroSync()).toBeNull(); + }); + + it('detects a reboot requirement from localized Windows output', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { + stdout: + 'Требуемая операция выполнена успешно. Чтобы сделанные изменения вступили в силу, следует перезагрузить систему.', + }, + '--list --quiet': { stdout: '' }, + }), + createPreferenceStore() as never + ); + + const result = await service.probe(); + + expect(result.status.wslInstalled).toBe(true); + expect(result.status.rebootRequired).toBe(true); + expect(result.status.statusDetail).toContain('restart'); + }); + + it('detects a reboot requirement from pending Windows optional feature state', async () => { + const execFileMock = ( + command: string, + args: string[], + _options: { + timeout: number; + windowsHide: boolean; + maxBuffer: number; + encoding: 'buffer'; + }, + callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void + ) => { + if (command === 'powershell.exe') { + callback( + null, + JSON.stringify([ + { + FeatureName: 'Microsoft-Windows-Subsystem-Linux', + State: 'EnablePending', + RestartRequired: 'Possible', + }, + { + FeatureName: 'VirtualMachinePlatform', + State: 'EnablePending', + RestartRequired: 'Possible', + }, + ]), + '' + ); + return; + } + + const key = args.join(' '); + if (key === '--status') { + callback(null, 'ok', ''); + return; + } + if (key === '--list --quiet') { + callback(null, '', ''); + return; + } + + callback( + Object.assign(new Error(`Unexpected command: ${command} ${key}`), { code: 'EFAIL' }), + '', + '' + ); + }; + + const service = new TmuxWslService(execFileMock, createPreferenceStore() as never); + + const result = await service.probe(); + + expect(result.status.wslInstalled).toBe(true); + expect(result.status.rebootRequired).toBe(true); + expect(result.status.statusDetail).toContain('restart'); + }); +}); diff --git a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts new file mode 100644 index 00000000..d939fe89 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts @@ -0,0 +1,126 @@ +import * as fsp from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { WindowsElevatedStepRunner } from '../WindowsElevatedStepRunner'; + +describe('WindowsElevatedStepRunner', () => { + it('returns success when the elevated helper writes a result file', async () => { + const runner = new WindowsElevatedStepRunner( + async (_command, args, _options, callback) => { + const launcherScriptPath = args[4]; + const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json'); + await fsp.writeFile( + resultFilePath, + JSON.stringify({ + ok: true, + detail: 'WSL core installation command completed.', + restartRequired: false, + featureStates: [ + { + featureName: 'Microsoft-Windows-Subsystem-Linux', + state: 'Enabled', + restartRequired: false, + }, + ], + }), + 'utf8' + ); + callback(null, '', ''); + }, + (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ); + + const result = await runner.runWslCoreInstall(); + + expect(result.outcome).toBe('elevated_succeeded'); + expect(result.detail).toContain('completed'); + expect(result.restartRequired).toBe(false); + expect(result.featureStates[0]?.featureName).toBe('Microsoft-Windows-Subsystem-Linux'); + expect(result.resultFilePath).toContain('result.json'); + }); + + it('parses a UTF-8 BOM result file from PowerShell content writes', async () => { + const runner = new WindowsElevatedStepRunner( + async (_command, args, _options, callback) => { + const launcherScriptPath = args[4]; + const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json'); + await fsp.writeFile( + resultFilePath, + `\uFEFF${JSON.stringify({ + ok: true, + detail: 'WSL core installation command completed.', + restartRequired: true, + featureStates: [ + { + featureName: 'VirtualMachinePlatform', + state: 'EnablePending', + restartRequired: 'Possible', + }, + ], + })}`, + 'utf8' + ); + callback(null, '', ''); + }, + (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ); + + const result = await runner.runWslCoreInstall(); + + expect(result.outcome).toBe('elevated_succeeded'); + expect(result.detail).toContain('completed'); + expect(result.restartRequired).toBe(true); + expect(result.featureStates[0]?.state).toBe('EnablePending'); + }); + + it('treats a missing result file plus cancel text as elevation cancellation', async () => { + const runner = new WindowsElevatedStepRunner( + (_command, _args, _options, callback) => { + callback( + Object.assign(new Error('cancelled'), { code: 1 }), + '', + 'The operation was canceled by the user.' + ); + }, + (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ); + + const result = await runner.runWslCoreInstall(); + + expect(result.outcome).toBe('elevated_cancelled'); + expect(result.detail).toContain('cancelled'); + expect(result.restartRequired).toBe(false); + expect(result.featureStates).toEqual([]); + expect(result.resultFilePath).toBeNull(); + }); + + it('decodes localized Windows stderr from the launcher process', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const runner = new WindowsElevatedStepRunner( + (_command, _args, _options, callback) => { + callback( + Object.assign(new Error('restart required'), { code: 1 }), + Buffer.alloc(0), + Buffer.from( + 'Требуемая операция выполнена успешно. Чтобы заданные изменения вступили в силу, следует перезагрузить систему.', + 'utf16le' + ) + ); + }, + (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ); + + const result = await runner.runWslCoreInstall(); + + expect(result.outcome).toBe('elevated_unknown_outcome'); + expect(result.detail).toContain('Требуемая операция выполнена успешно'); + expect(result.restartRequired).toBe(false); + } finally { + consoleWarnSpy.mockRestore(); + } + }); +}); diff --git a/src/features/tmux-installer/preload/createTmuxInstallerBridge.ts b/src/features/tmux-installer/preload/createTmuxInstallerBridge.ts new file mode 100644 index 00000000..eff2e798 --- /dev/null +++ b/src/features/tmux-installer/preload/createTmuxInstallerBridge.ts @@ -0,0 +1,43 @@ +import { + TMUX_CANCEL_INSTALL, + TMUX_GET_INSTALLER_SNAPSHOT, + TMUX_GET_STATUS, + TMUX_INSTALL, + TMUX_INSTALLER_PROGRESS, + TMUX_INVALIDATE_STATUS, + TMUX_SUBMIT_INSTALLER_INPUT, +} from '@features/tmux-installer/contracts'; + +import type { TmuxAPI } from '@features/tmux-installer/contracts'; +import type { IpcRenderer } from 'electron'; + +interface CreateTmuxInstallerBridgeDeps { + ipcRenderer: IpcRenderer; + invokeIpcWithResult: (channel: string, ...args: unknown[]) => Promise; +} + +export function createTmuxInstallerBridge({ + ipcRenderer, + invokeIpcWithResult, +}: CreateTmuxInstallerBridgeDeps): TmuxAPI { + return { + getStatus: () => invokeIpcWithResult(TMUX_GET_STATUS), + getInstallerSnapshot: () => invokeIpcWithResult(TMUX_GET_INSTALLER_SNAPSHOT), + install: () => invokeIpcWithResult(TMUX_INSTALL), + cancelInstall: () => invokeIpcWithResult(TMUX_CANCEL_INSTALL), + submitInstallerInput: (input) => invokeIpcWithResult(TMUX_SUBMIT_INSTALLER_INPUT, input), + invalidateStatus: () => invokeIpcWithResult(TMUX_INVALIDATE_STATUS), + onProgress: (callback) => { + ipcRenderer.on( + TMUX_INSTALLER_PROGRESS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TMUX_INSTALLER_PROGRESS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + }; +} diff --git a/src/features/tmux-installer/preload/index.ts b/src/features/tmux-installer/preload/index.ts new file mode 100644 index 00000000..ec118c3c --- /dev/null +++ b/src/features/tmux-installer/preload/index.ts @@ -0,0 +1 @@ +export { createTmuxInstallerBridge } from './createTmuxInstallerBridge'; diff --git a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts new file mode 100644 index 00000000..c989089e --- /dev/null +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -0,0 +1,179 @@ +import { + formatInstallButtonLabel, + formatTmuxInstallerProgress, + formatTmuxInstallerTitle, + formatTmuxLocationLabel, + formatTmuxOptionalBenefits, + formatTmuxPlatformLabel, +} from '@features/tmux-installer/renderer/utils/formatTmuxInstallerText'; + +import type { + TmuxInstallerSnapshot, + TmuxInstallHint, + TmuxStatus, +} from '@features/tmux-installer/contracts'; + +export interface TmuxInstallerBannerViewModel { + visible: boolean; + loading: boolean; + title: string; + body: string; + benefitsBody: string | null; + error: string | null; + platformLabel: string | null; + locationLabel: string | null; + runtimeReadyLabel: string | null; + versionLabel: string | null; + phase: TmuxInstallerSnapshot['phase']; + progressPercent: number | null; + logs: string[]; + manualHints: TmuxInstallHint[]; + manualHintsCollapsible: boolean; + primaryGuideUrl: string | null; + installSupported: boolean; + installDisabled: boolean; + installLabel: string; + installButtonPrimary: boolean; + showRefreshButton: boolean; + canCancel: boolean; + acceptsInput: boolean; + inputPrompt: string | null; + inputSecret: boolean; + detailsOpen: boolean; +} + +interface AdaptInput { + status: TmuxStatus | null; + snapshot: TmuxInstallerSnapshot; + loading: boolean; + error: string | null; + detailsOpen: boolean; +} + +const RESTART_REQUIRED_PATTERNS = ['restart', 'reboot', 'перезагруз', 'требуется перезагрузка']; + +export class TmuxInstallerBannerAdapter { + static create(): TmuxInstallerBannerAdapter { + return new TmuxInstallerBannerAdapter(); + } + + adapt(input: AdaptInput): TmuxInstallerBannerViewModel { + const status = input.status; + const snapshot = input.snapshot; + const displayPhase = this.#resolveDisplayPhase(snapshot, status); + const hasActiveInstallFlow = + displayPhase !== 'idle' && displayPhase !== 'completed' && displayPhase !== 'cancelled'; + const tmuxMissing = status ? !status.effective.available : !input.loading; + const visible = + hasActiveInstallFlow || (displayPhase !== 'completed' && !input.loading && tmuxMissing); + const title = + snapshot.message && + (displayPhase === 'pending_external_elevation' || + displayPhase === 'waiting_for_external_step' || + displayPhase === 'needs_restart' || + displayPhase === 'needs_manual_step') + ? snapshot.message + : formatTmuxInstallerTitle(displayPhase); + const primaryGuideUrl = + status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null; + const body = + input.error ?? + snapshot.error ?? + snapshot.detail ?? + snapshot.message ?? + status?.effective.detail ?? + status?.wsl?.statusDetail ?? + 'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.'; + const benefitsBody = + status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null; + const runtimeReadyLabel = status + ? status.effective.runtimeReady + ? 'Ready for persistent teammates' + : status.effective.available + ? 'Installed, but not active yet' + : null + : null; + const versionLabel = + status?.effective.version ?? status?.host.version ?? status?.wsl?.tmuxVersion ?? null; + const manualHints = status?.autoInstall.manualHints ?? []; + const manualHintsCollapsible = status?.platform === 'win32' && manualHints.length > 0; + const installLabel = + displayPhase === 'idle' && + status?.platform === 'win32' && + status.autoInstall.strategy === 'wsl' && + status.autoInstall.supported + ? !status.wsl?.wslInstalled + ? 'Install WSL' + : !status.wsl?.distroName + ? 'Install Ubuntu in WSL' + : 'Install tmux in WSL' + : formatInstallButtonLabel(displayPhase); + const installDisabled = + input.loading || + displayPhase === 'preparing' || + displayPhase === 'checking' || + displayPhase === 'requesting_privileges' || + displayPhase === 'pending_external_elevation' || + displayPhase === 'waiting_for_external_step' || + displayPhase === 'installing' || + displayPhase === 'verifying'; + const installButtonPrimary = + !installDisabled && (installLabel.startsWith('Install') || installLabel.startsWith('Retry')); + const showRefreshButton = + !(status?.autoInstall.supported ?? false) || + (installLabel !== 'Re-check' && installLabel !== 'Re-check after restart'); + + return { + visible, + loading: input.loading, + title, + body, + benefitsBody, + error: input.error ?? snapshot.error ?? status?.error ?? null, + platformLabel: formatTmuxPlatformLabel(status?.platform ?? null), + locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null), + runtimeReadyLabel, + versionLabel, + phase: displayPhase, + progressPercent: formatTmuxInstallerProgress(displayPhase), + logs: snapshot.logs, + manualHints, + manualHintsCollapsible, + primaryGuideUrl, + installSupported: status?.autoInstall.supported ?? false, + installDisabled, + installLabel, + installButtonPrimary, + showRefreshButton, + canCancel: snapshot.canCancel, + acceptsInput: snapshot.acceptsInput, + inputPrompt: snapshot.inputPrompt, + inputSecret: snapshot.inputSecret, + detailsOpen: input.detailsOpen, + }; + } + + #resolveDisplayPhase( + snapshot: TmuxInstallerSnapshot, + status: TmuxStatus | null + ): TmuxInstallerSnapshot['phase'] { + if (snapshot.phase !== 'waiting_for_external_step') { + return snapshot.phase; + } + + const combinedSignals = [ + snapshot.message, + snapshot.detail, + status?.wsl?.statusDetail, + ...snapshot.logs, + ] + .filter(Boolean) + .join('\n') + .toLowerCase(); + const restartRequired = + status?.wsl?.rebootRequired === true || + RESTART_REQUIRED_PATTERNS.some((pattern) => combinedSignals.includes(pattern)); + + return restartRequired ? 'needs_restart' : snapshot.phase; + } +} diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts new file mode 100644 index 00000000..1939f8ea --- /dev/null +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it } from 'vitest'; + +import { TmuxInstallerBannerAdapter } from '../TmuxInstallerBannerAdapter'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +const baseStatus: TmuxStatus = { + platform: 'darwin', + nativeSupported: true, + checkedAt: new Date().toISOString(), + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'tmux improves persistent teammate reliability.', + }, + error: null, + autoInstall: { + supported: true, + strategy: 'homebrew', + packageManagerLabel: 'Homebrew', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [{ title: 'Homebrew', description: 'Recommended', command: 'brew install tmux' }], + }, +}; + +const idleSnapshot: TmuxInstallerSnapshot = { + phase: 'idle', + strategy: null, + message: null, + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: [], + updatedAt: new Date().toISOString(), +}; + +describe('TmuxInstallerBannerAdapter', () => { + it('builds an install-ready view model for unavailable tmux', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: baseStatus, + snapshot: idleSnapshot, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.visible).toBe(true); + expect(result.installSupported).toBe(true); + expect(result.installDisabled).toBe(false); + expect(result.installLabel).toBe('Install tmux'); + expect(result.platformLabel).toBe('macOS'); + expect(result.runtimeReadyLabel).toBeNull(); + expect(result.primaryGuideUrl).toBeNull(); + expect(result.progressPercent).toBeNull(); + expect(result.manualHints).toHaveLength(1); + expect(result.manualHintsCollapsible).toBe(false); + expect(result.body).toContain('persistent teammate reliability'); + expect(result.benefitsBody).toContain('Optional, but recommended'); + expect(result.installButtonPrimary).toBe(true); + expect(result.showRefreshButton).toBe(true); + }); + + it('prioritizes renderer errors and disables the install button while installing', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: baseStatus, + snapshot: { + ...idleSnapshot, + phase: 'installing', + strategy: 'homebrew', + message: 'brew install tmux', + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: ['Downloading bottle...'], + }, + loading: false, + error: 'Renderer bridge failed', + detailsOpen: true, + }); + + expect(result.title).toBe('Installing tmux'); + expect(result.body).toBe('Renderer bridge failed'); + expect(result.benefitsBody).toContain('Optional, but recommended'); + expect(result.error).toBe('Renderer bridge failed'); + expect(result.installDisabled).toBe(true); + expect(result.canCancel).toBe(true); + expect(result.acceptsInput).toBe(false); + expect(result.progressPercent).toBe(68); + expect(result.logs).toEqual(['Downloading bottle...']); + expect(result.installButtonPrimary).toBe(false); + }); + + it('keeps the banner visible while loading if installer progress is already active', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: null, + snapshot: { + ...idleSnapshot, + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Finish Ubuntu setup in WSL', + }, + loading: true, + error: null, + detailsOpen: false, + }); + + expect(result.visible).toBe(true); + expect(result.title).toBe('Finish Ubuntu setup in WSL'); + }); + + it('exposes a manual guide url when auto install is unavailable', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + effective: { + ...baseStatus.effective, + detail: 'WSL is installed, but tmux still needs to be installed there.', + }, + autoInstall: { + ...baseStatus.autoInstall, + supported: false, + strategy: 'wsl', + manualHints: [ + { + title: 'Microsoft WSL', + description: 'Official WSL docs', + url: 'https://learn.microsoft.com/en-us/windows/wsl/install', + }, + ], + }, + }, + snapshot: { + ...idleSnapshot, + phase: 'needs_manual_step', + strategy: 'wsl', + detail: 'WSL wizard is not wired yet.', + }, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.platformLabel).toBe('Windows'); + expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install'); + expect(result.progressPercent).toBe(82); + expect(result.manualHintsCollapsible).toBe(true); + expect(result.benefitsBody).toContain('With tmux in WSL'); + expect(result.showRefreshButton).toBe(true); + }); + + it('hides the banner when tmux is already installed', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + effective: { + available: true, + location: 'host', + version: 'tmux 3.4', + binaryPath: 'C:\\tmux.exe', + runtimeReady: false, + detail: 'tmux was found on Windows, but WSL-backed tmux is still preferred.', + }, + }, + snapshot: idleSnapshot, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.visible).toBe(false); + expect(result.locationLabel).toBe('Host runtime'); + expect(result.runtimeReadyLabel).toBe('Installed, but not active yet'); + expect(result.versionLabel).toBe('tmux 3.4'); + expect(result.benefitsBody).toBeNull(); + }); + + it('hides a completed installer banner once tmux is available', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: { + ...baseStatus, + effective: { + available: true, + location: 'host', + version: 'tmux 3.6a', + binaryPath: '/opt/homebrew/bin/tmux', + runtimeReady: true, + detail: 'tmux is available for persistent teammates.', + }, + }, + snapshot: { + ...idleSnapshot, + phase: 'completed', + strategy: 'homebrew', + message: 'tmux installed', + }, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.visible).toBe(false); + }); + + it('exposes installer input metadata for interactive privilege flows', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: baseStatus, + snapshot: { + ...idleSnapshot, + phase: 'requesting_privileges', + strategy: 'apt', + acceptsInput: true, + inputPrompt: 'Enter password if prompted', + inputSecret: true, + }, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.acceptsInput).toBe(true); + expect(result.inputPrompt).toBe('Enter password if prompted'); + expect(result.inputSecret).toBe(true); + }); + + it('uses Windows-specific install labels for the WSL wizard states', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const installWslResult = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + autoInstall: { + ...baseStatus.autoInstall, + supported: true, + strategy: 'wsl', + }, + wsl: { + wslInstalled: false, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'WSL is not installed yet.', + }, + }, + snapshot: idleSnapshot, + loading: false, + error: null, + detailsOpen: false, + }); + const installUbuntuResult = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + autoInstall: { + ...baseStatus.autoInstall, + supported: true, + strategy: 'wsl', + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'No distro yet.', + }, + }, + snapshot: idleSnapshot, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(installWslResult.installLabel).toBe('Install WSL'); + expect(installUbuntuResult.installLabel).toBe('Install Ubuntu in WSL'); + expect(installWslResult.installButtonPrimary).toBe(true); + expect(installUbuntuResult.installButtonPrimary).toBe(true); + }); + + it('uses a specific Windows external-step message as the title', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + autoInstall: { + ...baseStatus.autoInstall, + supported: true, + strategy: 'wsl', + }, + }, + snapshot: { + ...idleSnapshot, + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Finish Ubuntu setup in WSL', + detail: + 'Ubuntu installation was started, but Windows has not exposed the distro to the app yet.', + }, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.title).toBe('Finish Ubuntu setup in WSL'); + expect(result.progressPercent).toBe(48); + expect(result.installDisabled).toBe(true); + expect(result.showRefreshButton).toBe(true); + }); + + it('shows a restart state when external-step details already require a reboot', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + autoInstall: { + ...baseStatus.autoInstall, + supported: true, + strategy: 'wsl', + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: null, + }, + }, + snapshot: { + ...idleSnapshot, + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Checking WSL after the administrator step...', + detail: + 'Требуемая операция выполнена успешно. Чтобы сделанные изменения вступили в силу, следует перезагрузить систему.', + }, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.phase).toBe('needs_restart'); + expect(result.progressPercent).toBe(96); + expect(result.installLabel).toBe('Re-check after restart'); + }); +}); diff --git a/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx new file mode 100644 index 00000000..8f6cc321 --- /dev/null +++ b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx @@ -0,0 +1,360 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useTmuxInstallerBanner } from '../useTmuxInstallerBanner'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +type HookResult = ReturnType; + +const baseStatus: TmuxStatus = { + platform: 'darwin', + nativeSupported: true, + checkedAt: new Date().toISOString(), + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'tmux improves persistent teammate reliability.', + }, + error: null, + autoInstall: { + supported: true, + strategy: 'homebrew', + packageManagerLabel: 'Homebrew', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [], + }, +}; + +const idleSnapshot: TmuxInstallerSnapshot = { + phase: 'idle', + strategy: null, + message: null, + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: [], + updatedAt: new Date().toISOString(), +}; + +let capturedHook: HookResult | null = null; +let progressListener: ((event: unknown, progress: TmuxInstallerSnapshot) => void) | null = null; + +const { mockApi } = vi.hoisted(() => ({ + mockApi: { + isElectronMode: vi.fn(() => true), + tmux: { + getStatus: vi.fn<() => Promise>(), + getInstallerSnapshot: vi.fn<() => Promise>(), + install: vi.fn<() => Promise>(), + cancelInstall: vi.fn<() => Promise>(), + submitInstallerInput: vi.fn<(input: string) => Promise>(), + onProgress: + vi.fn< + (callback: (event: unknown, progress: TmuxInstallerSnapshot) => void) => () => void + >(), + }, + openExternal: vi.fn<(url: string) => Promise>(), + }, +})); + +vi.mock('@renderer/api', () => ({ + api: mockApi, + isElectronMode: mockApi.isElectronMode, +})); + +function Harness(): React.JSX.Element | null { + capturedHook = useTmuxInstallerBanner(); + return null; +} + +describe('useTmuxInstallerBanner', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + capturedHook = null; + progressListener = null; + mockApi.isElectronMode.mockReturnValue(true); + mockApi.tmux.getStatus.mockResolvedValue(baseStatus); + mockApi.tmux.getInstallerSnapshot.mockResolvedValue(idleSnapshot); + mockApi.tmux.install.mockResolvedValue(undefined); + mockApi.tmux.cancelInstall.mockResolvedValue(undefined); + mockApi.tmux.submitInstallerInput.mockResolvedValue(undefined); + mockApi.openExternal.mockResolvedValue(undefined); + mockApi.tmux.onProgress.mockImplementation((callback) => { + progressListener = callback; + return () => { + if (progressListener === callback) { + progressListener = null; + } + }; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + progressListener = null; + capturedHook = null; + document.body.innerHTML = ''; + }); + + it('loads tmux status immediately on mount', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1); + expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1); + expect(capturedHook?.viewModel.visible).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('stays idle and hidden outside Electron mode', async () => { + mockApi.isElectronMode.mockReturnValue(false); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApi.tmux.getStatus).not.toHaveBeenCalled(); + expect(mockApi.tmux.getInstallerSnapshot).not.toHaveBeenCalled(); + expect(capturedHook?.viewModel.visible).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('refreshes tmux status again after error and cancelled progress events', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + mockApi.tmux.getStatus.mockClear(); + mockApi.tmux.getInstallerSnapshot.mockClear(); + + await act(async () => { + progressListener?.(null, { + ...idleSnapshot, + phase: 'error', + error: 'tmux install failed', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1); + expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1); + + mockApi.tmux.getStatus.mockClear(); + mockApi.tmux.getInstallerSnapshot.mockClear(); + + await act(async () => { + progressListener?.(null, { + ...idleSnapshot, + phase: 'cancelled', + message: 'tmux installation cancelled', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1); + expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the banner visible during background refreshes after installer progress updates', async () => { + let resolveStatus: ((value: TmuxStatus) => void) | null = null; + let resolveSnapshot: ((value: TmuxInstallerSnapshot) => void) | null = null; + mockApi.tmux.getStatus.mockResolvedValueOnce(baseStatus).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStatus = resolve; + }) + ); + mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }) + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(capturedHook?.viewModel.visible).toBe(true); + + await act(async () => { + progressListener?.(null, { + ...idleSnapshot, + phase: 'waiting_for_external_step', + message: 'Finish Ubuntu setup in WSL', + }); + await Promise.resolve(); + }); + + expect(capturedHook?.viewModel.visible).toBe(true); + expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step'); + + await act(async () => { + resolveStatus?.(baseStatus); + resolveSnapshot?.({ + ...idleSnapshot, + phase: 'waiting_for_external_step', + message: 'Finish Ubuntu setup in WSL', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(capturedHook?.viewModel.visible).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not let an older refreshed snapshot overwrite newer live progress', async () => { + let resolveStatus: ((value: TmuxStatus) => void) | null = null; + let resolveSnapshot: ((value: TmuxInstallerSnapshot) => void) | null = null; + const olderSnapshot = { + ...idleSnapshot, + phase: 'idle' as const, + updatedAt: '2099-04-14T10:00:00.000Z', + }; + const newerProgress = { + ...idleSnapshot, + phase: 'waiting_for_external_step' as const, + message: 'Finish Ubuntu setup in WSL', + updatedAt: '2099-04-14T10:00:05.000Z', + }; + + mockApi.tmux.getStatus.mockResolvedValueOnce(baseStatus).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStatus = resolve; + }) + ); + mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }) + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + progressListener?.(null, newerProgress); + await Promise.resolve(); + }); + + expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step'); + + await act(async () => { + resolveStatus?.({ + ...baseStatus, + checkedAt: '2099-04-14T10:00:00.000Z', + }); + resolveSnapshot?.(olderSnapshot); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step'); + expect(capturedHook?.viewModel.title).toBe('Finish Ubuntu setup in WSL'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('stores action errors instead of letting rejected installer calls disappear', async () => { + mockApi.tmux.install.mockRejectedValueOnce(new Error('bridge failed')); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + await capturedHook?.install(); + await Promise.resolve(); + }); + + expect(capturedHook?.viewModel.error).toBe('bridge failed'); + expect(capturedHook?.viewModel.body).toBe('bridge failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts new file mode 100644 index 00000000..20ab51f4 --- /dev/null +++ b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { api, isElectronMode } from '@renderer/api'; + +import { TmuxInstallerBannerAdapter } from '../adapters/TmuxInstallerBannerAdapter'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +const IDLE_SNAPSHOT: TmuxInstallerSnapshot = { + phase: 'idle', + strategy: null, + message: null, + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: [], + updatedAt: new Date(0).toISOString(), +}; + +function getIsoTimestamp(value: string | null | undefined): number { + if (!value) { + return 0; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +export function useTmuxInstallerBanner(): { + viewModel: ReturnType; + install: () => Promise; + cancel: () => Promise; + submitInput: (input: string) => Promise; + refresh: () => Promise; + toggleDetails: () => void; + openExternal: (url: string) => Promise; +} { + const electronMode = isElectronMode(); + const adapter = useMemo(() => TmuxInstallerBannerAdapter.create(), []); + const [status, setStatus] = useState(null); + const [snapshot, setSnapshot] = useState(IDLE_SNAPSHOT); + const [loading, setLoading] = useState(electronMode); + const [error, setError] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const hasLoadedRef = useRef(!electronMode); + + const getErrorMessage = useCallback((value: unknown, fallback: string): string => { + return value instanceof Error ? value.message : fallback; + }, []); + + const refresh = useCallback( + async (options?: { background?: boolean }) => { + if (!electronMode) { + setLoading(false); + return; + } + + const background = options?.background ?? hasLoadedRef.current; + if (!background) { + setLoading(true); + } + setError(null); + try { + const [nextStatus, nextSnapshot] = await Promise.all([ + api.tmux.getStatus(), + api.tmux.getInstallerSnapshot(), + ]); + setStatus((current) => + getIsoTimestamp(nextStatus.checkedAt) >= getIsoTimestamp(current?.checkedAt) + ? nextStatus + : current + ); + setSnapshot((current) => + getIsoTimestamp(nextSnapshot.updatedAt) >= getIsoTimestamp(current.updatedAt) + ? nextSnapshot + : current + ); + hasLoadedRef.current = true; + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux state'); + } finally { + if (!background) { + setLoading(false); + } + } + }, + [electronMode] + ); + + useEffect(() => { + if (!electronMode) { + setLoading(false); + return; + } + + void refresh({ background: false }); + + return api.tmux.onProgress((_event, progress) => { + setSnapshot((current) => + getIsoTimestamp(progress.updatedAt) >= getIsoTimestamp(current.updatedAt) + ? progress + : current + ); + if ( + progress.phase === 'completed' || + progress.phase === 'needs_manual_step' || + progress.phase === 'waiting_for_external_step' || + progress.phase === 'needs_restart' || + progress.phase === 'error' || + progress.phase === 'cancelled' + ) { + void refresh({ background: true }); + } + }); + }, [electronMode, refresh]); + + const install = useCallback(async () => { + if (!electronMode) { + return; + } + + setError(null); + try { + await api.tmux.install(); + } catch (nextError) { + setError(getErrorMessage(nextError, 'Failed to start tmux installation')); + } + }, [electronMode, getErrorMessage]); + + const cancel = useCallback(async () => { + if (!electronMode) { + return; + } + + setError(null); + try { + await api.tmux.cancelInstall(); + } catch (nextError) { + setError(getErrorMessage(nextError, 'Failed to cancel tmux installation')); + } + }, [electronMode, getErrorMessage]); + + const submitInput = useCallback( + async (input: string) => { + if (!electronMode) { + return false; + } + + setError(null); + try { + await api.tmux.submitInstallerInput(input); + return true; + } catch (nextError) { + setError(getErrorMessage(nextError, 'Failed to send installer input')); + return false; + } + }, + [electronMode, getErrorMessage] + ); + + const toggleDetails = useCallback(() => { + setDetailsOpen((current) => !current); + }, []); + + const openExternal = useCallback( + async (url: string) => { + if (!electronMode) { + return; + } + + setError(null); + try { + await api.openExternal(url); + } catch (nextError) { + setError(getErrorMessage(nextError, 'Failed to open the external guide')); + } + }, + [electronMode, getErrorMessage] + ); + + const viewModel = useMemo( + () => + adapter.adapt({ + status, + snapshot, + loading, + error, + detailsOpen, + }), + [adapter, detailsOpen, error, loading, snapshot, status] + ); + + return { + viewModel: electronMode ? viewModel : { ...viewModel, visible: false }, + install, + cancel, + submitInput, + refresh, + toggleDetails, + openExternal, + }; +} diff --git a/src/features/tmux-installer/renderer/index.ts b/src/features/tmux-installer/renderer/index.ts new file mode 100644 index 00000000..0cb365bd --- /dev/null +++ b/src/features/tmux-installer/renderer/index.ts @@ -0,0 +1 @@ +export { TmuxInstallerBannerView } from './ui/TmuxInstallerBannerView'; diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx new file mode 100644 index 00000000..4cbb1029 --- /dev/null +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -0,0 +1,421 @@ +import React from 'react'; + +import { + AlertTriangle, + ChevronDown, + ChevronUp, + ExternalLink, + RefreshCw, + Wrench, + XCircle, +} from 'lucide-react'; + +import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; + +const SUMMARY_TITLE = 'tmux is not installed'; +const BANNER_MIN_H = 'min-h-[4.25rem]'; + +const SourceLink = ({ + label, + url, + onOpen, +}: { + label: string; + url: string; + onOpen: (url: string) => Promise; +}): React.JSX.Element => ( + +); + +export function TmuxInstallerBannerView(): React.JSX.Element | null { + const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } = + useTmuxInstallerBanner(); + const [expanded, setExpanded] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + const [manualHintsExpanded, setManualHintsExpanded] = React.useState(false); + const previousPhaseRef = React.useRef(viewModel.phase); + + React.useEffect(() => { + if (!viewModel.acceptsInput) { + setInputValue(''); + } + }, [viewModel.acceptsInput]); + + React.useEffect(() => { + if (!viewModel.manualHintsCollapsible) { + setManualHintsExpanded(false); + } + }, [viewModel.manualHintsCollapsible]); + + React.useEffect(() => { + const previousPhase = previousPhaseRef.current; + const becameActive = + previousPhase === 'idle' && + viewModel.phase !== 'idle' && + viewModel.phase !== 'completed' && + viewModel.phase !== 'cancelled'; + + if (becameActive) { + setExpanded(true); + } + + previousPhaseRef.current = viewModel.phase; + }, [viewModel.phase]); + + if (!viewModel.visible) { + return null; + } + + const manualHintsVisible = + viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded); + const primaryGuideUrl = viewModel.primaryGuideUrl; + + return ( +
+ + + {expanded && ( +
+
+ {viewModel.title !== SUMMARY_TITLE && ( +
+ {viewModel.title} +
+ )} +

+ {viewModel.body} +

+ {viewModel.benefitsBody && ( +
+ {viewModel.benefitsBody} +
+ )} + {(viewModel.platformLabel || + viewModel.locationLabel || + viewModel.runtimeReadyLabel || + viewModel.versionLabel || + viewModel.phase !== 'idle') && ( +
+ {viewModel.platformLabel && ( + + Detected OS: {viewModel.platformLabel} + + )} + {viewModel.locationLabel && ( + + Runtime path: {viewModel.locationLabel} + + )} + {viewModel.runtimeReadyLabel && ( + + {viewModel.runtimeReadyLabel} + + )} + {viewModel.versionLabel && ( + + {viewModel.versionLabel} + + )} + {viewModel.phase !== 'idle' && ( + + Phase: {viewModel.phase} + + )} +
+ )} +
+ +
+ {viewModel.installSupported && ( + + )} + {viewModel.canCancel && ( + + )} + {primaryGuideUrl && ( + + )} + {viewModel.manualHintsCollapsible && ( + + )} + {viewModel.showRefreshButton && ( + + )} +
+ + {viewModel.progressPercent !== null && ( +
+
+ Installer progress + + {viewModel.progressPercent}% + +
+
+
+
+
+ )} + + {viewModel.acceptsInput && ( +
+
{ + event.preventDefault(); + void (async () => { + const submitted = await submitInput(inputValue); + if (submitted) { + setInputValue(''); + } + })(); + }} + > + setInputValue(event.target.value)} + placeholder={viewModel.inputPrompt ?? 'Send input to the installer'} + className="min-w-0 flex-1 rounded-md border px-3 py-2 text-sm" + style={{ + borderColor: 'var(--color-border)', + backgroundColor: 'rgba(0, 0, 0, 0.12)', + color: 'var(--color-text)', + }} + autoComplete="current-password" + /> + +
+ {viewModel.inputSecret && ( +
+ Password input is sent directly to the installer terminal and is not added to the + log output. +
+ )} +
+ )} + + {manualHintsVisible && ( +
+ {viewModel.manualHints.map((hint) => ( +
+
+ {hint.title} +
+
+ {hint.description} +
+ {hint.command && ( + + {hint.command} + + )} + {hint.url && ( +
+ +
+ )} +
+ ))} +
+ )} + + {(viewModel.logs.length > 0 || viewModel.error) && ( +
+ + {viewModel.detailsOpen && ( +
+                  {[viewModel.error, ...viewModel.logs].filter(Boolean).join('\n')}
+                
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx new file mode 100644 index 00000000..3c0ee931 --- /dev/null +++ b/src/features/tmux-installer/renderer/ui/__tests__/TmuxInstallerBannerView.test.tsx @@ -0,0 +1,196 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TmuxInstallerBannerView } from '../TmuxInstallerBannerView'; + +import type { TmuxInstallerBannerViewModel } from '../../adapters/TmuxInstallerBannerAdapter'; + +const { mockUseTmuxInstallerBanner } = vi.hoisted(() => ({ + mockUseTmuxInstallerBanner: vi.fn(), +})); + +vi.mock('../../hooks/useTmuxInstallerBanner', () => ({ + useTmuxInstallerBanner: mockUseTmuxInstallerBanner, +})); + +const baseViewModel: TmuxInstallerBannerViewModel = { + visible: true, + loading: false, + title: 'tmux is not installed', + body: 'WSL is available, but no Linux distribution is installed yet.', + benefitsBody: + 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable.', + error: null, + platformLabel: 'Windows', + locationLabel: null, + runtimeReadyLabel: null, + versionLabel: null, + phase: 'idle', + progressPercent: null, + logs: [], + manualHints: [ + { + title: 'Install WSL', + description: 'Install Windows Subsystem for Linux.', + command: 'wsl --install --no-distribution', + }, + { + title: 'Install Ubuntu', + description: 'Recommended WSL distro.', + command: 'wsl --install -d Ubuntu --no-launch', + }, + ], + manualHintsCollapsible: true, + primaryGuideUrl: 'https://example.com/guide', + installSupported: true, + installDisabled: false, + installLabel: 'Install Ubuntu in WSL', + installButtonPrimary: true, + showRefreshButton: true, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + detailsOpen: false, +}; + +function renderBanner(viewModel: TmuxInstallerBannerViewModel): { + host: HTMLDivElement; + root: ReturnType; +} { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + mockUseTmuxInstallerBanner.mockReturnValue({ + viewModel, + install: vi.fn(), + cancel: vi.fn(), + submitInput: vi.fn(), + refresh: vi.fn(), + toggleDetails: vi.fn(), + openExternal: vi.fn(), + }); + + act(() => { + root.render(React.createElement(TmuxInstallerBannerView)); + }); + + return { host, root }; +} + +describe('TmuxInstallerBannerView', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + }); + + afterEach(() => { + mockUseTmuxInstallerBanner.mockReset(); + document.body.innerHTML = ''; + }); + + it('keeps Windows setup steps collapsed by default and expands them on demand', async () => { + const { host, root } = renderBanner(baseViewModel); + + expect(host.textContent).toContain('tmux is not installed'); + expect(host.textContent).not.toContain( + 'WSL is available, but no Linux distribution is installed yet.' + ); + expect(host.textContent).not.toContain('Show setup steps (2)'); + expect(host.textContent).not.toContain('wsl --install --no-distribution'); + + const summaryButton = [...host.querySelectorAll('button')].find((button) => + button.textContent?.includes('tmux is not installed') + ); + expect(summaryButton).toBeDefined(); + + await act(async () => { + summaryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'WSL is available, but no Linux distribution is installed yet.' + ); + expect(host.textContent).toContain('Show setup steps (2)'); + expect(host.textContent).not.toContain('Hide setup steps'); + + const setupToggle = [...host.querySelectorAll('button')].find((button) => + button.textContent?.includes('Show setup steps') + ); + expect(setupToggle).toBeDefined(); + + await act(async () => { + setupToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Hide setup steps'); + expect(host.textContent).toContain('wsl --install --no-distribution'); + expect(host.textContent).toContain('wsl --install -d Ubuntu --no-launch'); + + act(() => { + root.unmount(); + }); + }); + + it('shows setup hints immediately on non-Windows platforms', () => { + const { host, root } = renderBanner({ + ...baseViewModel, + platformLabel: 'macOS', + manualHintsCollapsible: false, + manualHints: [ + { title: 'Homebrew', description: 'Recommended', command: 'brew install tmux' }, + ], + }); + + const summaryButton = [...host.querySelectorAll('button')].find((button) => + button.textContent?.includes('tmux is not installed') + ); + expect(summaryButton).toBeDefined(); + + act(() => { + summaryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(host.textContent).toContain('brew install tmux'); + expect(host.textContent).not.toContain('Show setup steps'); + + act(() => { + root.unmount(); + }); + }); + + it('auto-expands when installer flow becomes active', async () => { + const { host, root } = renderBanner(baseViewModel); + + mockUseTmuxInstallerBanner.mockReturnValue({ + viewModel: { + ...baseViewModel, + title: 'tmux needs a restart', + body: 'Restart Windows before continuing.', + phase: 'needs_restart', + progressPercent: 96, + }, + install: vi.fn(), + cancel: vi.fn(), + submitInput: vi.fn(), + refresh: vi.fn(), + toggleDetails: vi.fn(), + openExternal: vi.fn(), + }); + + await act(async () => { + root.render(React.createElement(TmuxInstallerBannerView)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Restart Windows before continuing.'); + expect(host.textContent).toContain('96%'); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts new file mode 100644 index 00000000..8cd8294f --- /dev/null +++ b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts @@ -0,0 +1,75 @@ +import type { TmuxInstallerPhase } from '@features/tmux-installer/contracts'; +import type { TmuxPlatform } from '@features/tmux-installer/contracts'; + +export function formatTmuxInstallerTitle(phase: TmuxInstallerPhase): string { + if (phase === 'preparing' || phase === 'checking') return 'Preparing tmux installation'; + if (phase === 'pending_external_elevation') return 'Waiting for an administrator step'; + if (phase === 'waiting_for_external_step') return 'Finish the external setup step'; + if (phase === 'installing') return 'Installing tmux'; + if (phase === 'verifying') return 'Verifying tmux installation'; + if (phase === 'needs_restart') return 'Restart required before tmux setup can continue'; + if (phase === 'error') return 'tmux installation failed'; + if (phase === 'needs_manual_step') return 'tmux needs a manual step'; + if (phase === 'completed') return 'tmux installed'; + if (phase === 'cancelled') return 'tmux installation cancelled'; + return 'tmux is not installed'; +} + +export function formatInstallButtonLabel(phase: TmuxInstallerPhase): string { + if (phase === 'error') return 'Retry install'; + if (phase === 'needs_manual_step') return 'Re-check'; + if (phase === 'needs_restart') return 'Re-check after restart'; + if ( + phase === 'preparing' || + phase === 'checking' || + phase === 'pending_external_elevation' || + phase === 'waiting_for_external_step' || + phase === 'installing' || + phase === 'verifying' + ) { + return 'Installing...'; + } + return 'Install tmux'; +} + +export function formatTmuxInstallerProgress(phase: TmuxInstallerPhase): number | null { + if (phase === 'checking') return 8; + if (phase === 'preparing') return 18; + if (phase === 'requesting_privileges') return 32; + if (phase === 'pending_external_elevation') return 32; + if (phase === 'waiting_for_external_step') return 48; + if (phase === 'installing') return 68; + if (phase === 'verifying') return 90; + if (phase === 'needs_restart') return 96; + if (phase === 'completed') return 100; + if (phase === 'needs_manual_step') return 82; + if (phase === 'error') return 100; + if (phase === 'cancelled') return 0; + return null; +} + +export function formatTmuxPlatformLabel(platform: TmuxPlatform | null): string | null { + if (platform === 'darwin') return 'macOS'; + if (platform === 'linux') return 'Linux'; + if (platform === 'win32') return 'Windows'; + if (platform === 'unknown') return 'Unknown OS'; + return null; +} + +export function formatTmuxLocationLabel(location: 'host' | 'wsl' | null): string | null { + if (location === 'host') return 'Host runtime'; + if (location === 'wsl') return 'WSL runtime'; + return null; +} + +export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): string | null { + if (!platform) { + return null; + } + + if (platform === 'win32') { + return 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.'; + } + + return 'Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.'; +} diff --git a/src/main/http/config.ts b/src/main/http/config.ts index c5acd2fc..09220eb6 100644 --- a/src/main/http/config.ts +++ b/src/main/http/config.ts @@ -28,15 +28,15 @@ import { createLogger } from '@shared/utils/logger'; import { validateConfigUpdatePayload } from '../ipc/configValidation'; import { validateTriggerId } from '../ipc/guards'; -import { - ConfigManager, - type NotificationTrigger, - type TriggerContentType, - type TriggerMatchField, - type TriggerMode, - type TriggerTokenType, -} from '../services'; +import { ConfigManager } from '../services/infrastructure/ConfigManager'; +import type { + NotificationTrigger, + TriggerContentType, + TriggerMatchField, + TriggerMode, + TriggerTokenType, +} from '../services/infrastructure/ConfigManager'; import type { TriggerColor } from '@shared/constants/triggerColors'; import type { FastifyInstance } from 'fastify'; diff --git a/src/main/http/index.ts b/src/main/http/index.ts index 49c64058..c5914839 100644 --- a/src/main/http/index.ts +++ b/src/main/http/index.ts @@ -5,6 +5,10 @@ * Each route file mirrors the corresponding IPC handler. */ +import { + type RecentProjectsFeatureFacade, + registerRecentProjectsHttp, +} from '@features/recent-projects/main'; import { createLogger } from '@shared/utils/logger'; import { registerConfigRoutes } from './config'; @@ -40,6 +44,7 @@ export interface HttpServices { subagentResolver: SubagentResolver; chunkBuilder: ChunkBuilder; dataCache: DataCache; + recentProjectsFeature?: RecentProjectsFeatureFacade; updaterService: UpdaterService; sshConnectionManager: SshConnectionManager; teamProvisioningService?: TeamProvisioningService; @@ -63,6 +68,9 @@ export function registerHttpRoutes( registerUtilityRoutes(app); registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback); registerUpdaterRoutes(app, services); + if (services.recentProjectsFeature) { + registerRecentProjectsHttp(app, services.recentProjectsFeature); + } registerEventRoutes(app); logger.info('All HTTP routes registered'); diff --git a/src/main/http/notifications.ts b/src/main/http/notifications.ts index 535ca925..c48646e0 100644 --- a/src/main/http/notifications.ts +++ b/src/main/http/notifications.ts @@ -14,7 +14,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { coercePageLimit, validateNotificationId } from '../ipc/guards'; -import { NotificationManager } from '../services'; +import { NotificationManager } from '../services/infrastructure/NotificationManager'; import type { FastifyInstance } from 'fastify'; diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index a3bc267c..c0244274 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -13,7 +13,7 @@ import { createLogger } from '@shared/utils/logger'; import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards'; -import { DataCache } from '../services'; +import { DataCache } from '../services/infrastructure/DataCache'; import type { SessionsByIdsOptions, SessionsPaginationOptions } from '../types'; import type { HttpServices } from './index'; diff --git a/src/main/http/ssh.ts b/src/main/http/ssh.ts index 8e3a304c..60f71734 100644 --- a/src/main/http/ssh.ts +++ b/src/main/http/ssh.ts @@ -14,7 +14,7 @@ import { createLogger } from '@shared/utils/logger'; -import { ConfigManager } from '../services'; +import { ConfigManager } from '../services/infrastructure/ConfigManager'; import type { SshConnectionConfig, diff --git a/src/main/http/utility.ts b/src/main/http/utility.ts index e3839e71..d076b558 100644 --- a/src/main/http/utility.ts +++ b/src/main/http/utility.ts @@ -14,12 +14,12 @@ import { createLogger } from '@shared/utils/logger'; import * as fsp from 'fs/promises'; import * as path from 'path'; +import { readAgentConfigs } from '../services/parsing/AgentConfigReader'; import { type ClaudeMdFileInfo, - readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd, -} from '../services'; +} from '../services/parsing/ClaudeMdReader'; import { validateFilePath } from '../utils/pathValidation'; import { countTokens } from '../utils/tokenizer'; @@ -30,13 +30,20 @@ const logger = createLogger('HTTP:utility'); /** Cached app version — read once from package.json, not every request. */ let cachedVersion: string | null = null; +function resolvePackageJsonPath(): string { + if (typeof __dirname === 'string' && __dirname.length > 0) { + return path.resolve(__dirname, '../../../package.json'); + } + + return path.resolve(process.cwd(), 'package.json'); +} + export function registerUtilityRoutes(app: FastifyInstance): void { // App version (cached — no file I/O after first call) app.get('/api/version', async () => { if (cachedVersion) return cachedVersion; try { - const pkgPath = path.resolve(__dirname, '../../../package.json'); - const content = await fsp.readFile(pkgPath, 'utf8'); + const content = await fsp.readFile(resolvePackageJsonPath(), 'utf8'); const pkg = JSON.parse(content) as { version: string }; cachedVersion = pkg.version; return cachedVersion; diff --git a/src/main/index.ts b/src/main/index.ts index 832baa3e..baa887df 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,12 @@ process.env.UV_THREADPOOL_SIZE ??= '16'; // Sentry must be the first import to capture early errors. import './sentry'; +import { + createRecentProjectsFeature, + type RecentProjectsFeatureFacade, + registerRecentProjectsIpc, + removeRecentProjectsIpc, +} from '@features/recent-projects/main'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; import { SchedulerService } from '@main/services/schedule/SchedulerService'; @@ -54,15 +60,17 @@ import { import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics'; import { parseInboxJson } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { setReviewMainWindow } from './ipc/review'; +import { setTmuxMainWindow } from './ipc/tmux'; import { ApiKeyService, + createExtensionsRuntimeAdapter, ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, @@ -102,8 +110,8 @@ import { } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { - BoardTaskActivityRecordSource, BoardTaskActivityDetailService, + BoardTaskActivityRecordSource, BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, @@ -399,6 +407,7 @@ let contextRegistry: ServiceContextRegistry; let notificationManager: NotificationManager; let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; +let recentProjectsFeature: RecentProjectsFeatureFacade; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let cliInstallerService: CliInstallerService; @@ -863,8 +872,9 @@ async function initializeServices(): Promise { const officialMcpRegistry = new OfficialMcpRegistryService(); const glamaMcpService = new GlamaMcpEnrichmentService(); const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService); - const mcpStateService = new McpInstallationStateService(); - const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(); + const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter(); + const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter); + const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter); const skillsCatalogService = new SkillsCatalogService(); const skillsMutationService = new SkillsMutationService(); skillsWatcherService = new SkillsWatcherService(); @@ -876,8 +886,11 @@ async function initializeServices(): Promise { ); // Install services — resolve binary dynamically via ClaudeBinaryResolver - const pluginInstallService = new PluginInstallService(pluginCatalogService); - const mcpInstallService = new McpInstallService(mcpAggregator); + const pluginInstallService = new PluginInstallService( + pluginCatalogService, + extensionsRuntimeAdapter + ); + const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter); const apiKeyService = new ApiKeyService(); await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS); // warmup() and ensureInstalled() are deferred to after window creation @@ -927,6 +940,11 @@ async function initializeServices(): Promise { }); teamProvisioningService.setMainWindow(mainWindow); + recentProjectsFeature = createRecentProjectsFeature({ + getActiveContext: () => contextRegistry.getActive(), + getLocalContext: () => contextRegistry.get('local'), + logger: createLogger('Feature:RecentProjects'), + }); // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. @@ -980,6 +998,7 @@ async function initializeServices(): Promise { crossTeamService, teamBackupService ?? undefined ); + registerRecentProjectsIpc(ipcMain, recentProjectsFeature); // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { @@ -1028,6 +1047,7 @@ async function startHttpServer( subagentResolver: activeContext.subagentResolver, chunkBuilder: activeContext.chunkBuilder, dataCache: activeContext.dataCache, + recentProjectsFeature, updaterService, sshConnectionManager, teamProvisioningService, @@ -1119,6 +1139,7 @@ function shutdownServices(): void { // Remove IPC handlers removeIpcHandlers(); + removeRecentProjectsIpc(ipcMain); // Dispose backup service timers teamBackupService?.dispose(); @@ -1390,6 +1411,7 @@ function createWindow(): void { if (cliInstallerService) { cliInstallerService.setMainWindow(null); } + setTmuxMainWindow(null); if (ptyTerminalService) { ptyTerminalService.setMainWindow(null); } @@ -1423,6 +1445,7 @@ function createWindow(): void { if (cliInstallerService) { cliInstallerService.setMainWindow(mainWindow); } + setTmuxMainWindow(mainWindow); if (ptyTerminalService) { ptyTerminalService.setMainWindow(mainWindow); } diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index c1ddec7b..2a2794e8 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -12,6 +12,7 @@ import { CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, + CLI_INSTALLER_VERIFY_PROVIDER_MODELS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload } from '@preload/constants/ipcChannels'; import { getErrorMessage } from '@shared/utils/errorHandling'; @@ -49,6 +50,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer export function registerCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus); ipcMain.handle(CLI_INSTALLER_GET_PROVIDER_STATUS, handleGetProviderStatus); + ipcMain.handle(CLI_INSTALLER_VERIFY_PROVIDER_MODELS, handleVerifyProviderModels); ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall); ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus); @@ -61,6 +63,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void { export function removeCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS); ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS); + ipcMain.removeHandler(CLI_INSTALLER_VERIFY_PROVIDER_MODELS); ipcMain.removeHandler(CLI_INSTALLER_INSTALL); ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS); @@ -75,7 +78,12 @@ async function handleGetStatus( _event: IpcMainInvokeEvent ): Promise> { try { + const latestSnapshot = service.getLatestStatusSnapshot(); if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { + if (latestSnapshot) { + cachedStatus = { value: latestSnapshot, at: Date.now() }; + return { success: true, data: latestSnapshot }; + } return { success: true, data: cachedStatus.value }; } @@ -172,9 +180,25 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise> { + try { + const status = await service.verifyProviderModels(providerId); + patchCachedProviderStatus(status); + return { success: true, data: status }; + } catch (error) { + const msg = getErrorMessage(error); + logger.error(`Error in cliInstaller:verifyProviderModels(${providerId}):`, msg); + return { success: false, error: msg }; + } +} + function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult { cachedStatus = null; providerStatusInFlight.clear(); ClaudeBinaryResolver.clearCache(); + service.invalidateStatusCache(); return { success: true, data: undefined }; } diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 7b51dfc1..3a3cccc0 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService { return mcpHealthDiagnostics; } -async function handleMcpDiagnose(): Promise> { - return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose()); +async function handleMcpDiagnose( + _event: IpcMainInvokeEvent, + projectPath?: string +): Promise> { + return wrapHandler('mcpDiagnose', () => + getMcpHealthDiagnostics().diagnose(typeof projectPath === 'string' ? projectPath : undefined) + ); } // ── Install/Uninstall Handlers ──────────────────────────────────────────── @@ -416,11 +421,15 @@ async function handleApiKeysDelete( async function handleApiKeysLookup( _event: IpcMainInvokeEvent, - envVarNames?: string[] + envVarNames?: string[], + projectPath?: string ): Promise> { return wrapHandler('apiKeysLookup', () => { if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required'); - return getApiKeyService().lookup(envVarNames); + return getApiKeyService().lookup( + envVarNames, + typeof projectPath === 'string' ? projectPath : undefined + ); }); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c6d9dee7..c9fed835 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -122,8 +122,8 @@ import { } from './guards'; import type { - BoardTaskActivityService, BoardTaskActivityDetailService, + BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, BoardTaskLogStreamService, @@ -141,8 +141,8 @@ import type { AttachmentFileData, AttachmentMeta, AttachmentPayload, - BoardTaskActivityEntry, BoardTaskActivityDetailResult, + BoardTaskActivityEntry, BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, @@ -1442,11 +1442,15 @@ async function handlePrepareProvisioning( _event: IpcMainInvokeEvent, cwd: unknown, providerId: unknown, - providerIds: unknown + providerIds: unknown, + selectedModels: unknown, + limitContext: unknown ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined; + let validatedSelectedModels: string[] | undefined; + let validatedLimitContext: boolean | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; @@ -1477,10 +1481,32 @@ async function handlePrepareProvisioning( } validatedProviderIds = normalized; } + if (selectedModels !== undefined) { + if (!Array.isArray(selectedModels)) { + return { success: false, error: 'selectedModels must be an array when provided' }; + } + const normalized = Array.from( + new Set( + selectedModels + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + ) + ); + validatedSelectedModels = normalized; + } + if (limitContext !== undefined) { + if (typeof limitContext !== 'boolean') { + return { success: false, error: 'limitContext must be a boolean when provided' }; + } + validatedLimitContext = limitContext; + } return wrapTeamHandler('prepareProvisioning', () => getTeamProvisioningService().prepareForProvisioning(validatedCwd, { providerId: validatedProviderId, providerIds: validatedProviderIds, + modelIds: validatedSelectedModels, + limitContext: validatedLimitContext, }) ); } diff --git a/src/main/ipc/tmux.ts b/src/main/ipc/tmux.ts index ff98ee84..0c81c82e 100644 --- a/src/main/ipc/tmux.ts +++ b/src/main/ipc/tmux.ts @@ -1,138 +1,25 @@ -import { TMUX_GET_STATUS } from '@preload/constants/ipcChannels'; -import { getErrorMessage } from '@shared/utils/errorHandling'; +import { + createTmuxInstallerFeature, + registerTmuxInstallerIpc, + removeTmuxInstallerIpc, +} from '@features/tmux-installer/main'; import { createLogger } from '@shared/utils/logger'; -import { execFile } from 'child_process'; -import type { IpcResult, TmuxPlatform, TmuxStatus } from '@shared/types'; -import type { IpcMain, IpcMainInvokeEvent } from 'electron'; +import type { BrowserWindow, IpcMain } from 'electron'; const logger = createLogger('IPC:tmux'); - -let cachedStatus: { value: TmuxStatus; at: number } | null = null; -let statusInFlight: Promise | null = null; -const STATUS_CACHE_TTL_MS = 10_000; - -function mapPlatform(platform: NodeJS.Platform): TmuxPlatform { - if (platform === 'darwin' || platform === 'linux' || platform === 'win32') { - return platform; - } - return 'unknown'; -} - -function execFileAsync( - command: string, - args: string[], - timeout: number -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - execFile(command, args, { timeout }, (error, stdout, stderr) => { - if (error) { - reject(error); - return; - } - resolve({ stdout: String(stdout), stderr: String(stderr) }); - }); - }); -} - -async function resolveBinaryPath(platform: TmuxPlatform): Promise { - const locator = platform === 'win32' ? 'where' : 'which'; - try { - const { stdout } = await execFileAsync(locator, ['tmux'], 2_000); - const firstLine = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean); - return firstLine ?? null; - } catch { - return null; - } -} - -async function computeTmuxStatus(): Promise { - const platform = mapPlatform(process.platform); - const nativeSupported = platform === 'darwin' || platform === 'linux'; - const checkedAt = new Date().toISOString(); - - try { - const { stdout, stderr } = await execFileAsync('tmux', ['-V'], 3_000); - const version = (stdout || stderr).trim() || null; - const binaryPath = await resolveBinaryPath(platform); - return { - available: true, - version, - binaryPath, - platform, - nativeSupported, - checkedAt, - error: null, - }; - } catch (error) { - const message = getErrorMessage(error); - const missing = - typeof error === 'object' && - error !== null && - 'code' in error && - ((error as { code?: string }).code === 'ENOENT' || - (error as { code?: string }).code === 'ENOEXEC'); - - if (missing) { - return { - available: false, - version: null, - binaryPath: null, - platform, - nativeSupported, - checkedAt, - error: null, - }; - } - - logger.warn(`tmux status check failed: ${message}`); - return { - available: false, - version: null, - binaryPath: null, - platform, - nativeSupported, - checkedAt, - error: message, - }; - } -} - -async function handleGetStatus(_event: IpcMainInvokeEvent): Promise> { - try { - if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { - return { success: true, data: cachedStatus.value }; - } - - if (!statusInFlight) { - statusInFlight = computeTmuxStatus() - .then((status) => { - cachedStatus = { value: status, at: Date.now() }; - return status; - }) - .finally(() => { - statusInFlight = null; - }); - } - - const status = await statusInFlight; - return { success: true, data: status }; - } catch (error) { - const message = getErrorMessage(error); - logger.error('Error in tmux:getStatus:', message); - return { success: false, error: message }; - } -} +const tmuxInstallerFeature = createTmuxInstallerFeature(); export function registerTmuxHandlers(ipcMain: IpcMain): void { - ipcMain.handle(TMUX_GET_STATUS, handleGetStatus); + registerTmuxInstallerIpc(ipcMain, tmuxInstallerFeature); logger.info('tmux handlers registered'); } export function removeTmuxHandlers(ipcMain: IpcMain): void { - ipcMain.removeHandler(TMUX_GET_STATUS); + removeTmuxInstallerIpc(ipcMain); logger.info('tmux handlers removed'); } + +export function setTmuxMainWindow(window: BrowserWindow | null): void { + tmuxInstallerFeature.setMainWindow(window); +} diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts index 35760292..19c4500a 100644 --- a/src/main/services/extensions/apikeys/ApiKeyService.ts +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -39,6 +39,7 @@ interface StoredApiKey { encrypted?: boolean; encryptionMethod?: EncryptionMethod; scope: 'user' | 'project'; + projectPath?: string; createdAt: string; updatedAt?: string; } @@ -73,6 +74,7 @@ export class ApiKeyService { envVarName: k.envVarName, maskedValue: this.mask(this.decrypt(k)), scope: k.scope, + projectPath: k.projectPath, createdAt: k.createdAt, })); } @@ -86,6 +88,9 @@ export class ApiKeyService { ); } if (!request.value) throw new Error('Key value is required'); + if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) { + throw new Error('Project-scoped API keys require a project path'); + } const keys = await this.readStore(); const now = new Date().toISOString(); @@ -101,6 +106,7 @@ export class ApiKeyService { encryptedValue: value, encryptionMethod: method, scope: request.scope, + projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined, updatedAt: now, }; delete keys[idx].encrypted; @@ -112,6 +118,7 @@ export class ApiKeyService { encryptedValue: value, encryptionMethod: method, scope: request.scope, + projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined, createdAt: now, }); } @@ -124,6 +131,7 @@ export class ApiKeyService { envVarName: saved.envVarName, maskedValue: this.mask(request.value), scope: saved.scope, + projectPath: saved.projectPath, createdAt: saved.createdAt, }; } @@ -135,25 +143,36 @@ export class ApiKeyService { await this.writeStore(filtered); } - async lookup(envVarNames: string[]): Promise { + async lookup(envVarNames: string[], projectPath?: string): Promise { if (!envVarNames.length) return []; const keys = await this.readStore(); - const nameSet = new Set(envVarNames); - return keys - .filter((k) => nameSet.has(k.envVarName)) - .map((k) => ({ - envVarName: k.envVarName, - value: this.decrypt(k), - })); + return Array.from(new Set(envVarNames)).flatMap((envVarName) => { + const preferred = this.pickPreferredKey( + keys.filter((key) => key.envVarName === envVarName), + projectPath + ); + if (!preferred) { + return []; + } + + return [ + { + envVarName: preferred.envVarName, + value: this.decrypt(preferred), + }, + ]; + }); } - async lookupPreferred(envVarName: string): Promise { + async lookupPreferred( + envVarName: string, + projectPath?: string + ): Promise { const keys = await this.readStore(); - const matching = keys.filter((key) => key.envVarName === envVarName); - const preferred = - matching.find((key) => key.scope === 'user') ?? - matching.find((key) => key.scope === 'project') ?? - null; + const preferred = this.pickPreferredKey( + keys.filter((key) => key.envVarName === envVarName), + projectPath + ); if (!preferred) { return null; @@ -280,6 +299,20 @@ export class ApiKeyService { return stored.encrypted ? 'safeStorage' : 'base64'; } + private pickPreferredKey(matching: StoredApiKey[], projectPath?: string): StoredApiKey | null { + const normalizedProjectPath = projectPath?.trim(); + if (normalizedProjectPath) { + const projectMatch = matching.find( + (key) => key.scope === 'project' && key.projectPath === normalizedProjectPath + ); + if (projectMatch) { + return projectMatch; + } + } + + return matching.find((key) => key.scope === 'user') ?? null; + } + // ── AES-256-GCM local encryption ─────────────────────────────────────── /** diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index 1e056147..d2aaf042 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -11,6 +11,11 @@ export { PluginCatalogService } from './catalog/PluginCatalogService'; export { ExtensionFacadeService } from './ExtensionFacadeService'; export { McpInstallService } from './install/McpInstallService'; export { PluginInstallService } from './install/PluginInstallService'; +export { + ClaudeExtensionsAdapter, + createExtensionsRuntimeAdapter, + MultimodelExtensionsAdapter, +} from './runtime/ExtensionsRuntimeAdapter'; export { SkillImportService } from './skills/SkillImportService'; export { SkillMetadataParser } from './skills/SkillMetadataParser'; export { SkillPlanService } from './skills/SkillPlanService'; diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 3c83c0fe..31e89219 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -9,12 +9,15 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { createLogger } from '@shared/utils/logger'; +import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import path from 'path'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; + import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { McpCustomInstallRequest, McpInstallRequest, @@ -27,7 +30,7 @@ const logger = createLogger('Extensions:McpInstall'); const SERVER_NAME_RE = /^[\w.-]{1,100}$/; /** Allowed scope values (prevent command injection) */ -const VALID_SCOPES = new Set(['local', 'user', 'project']); +const VALID_SCOPES = new Set(['local', 'user', 'project', 'global']); /** Env var key must be safe shell identifier */ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; @@ -37,8 +40,15 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/; const TIMEOUT_MS = 30_000; +function scopeRequiresProjectPath(scope?: string): boolean { + return isProjectScopedMcpScope(scope); +} + export class McpInstallService { - constructor(private readonly aggregator: McpCatalogAggregator) {} + constructor( + private readonly aggregator: McpCatalogAggregator, + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} async install(request: McpInstallRequest): Promise { const { registryId, serverName, scope, projectPath, envValues, headers } = request; @@ -55,7 +65,14 @@ export class McpInstallService { if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', - error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`, + }; + } + + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { + state: 'error', + error: `projectPath is required for ${scope} scope`, }; } @@ -169,11 +186,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stderr } = await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr) { @@ -212,6 +230,10 @@ export class McpInstallService { return { state: 'error', error: `Invalid scope: "${scope}".` }; } + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { state: 'error', error: `projectPath is required for ${scope} scope` }; + } + for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { return { state: 'error', error: `Invalid env var name: "${key}".` }; @@ -280,11 +302,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stderr } = await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr) { @@ -315,7 +338,14 @@ export class McpInstallService { if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', - error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`, + }; + } + + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { + state: 'error', + error: `projectPath is required for ${scope} scope`, }; } @@ -342,11 +372,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); return { state: 'success' }; } catch (err) { diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts index f3f91d74..93479c38 100644 --- a/src/main/services/extensions/install/PluginInstallService.ts +++ b/src/main/services/extensions/install/PluginInstallService.ts @@ -7,12 +7,14 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { createLogger } from '@shared/utils/logger'; import path from 'path'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; + import type { PluginCatalogService } from '../catalog/PluginCatalogService'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions'; const logger = createLogger('Extensions:PluginInstall'); @@ -26,8 +28,15 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']); const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos const UNINSTALL_TIMEOUT_MS = 30_000; +function scopeRequiresProjectPath(scope?: string): boolean { + return scope === 'project' || scope === 'local'; +} + export class PluginInstallService { - constructor(private readonly catalogService: PluginCatalogService) {} + constructor( + private readonly catalogService: PluginCatalogService, + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} async install(request: PluginInstallRequest): Promise { const { pluginId, scope, projectPath } = request; @@ -48,6 +57,13 @@ export class PluginInstallService { }; } + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { + state: 'error', + error: `projectPath is required for ${scope}-scoped plugin installs`, + }; + } + // 3. Resolve qualifiedName from catalog (NOT from renderer) const resolved = await this.catalogService.resolvePlugin(pluginId); if (!resolved) { @@ -84,11 +100,12 @@ export class PluginInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stdout, stderr } = await execCli(claudeBinary, args, { timeout: INSTALL_TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr && !stdout) { @@ -123,6 +140,13 @@ export class PluginInstallService { }; } + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { + state: 'error', + error: `projectPath is required for ${scope}-scoped plugin uninstalls`, + }; + } + // Resolve qualifiedName from catalog const resolved = await this.catalogService.resolvePlugin(pluginId); if (!resolved) { @@ -157,11 +181,12 @@ export class PluginInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); await execCli(claudeBinary, args, { timeout: UNINSTALL_TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); return { state: 'success' }; } catch (err) { diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts new file mode 100644 index 00000000..9fb3e4ce --- /dev/null +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -0,0 +1,134 @@ +import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv'; +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { getConfiguredCliFlavor } from '@main/services/team/cliFlavor'; +import { execCli } from '@main/utils/childProcess'; +import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; + +import { McpConfigStateReader } from './McpConfigStateReader'; +import { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput } from './mcpDiagnosticsParser'; +import { parseInstalledMcpJsonOutput } from './mcpRuntimeJson'; + +import type { CliFlavor } from '@shared/types'; +import type { InstalledMcpEntry, McpServerDiagnostic } from '@shared/types/extensions'; + +const MCP_LIST_TIMEOUT_MS = 15_000; +const MCP_DIAGNOSE_TIMEOUT_MS = 60_000; + +async function buildManagementCliEnvForBinary(binaryPath: string): Promise { + const { env } = await buildProviderAwareCliEnv({ + binaryPath, + connectionMode: 'augment', + }); + return env; +} + +export interface ExtensionsRuntimeAdapter { + readonly flavor: CliFlavor; + buildManagementCliEnv(binaryPath: string): Promise; + getInstalledMcp(projectPath?: string): Promise; + diagnoseMcp(projectPath?: string): Promise; +} + +export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter { + readonly flavor = 'claude' as const; + + constructor(private readonly stateReader = new McpConfigStateReader()) {} + + async buildManagementCliEnv(binaryPath: string): Promise { + return buildManagementCliEnvForBinary(binaryPath); + } + + async getInstalledMcp(projectPath?: string): Promise { + return this.stateReader.readInstalled(projectPath); + } + + async diagnoseMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout, stderr } = await execCli(binaryPath, ['mcp', 'list'], { + timeout: MCP_DIAGNOSE_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseMcpDiagnosticsOutput([stdout, stderr].filter(Boolean).join('\n')); + } +} + +export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter { + readonly flavor = 'agent_teams_orchestrator' as const; + + async buildManagementCliEnv(binaryPath: string): Promise { + return buildManagementCliEnvForBinary(binaryPath); + } + + async getInstalledMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], { + timeout: MCP_LIST_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseInstalledMcpJsonOutput(stdout); + } + + async diagnoseMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], { + timeout: MCP_DIAGNOSE_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseMcpDiagnosticsJsonOutput(stdout); + } +} + +class RuntimeSwitchingExtensionsAdapter implements ExtensionsRuntimeAdapter { + constructor( + private readonly claudeAdapter: ClaudeExtensionsAdapter, + private readonly multimodelAdapter: MultimodelExtensionsAdapter + ) {} + + private getActiveAdapter(): ExtensionsRuntimeAdapter { + return getConfiguredCliFlavor() === 'claude' ? this.claudeAdapter : this.multimodelAdapter; + } + + get flavor(): CliFlavor { + return this.getActiveAdapter().flavor; + } + + buildManagementCliEnv(binaryPath: string): Promise { + return this.getActiveAdapter().buildManagementCliEnv(binaryPath); + } + + getInstalledMcp(projectPath?: string): Promise { + return this.getActiveAdapter().getInstalledMcp(projectPath); + } + + diagnoseMcp(projectPath?: string): Promise { + return this.getActiveAdapter().diagnoseMcp(projectPath); + } +} + +export function createExtensionsRuntimeAdapter(): ExtensionsRuntimeAdapter { + return new RuntimeSwitchingExtensionsAdapter( + new ClaudeExtensionsAdapter(), + new MultimodelExtensionsAdapter() + ); +} diff --git a/src/main/services/extensions/runtime/McpConfigStateReader.ts b/src/main/services/extensions/runtime/McpConfigStateReader.ts new file mode 100644 index 00000000..a9302277 --- /dev/null +++ b/src/main/services/extensions/runtime/McpConfigStateReader.ts @@ -0,0 +1,101 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { getHomeDir } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; + +import type { InstalledMcpEntry } from '@shared/types/extensions'; + +const logger = createLogger('Extensions:McpConfigStateReader'); + +export class McpConfigStateReader { + async readInstalled(projectPath?: string): Promise { + const entries: InstalledMcpEntry[] = []; + const claudeConfig = await this.readClaudeConfig(); + + entries.push(...this.readUserMcpServers(claudeConfig)); + + if (projectPath) { + entries.push(...this.readLocalMcpServers(claudeConfig, projectPath)); + entries.push(...(await this.readProjectMcpServers(projectPath))); + } + + return entries; + } + + private async readClaudeConfig(): Promise | null> { + const configPath = path.join(getHomeDir(), '.claude.json'); + try { + const raw = await fs.readFile(configPath, 'utf-8'); + return JSON.parse(raw) as Record; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Failed to read MCP servers from ${configPath}:`, err); + return null; + } + } + + private readUserMcpServers(config: Record | null): InstalledMcpEntry[] { + return this.readMcpServersFromConfig(config?.mcpServers, 'user'); + } + + private readLocalMcpServers( + config: Record | null, + projectPath: string + ): InstalledMcpEntry[] { + const projects = + config && typeof config.projects === 'object' && config.projects + ? (config.projects as Record) + : null; + const projectConfig = + projects && typeof projects[projectPath] === 'object' && projects[projectPath] + ? (projects[projectPath] as Record) + : null; + return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local'); + } + + private async readProjectMcpServers(projectPath: string): Promise { + const configPath = path.join(projectPath, '.mcp.json'); + return this.readMcpServersFromFile(configPath, 'project'); + } + + private readMcpServersFromConfig( + value: unknown, + scope: 'user' | 'project' | 'local' + ): InstalledMcpEntry[] { + const mcpServers = + value && typeof value === 'object' + ? (value as Record) + : null; + if (!mcpServers) { + return []; + } + + return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => { + let transport: string | undefined; + if (config.command) transport = 'stdio'; + else if (config.url) transport = 'http'; + + return { name, scope, transport }; + }); + } + + private async readMcpServersFromFile( + filePath: string, + scope: 'user' | 'project' + ): Promise { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as Record; + return this.readMcpServersFromConfig(json.mcpServers, scope); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + logger.error(`Failed to read MCP servers from ${filePath}:`, err); + return []; + } + } +} diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts new file mode 100644 index 00000000..e35d6b7f --- /dev/null +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -0,0 +1,215 @@ +import { isInstalledMcpScope } from '@shared/utils/mcpScopes'; + +import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; + +interface McpDiagnoseJsonEntry { + name?: string; + target?: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; + transport?: string; + status?: 'connected' | 'needs-authentication' | 'failed' | 'timeout'; + statusLabel?: string; +} + +interface McpDiagnoseJsonPayload { + checkedAt?: string; + diagnostics?: McpDiagnoseJsonEntry[]; +} + +const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'`]+/gi; +const SENSITIVE_FLAG_VALUE_PATTERN = /(--[a-z0-9_-]+)(?:=([^\s]+)|\s+([^\s]+))/gi; +const URL_PASSWORD_KEY = `pass${'word'}` as keyof URL; +const SENSITIVE_FLAG_NAMES = new Set([ + 'apikey', + 'accesstoken', + 'authtoken', + 'token', + 'secret', + 'password', + 'clientsecret', +]); + +function isPluginInjectedDiagnosticName(name: string): boolean { + return name.startsWith('plugin:'); +} + +function isExtensionsManagedDiagnosticEntry(entry: { + name: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; +}): boolean { + if (isPluginInjectedDiagnosticName(entry.name)) { + return false; + } + + return entry.scope === undefined || isInstalledMcpScope(entry.scope); +} + +function isSensitiveCliFlag(flag: string): boolean { + const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, ''); + return SENSITIVE_FLAG_NAMES.has(normalizedFlag); +} + +function extractJsonObject(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); + } +} + +function parseStatusChunk(statusChunk: string): { + status: McpServerHealthStatus; + statusLabel: string; +} { + const symbol = statusChunk[0]; + const label = statusChunk.slice(1).trim() || 'Unknown'; + + switch (symbol) { + case '✓': + return { status: 'connected', statusLabel: label }; + case '!': + return { status: 'needs-authentication', statusLabel: label }; + case '✗': + return { status: 'failed', statusLabel: label }; + default: + return { status: 'unknown', statusLabel: statusChunk }; + } +} + +function redactHttpUrl(urlString: string): string { + try { + const parsed = new URL(urlString); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return urlString; + } + + const passwordField = parsed[URL_PASSWORD_KEY]; + const hasUsername = parsed.username.length > 0; + const hasPassword = Boolean(passwordField); + + if (!hasUsername && !hasPassword && !parsed.search && !parsed.hash) { + return urlString; + } + + const redactedSearchParams = new URLSearchParams(parsed.search); + for (const key of new Set(redactedSearchParams.keys())) { + redactedSearchParams.set(key, 'REDACTED'); + } + + const authPrefix = + hasUsername || hasPassword + ? `${hasUsername ? '***' : ''}${hasPassword ? `${hasUsername ? ':' : ''}***` : ''}@` + : ''; + const searchSuffix = redactedSearchParams.size > 0 ? `?${redactedSearchParams.toString()}` : ''; + const hashSuffix = parsed.hash ? '#REDACTED' : ''; + + return `${parsed.protocol}//${authPrefix}${parsed.host}${parsed.pathname}${searchSuffix}${hashSuffix}`; + } catch { + return urlString; + } +} + +function redactDiagnosticTarget(target: string): string { + return target + .replace(EMBEDDED_HTTP_URL_PATTERN, (match) => redactHttpUrl(match)) + .replace( + SENSITIVE_FLAG_VALUE_PATTERN, + (match, flag: string, inlineValue?: string, separatedValue?: string) => { + if (!isSensitiveCliFlag(flag)) { + return match; + } + + return inlineValue || separatedValue ? `${flag}=REDACTED` : `${flag} REDACTED`; + } + ); +} + +function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { + const statusSeparatorIdx = line.lastIndexOf(' - '); + if (statusSeparatorIdx === -1) { + return null; + } + + const descriptor = line.slice(0, statusSeparatorIdx).trim(); + const statusChunk = line.slice(statusSeparatorIdx + 3).trim(); + + const nameSeparatorIdx = descriptor.indexOf(': '); + if (nameSeparatorIdx === -1) { + return null; + } + + const name = descriptor.slice(0, nameSeparatorIdx).trim(); + const target = redactDiagnosticTarget(descriptor.slice(nameSeparatorIdx + 2).trim()); + if (!name || !target) { + return null; + } + + const { status, statusLabel } = parseStatusChunk(statusChunk); + + return { + name, + target, + status, + statusLabel, + rawLine: line, + checkedAt, + }; +} + +export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] { + const checkedAt = Date.now(); + + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health')) + .map((line) => parseDiagnosticLine(line, checkedAt)) + .filter((entry): entry is McpServerDiagnostic => entry !== null) + .filter((entry) => isExtensionsManagedDiagnosticEntry(entry)); +} + +export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnostic[] { + const parsed = extractJsonObject(output); + const checkedAtValue = parsed.checkedAt ? Date.parse(parsed.checkedAt) : Number.NaN; + const checkedAt = Number.isFinite(checkedAtValue) ? checkedAtValue : Date.now(); + + return (parsed.diagnostics ?? []).flatMap((entry) => { + if ( + typeof entry.name !== 'string' || + typeof entry.target !== 'string' || + typeof entry.statusLabel !== 'string' + ) { + return []; + } + + const redactedTarget = redactDiagnosticTarget(entry.target); + const normalizedStatus: McpServerHealthStatus = + entry.status === 'connected' + ? 'connected' + : entry.status === 'needs-authentication' + ? 'needs-authentication' + : entry.status === 'failed' || entry.status === 'timeout' + ? 'failed' + : 'unknown'; + + const rawLine = `${entry.name}: ${redactedTarget} - ${entry.statusLabel}`; + const diagnostic = { + name: entry.name, + target: redactedTarget, + scope: entry.scope, + transport: entry.transport, + status: normalizedStatus, + statusLabel: entry.statusLabel, + rawLine, + checkedAt, + } satisfies McpServerDiagnostic; + + return isExtensionsManagedDiagnosticEntry(diagnostic) ? [diagnostic] : []; + }); +} diff --git a/src/main/services/extensions/runtime/mcpRuntimeJson.ts b/src/main/services/extensions/runtime/mcpRuntimeJson.ts new file mode 100644 index 00000000..858f532c --- /dev/null +++ b/src/main/services/extensions/runtime/mcpRuntimeJson.ts @@ -0,0 +1,45 @@ +import { isInstalledMcpScope } from '@shared/utils/mcpScopes'; + +import type { InstalledMcpEntry } from '@shared/types/extensions'; + +interface McpListJsonServer { + name?: string; + scope?: string; + transport?: string; +} + +interface McpListJsonPayload { + servers?: McpListJsonServer[]; +} + +function extractJsonObject(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); + } +} + +export function parseInstalledMcpJsonOutput(output: string): InstalledMcpEntry[] { + const parsed = extractJsonObject(output); + + return (parsed.servers ?? []).flatMap((entry) => { + if (typeof entry.name !== 'string' || !isInstalledMcpScope(entry.scope)) { + return []; + } + + return [ + { + name: entry.name, + scope: entry.scope, + transport: typeof entry.transport === 'string' ? entry.transport : undefined, + }, + ]; + }); +} diff --git a/src/main/services/extensions/skills/SkillMetadataParser.ts b/src/main/services/extensions/skills/SkillMetadataParser.ts index 9f93b607..cef82046 100644 --- a/src/main/services/extensions/skills/SkillMetadataParser.ts +++ b/src/main/services/extensions/skills/SkillMetadataParser.ts @@ -117,7 +117,7 @@ export class SkillMetadataParser { code: 'has-scripts', message: 'This skill includes a scripts directory. Review bundled scripts before trusting it.', - severity: 'warning', + severity: 'info', }); } diff --git a/src/main/services/extensions/skills/SkillRootsResolver.ts b/src/main/services/extensions/skills/SkillRootsResolver.ts index 288b54e5..fcadf4c5 100644 --- a/src/main/services/extensions/skills/SkillRootsResolver.ts +++ b/src/main/services/extensions/skills/SkillRootsResolver.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import { getHomeDir } from '@main/utils/pathDecoder'; +import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots'; import type { SkillRootKind, SkillScope } from '@shared/types/extensions'; @@ -11,11 +12,12 @@ export interface ResolvedSkillRoot { rootPath: string; } -const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = [ - { rootKind: 'claude', segments: ['.claude', 'skills'] }, - { rootKind: 'cursor', segments: ['.cursor', 'skills'] }, - { rootKind: 'agents', segments: ['.agents', 'skills'] }, -]; +const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = SKILL_ROOT_DEFINITIONS.map( + (definition) => ({ + rootKind: definition.rootKind, + segments: [...definition.segments], + }) +); export class SkillRootsResolver { resolve(projectPath?: string): ResolvedSkillRoot[] { diff --git a/src/main/services/extensions/skills/SkillValidator.ts b/src/main/services/extensions/skills/SkillValidator.ts index a68dfb79..4de32e57 100644 --- a/src/main/services/extensions/skills/SkillValidator.ts +++ b/src/main/services/extensions/skills/SkillValidator.ts @@ -1,9 +1,12 @@ +import { formatSkillRootKind, getSkillAudience } from '@shared/utils/skillRoots'; + import type { SkillCatalogItem } from '@shared/types/extensions'; const ROOT_PRECEDENCE: Record = { claude: 0, cursor: 1, agents: 2, + codex: 3, }; export class SkillValidator { @@ -21,14 +24,14 @@ export class SkillValidator { private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] { const itemsByName = new Map(); for (const item of items) { - const key = item.name.trim().toLowerCase(); + const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`; const bucket = itemsByName.get(key) ?? []; bucket.push(item); itemsByName.set(key, bucket); } return items.map((item) => { - const key = item.name.trim().toLowerCase(); + const key = `${item.name.trim().toLowerCase()}::${getSkillAudience(item.rootKind)}`; const duplicates = itemsByName.get(key) ?? []; if (duplicates.length <= 1) { return item; @@ -59,6 +62,7 @@ export class SkillValidator { } private formatRootLabel(item: SkillCatalogItem): string { - return item.scope === 'project' ? `project .${item.rootKind}` : `.${item.rootKind}`; + const rootLabel = formatSkillRootKind(item.rootKind); + return item.scope === 'project' ? `project ${rootLabel}` : rootLabel; } } diff --git a/src/main/services/extensions/state/McpHealthDiagnosticsService.ts b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts index 1000adc9..c926cc62 100644 --- a/src/main/services/extensions/state/McpHealthDiagnosticsService.ts +++ b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts @@ -1,97 +1,27 @@ /** - * Runs `claude mcp list` and parses per-server health statuses. + * Resolves MCP diagnostics through the active runtime adapter. + * + * Direct Claude mode parses `claude mcp list` text output. + * Multimodel mode uses the structured `mcp diagnose --json` runtime contract. */ -import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; -import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; -import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; -import { createLogger } from '@shared/utils/logger'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import { + parseMcpDiagnosticsJsonOutput, + parseMcpDiagnosticsOutput, +} from '../runtime/mcpDiagnosticsParser'; -import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import type { McpServerDiagnostic } from '@shared/types/extensions'; -const logger = createLogger('Extensions:McpHealthDiagnostics'); - -const TIMEOUT_MS = 30_000; +export { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput }; export class McpHealthDiagnosticsService { - async diagnose(): Promise { - const claudeBinary = await ClaudeBinaryResolver.resolve(); - if (!claudeBinary) { - throw new Error(CLI_NOT_FOUND_MESSAGE); - } + constructor( + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} - const { stdout, stderr } = await execCli(claudeBinary, ['mcp', 'list'], { - timeout: TIMEOUT_MS, - env: buildEnrichedEnv(claudeBinary), - }); - - const output = [stdout, stderr].filter(Boolean).join('\n'); - const diagnostics = parseMcpDiagnosticsOutput(output); - - logger.info(`Parsed ${diagnostics.length} MCP diagnostic entries`); - return diagnostics; - } -} - -export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] { - const checkedAt = Date.now(); - - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health')) - .map((line) => parseDiagnosticLine(line, checkedAt)) - .filter((entry): entry is McpServerDiagnostic => entry !== null); -} - -function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { - const statusSeparatorIdx = line.lastIndexOf(' - '); - if (statusSeparatorIdx === -1) { - return null; - } - - const descriptor = line.slice(0, statusSeparatorIdx).trim(); - const statusChunk = line.slice(statusSeparatorIdx + 3).trim(); - - const nameSeparatorIdx = descriptor.indexOf(': '); - if (nameSeparatorIdx === -1) { - return null; - } - - const name = descriptor.slice(0, nameSeparatorIdx).trim(); - const target = descriptor.slice(nameSeparatorIdx + 2).trim(); - if (!name || !target) { - return null; - } - - const { status, statusLabel } = parseStatusChunk(statusChunk); - - return { - name, - target, - status, - statusLabel, - rawLine: line, - checkedAt, - }; -} - -function parseStatusChunk(statusChunk: string): { - status: McpServerHealthStatus; - statusLabel: string; -} { - const symbol = statusChunk[0]; - const label = statusChunk.slice(1).trim() || 'Unknown'; - - switch (symbol) { - case '✓': - return { status: 'connected', statusLabel: label }; - case '!': - return { status: 'needs-authentication', statusLabel: label }; - case '✗': - return { status: 'failed', statusLabel: label }; - default: - return { status: 'unknown', statusLabel: statusChunk }; + async diagnose(projectPath?: string): Promise { + return this.runtimeAdapter.diagnoseMcp(projectPath); } } diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts index f947f190..5aee9f75 100644 --- a/src/main/services/extensions/state/McpInstallationStateService.ts +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -1,24 +1,15 @@ /** - * Reads installed MCP server state from the filesystem. + * Resolves installed MCP server state through the active runtime adapter. * - * Sources: - * - User scope: ~/.claude.json → mcpServers - * - Project scope: .mcp.json in project root - * - Local scope: determined by Claude CLI (may also be in ~/.claude.json) - * - * Both files are managed by the Claude CLI. This service is read-only. + * Direct Claude mode reads CLI-managed config files. + * Multimodel mode uses the structured `mcp list --json` runtime contract. */ -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; - -import { getHomeDir } from '@main/utils/pathDecoder'; -import { createLogger } from '@shared/utils/logger'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { InstalledMcpEntry } from '@shared/types/extensions'; -const logger = createLogger('Extensions:McpState'); - const CACHE_TTL_MS = 10_000; // 10 seconds interface TimedCache { @@ -27,80 +18,25 @@ interface TimedCache { } export class McpInstallationStateService { - private cache: TimedCache | null = null; + private cache = new Map>(); + + constructor( + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} - /** - * Get all installed MCP servers across user and project scopes. - */ async getInstalled(projectPath?: string): Promise { - // Cache is project-path-dependent, so invalidate on path change - if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { - return this.cache.data; + const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.data; } - const entries: InstalledMcpEntry[] = []; - - // User scope: ~/.claude.json - const userEntries = await this.readUserMcpServers(); - entries.push(...userEntries); - - // Project scope: .mcp.json - if (projectPath) { - const projectEntries = await this.readProjectMcpServers(projectPath); - entries.push(...projectEntries); - } - - this.cache = { data: entries, fetchedAt: Date.now() }; + const entries = await this.runtimeAdapter.getInstalledMcp(projectPath); + this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); return entries; } - /** - * Invalidate cache. Call after install/uninstall operations. - */ invalidateCache(): void { - this.cache = null; - } - - // ── Private ──────────────────────────────────────────────────────────── - - private async readUserMcpServers(): Promise { - const configPath = path.join(getHomeDir(), '.claude.json'); - return this.readMcpServersFromFile(configPath, 'user'); - } - - private async readProjectMcpServers(projectPath: string): Promise { - const configPath = path.join(projectPath, '.mcp.json'); - return this.readMcpServersFromFile(configPath, 'project'); - } - - private async readMcpServersFromFile( - filePath: string, - scope: 'user' | 'project' - ): Promise { - try { - const raw = await fs.readFile(filePath, 'utf-8'); - const json = JSON.parse(raw) as Record; - const mcpServers = json.mcpServers as - | Record - | undefined; - - if (!mcpServers || typeof mcpServers !== 'object') { - return []; - } - - return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => { - let transport: string | undefined; - if (config.command) transport = 'stdio'; - else if (config.url) transport = 'http'; - - return { name, scope, transport }; - }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return []; - } - logger.error(`Failed to read MCP servers from ${filePath}:`, err); - return []; - } + this.cache.clear(); } } diff --git a/src/main/services/extensions/state/PluginInstallationStateService.ts b/src/main/services/extensions/state/PluginInstallationStateService.ts index 382b07f8..42c6c1a8 100644 --- a/src/main/services/extensions/state/PluginInstallationStateService.ts +++ b/src/main/services/extensions/state/PluginInstallationStateService.ts @@ -60,23 +60,26 @@ interface TimedCache { // ── Service ──────────────────────────────────────────────────────────────── export class PluginInstallationStateService { - private installedCache: TimedCache | null = null; + private installedCache = new Map>(); private countsCache: TimedCache> | null = null; /** - * Get all installed plugins across all scopes. - * Returns merged list from installed_plugins.json with scope tags. + * Get installed plugins relevant to the active context. + * Always includes user scope. Project/local entries are only included when + * they are enabled for the active project. */ - async getInstalledPlugins(_projectPath?: string): Promise { - if ( - this.installedCache && - Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS - ) { - return this.installedCache.data; + async getInstalledPlugins(projectPath?: string): Promise { + const normalizedProjectPath = + typeof projectPath === 'string' && path.isAbsolute(projectPath) ? projectPath : undefined; + const cacheKey = this.getInstalledCacheKey(normalizedProjectPath); + const cached = this.installedCache.get(cacheKey); + + if (cached && Date.now() - cached.fetchedAt < INSTALLED_STATE_TTL_MS) { + return cached.data; } - const entries = await this.readInstalledPlugins(); - this.installedCache = { data: entries, fetchedAt: Date.now() }; + const entries = await this.buildInstalledEntriesForContext(normalizedProjectPath); + this.installedCache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); return entries; } @@ -97,7 +100,7 @@ export class PluginInstallationStateService { * Invalidate all caches. Call after install/uninstall operations. */ invalidateCache(): void { - this.installedCache = null; + this.installedCache.clear(); this.countsCache = null; } @@ -107,7 +110,81 @@ export class PluginInstallationStateService { return path.join(getClaudeBasePath(), 'plugins'); } - private async readInstalledPlugins(): Promise { + private getInstalledCacheKey(projectPath?: string): string { + return projectPath ?? '__user__'; + } + + private async buildInstalledEntriesForContext( + projectPath?: string + ): Promise { + const installedMetadata = await this.readInstalledPluginMetadata(); + const metadataByKey = new Map(); + + for (const entry of installedMetadata) { + const key = this.getPluginScopeKey(entry.pluginId, entry.scope); + const matches = metadataByKey.get(key) ?? []; + matches.push(entry); + metadataByKey.set(key, matches); + } + + const userEnabled = await this.readEnabledPlugins( + path.join(getClaudeBasePath(), 'settings.json') + ); + const projectEnabled = projectPath + ? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.json')) + : new Set(); + const localEnabled = projectPath + ? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.local.json')) + : new Set(); + + return [ + ...this.buildScopedEntries('user', userEnabled, metadataByKey), + ...this.buildScopedEntries('project', projectEnabled, metadataByKey), + ...this.buildScopedEntries('local', localEnabled, metadataByKey), + ]; + } + + private buildScopedEntries( + scope: InstallScope, + enabledPlugins: Set, + metadataByKey: Map + ): InstalledPluginEntry[] { + return Array.from(enabledPlugins).map((pluginId) => { + const key = this.getPluginScopeKey(pluginId, scope); + const bestMatch = this.pickBestInstallationEntry(metadataByKey.get(key) ?? []); + + return bestMatch + ? { + ...bestMatch, + pluginId, + scope, + } + : { + pluginId, + scope, + }; + }); + } + + private getPluginScopeKey(pluginId: string, scope: InstallScope): string { + return `${pluginId}::${scope}`; + } + + private pickBestInstallationEntry(entries: InstalledPluginEntry[]): InstalledPluginEntry | null { + if (entries.length === 0) { + return null; + } + + return [...entries].sort((left, right) => { + const leftInstalledAt = Date.parse(left.installedAt ?? ''); + const rightInstalledAt = Date.parse(right.installedAt ?? ''); + const normalizedLeft = Number.isFinite(leftInstalledAt) ? leftInstalledAt : 0; + const normalizedRight = Number.isFinite(rightInstalledAt) ? rightInstalledAt : 0; + return normalizedRight - normalizedLeft; + })[0]; + } + + private async readInstalledPluginMetadata(): Promise { const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json'); try { @@ -143,6 +220,31 @@ export class PluginInstallationStateService { } } + private async readEnabledPlugins(filePath: string): Promise> { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as { + enabledPlugins?: Record | null; + }; + + if (!json.enabledPlugins || typeof json.enabledPlugins !== 'object') { + return new Set(); + } + + return new Set( + Object.entries(json.enabledPlugins) + .filter(([, enabled]) => enabled === true) + .map(([pluginId]) => pluginId) + ); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return new Set(); + } + logger.error(`Failed to read plugin settings from ${filePath}:`, err); + return new Set(); + } + } + private async readInstallCounts(): Promise> { const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json'); diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 20df1a1b..9ab0015a 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -30,6 +30,7 @@ import { } from '@main/utils/shellEnv'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { createHash } from 'crypto'; import { createWriteStream, existsSync, promises as fsp } from 'fs'; import http from 'http'; @@ -38,6 +39,11 @@ import { tmpdir } from 'os'; import { join, posix as pathPosix, win32 as pathWin32 } from 'path'; import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService'; +import { + CliProviderModelAvailabilityService, + type ProviderModelAvailabilityContext, + type ProviderModelAvailabilitySnapshot, +} from '../runtime/CliProviderModelAvailabilityService'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor'; @@ -46,6 +52,7 @@ import type { CliInstallerProgress, CliPlatform, CliProviderId, + CliProviderModelAvailability, CliProviderStatus, } from '@shared/types'; import type { BrowserWindow } from 'electron'; @@ -137,7 +144,15 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat launchError: status.launchError ?? null, providers: status.providers.map((provider) => ({ ...provider, - capabilities: { ...provider.capabilities }, + modelVerificationState: provider.modelVerificationState ?? 'idle', + modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [], + capabilities: { + ...provider.capabilities, + extensions: { + ...createDefaultCliExtensionCapabilities(), + ...provider.capabilities.extensions, + }, + }, selectedBackendId: provider.selectedBackendId ?? null, resolvedBackendId: provider.resolvedBackendId ?? null, availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [], @@ -149,6 +164,12 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat }; } +function cloneProviderModelAvailability( + modelAvailability: CliProviderModelAvailability[] | undefined +): CliProviderModelAvailability[] { + return modelAvailability?.map((item) => ({ ...item })) ?? []; +} + // ============================================================================= // Helpers // ============================================================================= @@ -328,6 +349,13 @@ export class CliInstallerService { private mainWindow: BrowserWindow | null = null; private installing = false; private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService(); + private readonly modelAvailabilityService = new CliProviderModelAvailabilityService( + (providerId, signature, snapshot) => { + this.handleProviderModelAvailabilityUpdate(providerId, signature, snapshot); + } + ); + private latestStatusSnapshot: CliInstallationStatus | null = null; + private readonly latestProviderSignatures = new Map(); private electronMetaForDiag(): Record { try { @@ -395,6 +423,16 @@ export class CliInstallerService { this.mainWindow = window; } + getLatestStatusSnapshot(): CliInstallationStatus | null { + return this.latestStatusSnapshot ? cloneCliInstallationStatus(this.latestStatusSnapshot) : null; + } + + invalidateStatusCache(): void { + this.latestStatusSnapshot = null; + this.latestProviderSignatures.clear(); + this.modelAvailabilityService.invalidate(); + } + /** * Env for CLI subprocesses: login-shell vars + consistent HOME/PATH + same config root as the app. */ @@ -428,12 +466,15 @@ export class CliInstallerService { authenticated: false, authMethod: null, verificationState: 'unknown' as const, + modelVerificationState: 'idle' as const, statusMessage: 'Checking...', models: [], + modelAvailability: [], canLoginFromUi: true, capabilities: { teamLaunch: false, oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), }, backend: null, })) @@ -457,12 +498,153 @@ export class CliInstallerService { }; } + private publishStatusSnapshot(status: CliInstallationStatus): void { + this.latestStatusSnapshot = cloneCliInstallationStatus(status); + for (const provider of this.latestStatusSnapshot.providers) { + if ( + provider.modelVerificationState === 'verifying' || + (provider.modelVerificationState === 'verified' && + (provider.modelAvailability?.length ?? 0) > 0) + ) { + this.latestProviderSignatures.set( + provider.providerId, + this.latestProviderSignatures.get(provider.providerId) ?? null + ); + } else { + this.latestProviderSignatures.set(provider.providerId, null); + } + } + this.sendProgress({ + type: 'status', + status: cloneCliInstallationStatus(this.latestStatusSnapshot), + }); + } + + private buildProviderModelAvailabilityContext( + binaryPath: string, + installedVersion: string | null, + provider: CliProviderStatus + ): ProviderModelAvailabilityContext { + return { + binaryPath, + installedVersion, + provider: { + providerId: provider.providerId, + models: [...provider.models], + supported: provider.supported, + authenticated: provider.authenticated, + authMethod: provider.authMethod, + selectedBackendId: provider.selectedBackendId ?? null, + resolvedBackendId: provider.resolvedBackendId ?? null, + capabilities: { + ...provider.capabilities, + extensions: { + ...createDefaultCliExtensionCapabilities(), + ...provider.capabilities.extensions, + }, + }, + backend: provider.backend ? { ...provider.backend } : null, + }, + }; + } + + private applyProviderModelAvailability( + binaryPath: string, + installedVersion: string | null, + providers: CliProviderStatus[] + ): CliProviderStatus[] { + return providers.map((provider) => { + const snapshot = this.modelAvailabilityService.getSnapshot( + this.buildProviderModelAvailabilityContext(binaryPath, installedVersion, provider) + ); + this.latestProviderSignatures.set(provider.providerId, snapshot.signature); + + return { + ...provider, + modelVerificationState: snapshot.modelVerificationState, + modelAvailability: cloneProviderModelAvailability(snapshot.modelAvailability), + }; + }); + } + + private applyProviderModelAvailabilityToProvider( + binaryPath: string, + installedVersion: string | null, + provider: CliProviderStatus + ): CliProviderStatus { + return this.applyProviderModelAvailability(binaryPath, installedVersion, [provider])[0]; + } + + private handleProviderModelAvailabilityUpdate( + providerId: CliProviderId, + signature: string, + snapshot: ProviderModelAvailabilitySnapshot + ): void { + if (!this.latestStatusSnapshot) { + return; + } + if (this.latestProviderSignatures.get(providerId) !== signature) { + return; + } + + const providerIndex = this.latestStatusSnapshot.providers.findIndex( + (provider) => provider.providerId === providerId + ); + if (providerIndex < 0) { + return; + } + + const nextProviders = [...this.latestStatusSnapshot.providers]; + nextProviders[providerIndex] = { + ...nextProviders[providerIndex], + modelVerificationState: snapshot.modelVerificationState, + modelAvailability: cloneProviderModelAvailability(snapshot.modelAvailability), + }; + this.latestStatusSnapshot = { + ...this.latestStatusSnapshot, + providers: nextProviders, + }; + this.publishStatusSnapshot(this.latestStatusSnapshot); + } + + private updateLatestProviderStatus(providerStatus: CliProviderStatus): void { + if ( + providerStatus.modelVerificationState !== 'verifying' && + (providerStatus.modelAvailability?.length ?? 0) <= 0 + ) { + this.latestProviderSignatures.set(providerStatus.providerId, null); + } + + if (!this.latestStatusSnapshot) { + return; + } + + const hasProvider = this.latestStatusSnapshot.providers.some( + (provider) => provider.providerId === providerStatus.providerId + ); + const nextProviders = hasProvider + ? this.latestStatusSnapshot.providers.map((provider) => + provider.providerId === providerStatus.providerId ? providerStatus : provider + ) + : [...this.latestStatusSnapshot.providers, providerStatus]; + const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null; + + this.latestStatusSnapshot = { + ...this.latestStatusSnapshot, + providers: nextProviders, + authLoggedIn: nextProviders.some((provider) => provider.authenticated), + authMethod: authenticatedProvider?.authMethod ?? null, + }; + } + // --------------------------------------------------------------------------- // Public: getStatus // --------------------------------------------------------------------------- async getStatus(): Promise { const result = this.createInitialStatus(); + this.latestProviderSignatures.clear(); + this.latestStatusSnapshot = cloneCliInstallationStatus(result); // Run the actual status gathering with an overall timeout. // On timeout, return whatever partial result was collected so far. @@ -516,7 +698,46 @@ export class CliInstallerService { return null; } - return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId); + const providerStatus = await this.multimodelBridgeService.getProviderStatus( + binaryPath, + providerId + ); + this.updateLatestProviderStatus(providerStatus); + return providerStatus; + } + + async verifyProviderModels(providerId: CliProviderId): Promise { + await resolveInteractiveShellEnv(); + + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + return null; + } + + const flavor = getConfiguredCliFlavor(); + if (flavor !== 'agent_teams_orchestrator') { + return this.getProviderStatus(providerId); + } + + const versionProbe = await this.probeCliVersion(binaryPath); + if (!versionProbe.ok) { + return null; + } + + const providerStatus = await this.multimodelBridgeService.getProviderStatus( + binaryPath, + providerId + ); + const nextProviderStatus = this.applyProviderModelAvailabilityToProvider( + binaryPath, + versionProbe.version, + providerStatus + ); + this.updateLatestProviderStatus(nextProviderStatus); + if (this.latestStatusSnapshot) { + this.publishStatusSnapshot(this.latestStatusSnapshot); + } + return nextProviderStatus; } /** @@ -543,7 +764,7 @@ export class CliInstallerService { r.installedVersion = versionProbe.version; r.launchError = null; r.authStatusChecking = true; - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); + this.publishStatusSnapshot(r); // Auth and GCS version check are independent — run in parallel. // Both mutate `r` directly so partial results survive the outer timeout. @@ -551,6 +772,7 @@ export class CliInstallerService { this.checkAuthStatus(binaryPath, r, diag), r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(), ]); + this.publishStatusSnapshot(r); } else { diag.versionError = versionProbe.error; r.installed = false; @@ -567,7 +789,7 @@ export class CliInstallerService { if (r.supportsSelfUpdate) { await this.fetchLatestVersion(r); } - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); + this.publishStatusSnapshot(r); } } else { // No binary — still check latest version for "install" prompt @@ -577,7 +799,7 @@ export class CliInstallerService { if (r.supportsSelfUpdate) { await this.fetchLatestVersion(r); } - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); + this.publishStatusSnapshot(r); } } @@ -641,8 +863,10 @@ export class CliInstallerService { authenticated: false, authMethod: null, verificationState: 'error', + modelVerificationState: 'idle', statusMessage: message, models: [], + modelAvailability: [], canLoginFromUi: false, backend: null, })); @@ -671,7 +895,7 @@ export class CliInstallerService { result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated); result.authMethod = providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null; - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); + this.publishStatusSnapshot(result); } ); result.providers = providers; @@ -679,7 +903,7 @@ export class CliInstallerService { result.authMethod = providers.find((provider) => provider.authenticated)?.authMethod ?? null; result.authStatusChecking = false; - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); + this.publishStatusSnapshot(result); } catch (error) { const msg = getErrorMessage(error); diag.authLastError = msg; diff --git a/src/main/services/infrastructure/HttpServer.ts b/src/main/services/infrastructure/HttpServer.ts index 81ad929d..8fb9f9d6 100644 --- a/src/main/services/infrastructure/HttpServer.ts +++ b/src/main/services/infrastructure/HttpServer.ts @@ -25,16 +25,21 @@ const logger = createLogger('Service:HttpServer'); */ function resolveRendererPath(): string | null { const candidates = [ - // Electron production (asarUnpack): app.asar.unpacked/out/renderer (real filesystem) - join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked'), - // Electron production (asar fallback): app.asar/out/renderer - join(__dirname, '../../out/renderer'), - // Standalone: dist-standalone/index.cjs → ../out/renderer - join(__dirname, '../out/renderer'), // Fallback: relative to cwd (dev mode, standalone) join(process.cwd(), 'out/renderer'), ]; + if (typeof __dirname === 'string') { + candidates.unshift( + // Standalone: dist-standalone/index.cjs → ../out/renderer + join(__dirname, '../out/renderer'), + // Electron production (asar fallback): app.asar/out/renderer + join(__dirname, '../../out/renderer'), + // Electron production (asarUnpack): app.asar.unpacked/out/renderer (real filesystem) + join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked') + ); + } + // Allow explicit override via env if (process.env.RENDERER_PATH) { candidates.unshift(process.env.RENDERER_PATH); diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 4e3c1db7..1f493c96 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -21,13 +21,15 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { stripMarkdown } from '@main/utils/textFormatting'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; -import { type BrowserWindow, Notification } from 'electron'; +import { Notification as ElectronNotification } from 'electron'; import { EventEmitter } from 'events'; import * as fsp from 'fs/promises'; import * as path from 'path'; import { type DetectedError } from '../error/ErrorMessageBuilder'; +import type { BrowserWindow, NotificationConstructorOptions } from 'electron'; + const logger = createLogger('Service:NotificationManager'); import { buildDetectedErrorFromTeam, @@ -93,6 +95,22 @@ const THROTTLE_MS = 5000; /** Path to notifications storage file */ const NOTIFICATIONS_PATH = path.join(getHomeDir(), '.claude', 'claude-devtools-notifications.json'); +type NotificationEventName = 'click' | 'close' | 'show' | 'failed'; + +interface NotificationInstance { + on(event: NotificationEventName, listener: (...args: unknown[]) => void): void; + show(): void; +} + +interface NotificationClass { + new (options: NotificationConstructorOptions): NotificationInstance; + isSupported(): boolean; +} + +function getNotificationClass(): NotificationClass | null { + return (ElectronNotification as NotificationClass | undefined) ?? null; +} + // ============================================================================= // NotificationManager Class // ============================================================================= @@ -110,7 +128,7 @@ export class NotificationManager extends EventEmitter { * and click handlers stop working after ~1-2 minutes. * @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html */ - private activeNotifications = new Set(); + private activeNotifications = new Set(); /** Promise that resolves when async initialization is complete. * Used by addError() to wait for notifications to be loaded from disk * before writing, preventing a race where save overwrites unloaded data. */ @@ -378,13 +396,14 @@ export class NotificationManager extends EventEmitter { * Closes over `stored` (StoredNotification) so click handler has full data. */ private showErrorNativeNotification(stored: StoredNotification): void { - if (!this.isNativeNotificationSupported()) return; + const NotificationClass = getNotificationClass(); + if (!NotificationClass || !this.isNativeNotificationSupported()) return; const config = this.configManager.getConfig(); const isMac = process.platform === 'darwin'; const truncatedMessage = stripMarkdown(stored.message).slice(0, 200); const iconPath = isMac ? undefined : getAppIconPath(); - const notification = new Notification({ + const notification = new NotificationClass({ title: 'Claude Code Error', ...(isMac ? { subtitle: stored.context.projectName } : {}), body: isMac ? truncatedMessage : `${stored.context.projectName}\n${truncatedMessage}`, @@ -408,7 +427,7 @@ export class NotificationManager extends EventEmitter { logger.debug(`[notification] shown: "Claude Code Error" — ${stored.context.projectName}`); }); notification.on('failed', (_, error) => { - logger.warn(`[notification] failed: ${error}`); + logger.warn(`[notification] failed: ${String(error)}`); cleanup(); }); @@ -423,7 +442,8 @@ export class NotificationManager extends EventEmitter { stored: StoredNotification, payload: TeamNotificationPayload ): void { - if (!this.isNativeNotificationSupported()) { + const NotificationClass = getNotificationClass(); + if (!NotificationClass || !this.isNativeNotificationSupported()) { logger.warn('[team-toast] native notifications not supported — skipping'); return; } @@ -438,7 +458,7 @@ export class NotificationManager extends EventEmitter { `[team-toast] creating: title="${payload.teamDisplayName}" summary="${payload.summary ?? ''}" bodyLen=${truncatedBody.length}` ); - const notification = new Notification({ + const notification = new NotificationClass({ title: payload.teamDisplayName, ...(isMac ? { subtitle: payload.summary } : {}), body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody, @@ -491,8 +511,9 @@ export class NotificationManager extends EventEmitter { * Guard: checks if Electron's Notification API is available. */ private isNativeNotificationSupported(): boolean { + const Notification = getNotificationClass(); if ( - typeof Notification === 'undefined' || + !Notification || typeof Notification.isSupported !== 'function' || !Notification.isSupported() ) { @@ -511,7 +532,8 @@ export class NotificationManager extends EventEmitter { * Returns a result object indicating success or failure reason. */ sendTestNotification(): { success: boolean; error?: string } { - if (!this.isNativeNotificationSupported()) { + const NotificationClass = getNotificationClass(); + if (!NotificationClass || !this.isNativeNotificationSupported()) { logger.warn('[test-notification] native notifications not supported'); return { success: false, error: 'Native notifications are not supported on this platform' }; } @@ -519,7 +541,7 @@ export class NotificationManager extends EventEmitter { const isMac = process.platform === 'darwin'; const iconPath = isMac ? undefined : getAppIconPath(); logger.debug(`[test-notification] creating Notification (platform=${process.platform})`); - const notification = new Notification({ + const notification = new NotificationClass({ title: 'Test Notification', ...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}), body: isMac @@ -541,7 +563,7 @@ export class NotificationManager extends EventEmitter { logger.debug('[notification] test notification shown successfully'); }); notification.on('failed', (_, error) => { - logger.warn(`[notification] test notification failed: ${error}`); + logger.warn(`[notification] test notification failed: ${String(error)}`); cleanup(); }); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 243de82d..53a6bb17 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,6 +1,10 @@ import { execCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; +import { + createDefaultCliExtensionCapabilities, + createLegacyRuntimeFallbackCliExtensionCapabilities, +} from '@shared/utils/providerExtensionCapabilities'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; @@ -13,6 +17,19 @@ const logger = createLogger('ClaudeMultimodelBridgeService'); const PROVIDER_STATUS_TIMEOUT_MS = 10_000; const PROVIDER_MODELS_TIMEOUT_MS = 10_000; +interface RuntimeExtensionCapabilityResponse { + status?: 'supported' | 'read-only' | 'unsupported'; + ownership?: 'shared' | 'provider-scoped'; + reason?: string | null; +} + +interface RuntimeExtensionCapabilitiesResponse { + plugins?: RuntimeExtensionCapabilityResponse; + mcp?: RuntimeExtensionCapabilityResponse; + skills?: RuntimeExtensionCapabilityResponse; + apiKeys?: RuntimeExtensionCapabilityResponse; +} + interface ProviderStatusCommandResponse { schemaVersion?: number; providers?: Record< @@ -27,6 +44,7 @@ interface ProviderStatusCommandResponse { capabilities?: { teamLaunch?: boolean; oneShot?: boolean; + extensions?: RuntimeExtensionCapabilitiesResponse; }; backend?: { kind?: string; @@ -84,6 +102,7 @@ interface UnifiedRuntimeStatusResponse { capabilities?: { teamLaunch?: boolean; oneShot?: boolean; + extensions?: RuntimeExtensionCapabilitiesResponse; }; backend?: { kind?: string; @@ -121,12 +140,15 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat authenticated: false, authMethod: null, verificationState: 'unknown', + modelVerificationState: 'idle', statusMessage: null, models: [], + modelAvailability: [], canLoginFromUi: true, capabilities: { teamLaunch: false, oneShot: false, + extensions: createLegacyRuntimeFallbackCliExtensionCapabilities(), }, selectedBackendId: null, resolvedBackendId: null, @@ -137,6 +159,41 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat }; } +function mapRuntimeExtensionCapabilities( + capabilities?: RuntimeExtensionCapabilitiesResponse +): CliProviderStatus['capabilities']['extensions'] { + const defaults = capabilities + ? createDefaultCliExtensionCapabilities() + : createLegacyRuntimeFallbackCliExtensionCapabilities(); + + return { + plugins: { + ...defaults.plugins, + status: capabilities?.plugins?.status ?? defaults.plugins.status, + ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership, + reason: capabilities?.plugins?.reason ?? defaults.plugins.reason, + }, + mcp: { + ...defaults.mcp, + status: capabilities?.mcp?.status ?? defaults.mcp.status, + ownership: capabilities?.mcp?.ownership ?? defaults.mcp.ownership, + reason: capabilities?.mcp?.reason ?? defaults.mcp.reason, + }, + skills: { + ...defaults.skills, + status: capabilities?.skills?.status ?? defaults.skills.status, + ownership: capabilities?.skills?.ownership ?? defaults.skills.ownership, + reason: capabilities?.skills?.reason ?? defaults.skills.reason, + }, + apiKeys: { + ...defaults.apiKeys, + status: capabilities?.apiKeys?.status ?? defaults.apiKeys.status, + ownership: capabilities?.apiKeys?.ownership ?? defaults.apiKeys.ownership, + reason: capabilities?.apiKeys?.reason ?? defaults.apiKeys.reason, + }, + }; +} + function extractModelIds( models: (string | { id?: string; label?: string; description?: string })[] | undefined ): string[] { @@ -201,6 +258,7 @@ export class ClaudeMultimodelBridgeService { capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, oneShot: runtimeStatus.capabilities?.oneShot === true, + extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions), }, selectedBackendId: runtimeStatus.selectedBackendId ?? null, resolvedBackendId: runtimeStatus.resolvedBackendId ?? null, @@ -323,6 +381,7 @@ export class ClaudeMultimodelBridgeService { provider.capabilities = { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }; } } catch (error) { @@ -426,6 +485,7 @@ export class ClaudeMultimodelBridgeService { capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, oneShot: runtimeStatus.capabilities?.oneShot === true, + extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions), }, backend: runtimeStatus.backend?.kind ? { diff --git a/src/main/services/runtime/CliProviderModelAvailabilityService.ts b/src/main/services/runtime/CliProviderModelAvailabilityService.ts new file mode 100644 index 00000000..75ee0d79 --- /dev/null +++ b/src/main/services/runtime/CliProviderModelAvailabilityService.ts @@ -0,0 +1,292 @@ +import { execCli } from '@main/utils/childProcess'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; + +import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; +import { + buildProviderModelProbeArgs, + classifyProviderModelProbeFailure, + getProviderModelProbeTimeoutMs, + isProviderModelProbeSuccessOutput, + normalizeProviderModelProbeFailureReason, +} from './providerModelProbe'; + +import type { CliProviderId, CliProviderModelAvailability, CliProviderStatus } from '@shared/types'; + +const logger = createLogger('CliProviderModelAvailabilityService'); +const MODEL_PROBE_CONCURRENCY = 3; + +export interface ProviderModelAvailabilityContext { + binaryPath: string; + installedVersion: string | null; + provider: Pick< + CliProviderStatus, + | 'providerId' + | 'models' + | 'supported' + | 'authenticated' + | 'authMethod' + | 'selectedBackendId' + | 'resolvedBackendId' + | 'capabilities' + | 'backend' + >; +} + +export interface ProviderModelAvailabilitySnapshot { + signature: string | null; + modelVerificationState: 'idle' | 'verifying' | 'verified'; + modelAvailability: CliProviderModelAvailability[]; +} + +interface ProviderModelAvailabilityCacheEntry { + providerId: CliProviderId; + signature: string; + snapshot: ProviderModelAvailabilitySnapshot; + envPromise: Promise; +} + +type ProviderAvailabilityUpdateHandler = ( + providerId: CliProviderId, + signature: string, + snapshot: ProviderModelAvailabilitySnapshot +) => void; + +function cloneModelAvailabilitySnapshot( + snapshot: ProviderModelAvailabilitySnapshot +): ProviderModelAvailabilitySnapshot { + return { + signature: snapshot.signature, + modelVerificationState: snapshot.modelVerificationState, + modelAvailability: snapshot.modelAvailability.map((item) => ({ ...item })), + }; +} + +function createIdleSnapshot(): ProviderModelAvailabilitySnapshot { + return { + signature: null, + modelVerificationState: 'idle', + modelAvailability: [], + }; +} + +function createCheckingSnapshot( + signature: string, + models: string[] +): ProviderModelAvailabilitySnapshot { + return { + signature, + modelVerificationState: models.length > 0 ? 'verifying' : 'verified', + modelAvailability: models.map((modelId) => ({ + modelId, + status: 'checking', + reason: null, + checkedAt: null, + })), + }; +} + +function isFinalModelAvailabilityStatus(status: CliProviderModelAvailability['status']): boolean { + return status !== 'checking'; +} + +function buildProviderSignature( + context: ProviderModelAvailabilityContext, + visibleModels: string[] +): string { + return JSON.stringify({ + binaryPath: context.binaryPath, + installedVersion: context.installedVersion ?? null, + providerId: context.provider.providerId, + authMethod: context.provider.authMethod ?? null, + selectedBackendId: context.provider.selectedBackendId ?? null, + resolvedBackendId: context.provider.resolvedBackendId ?? null, + endpointLabel: context.provider.backend?.endpointLabel ?? null, + models: visibleModels, + }); +} + +function isProviderEligibleForModelVerification( + context: ProviderModelAvailabilityContext, + visibleModels: string[] +): boolean { + return ( + (context.provider.providerId === 'codex' || context.provider.providerId === 'gemini') && + visibleModels.length > 0 && + context.provider.supported === true && + context.provider.authenticated === true && + context.provider.capabilities.oneShot === true + ); +} + +function classifyFailedProbe( + modelId: string, + error: unknown +): Pick { + const message = getErrorMessage(error).trim(); + const normalizedReason = normalizeProviderModelProbeFailureReason(message); + const lower = message.toLowerCase(); + + if (classifyProviderModelProbeFailure(message) === 'unavailable') { + return { + status: 'unavailable', + reason: normalizedReason, + }; + } + + if ( + lower.includes('timeout') || + lower.includes('timed out') || + lower.includes('etimedout') || + lower.includes('econnreset') || + lower.includes('429') || + lower.includes('500') || + lower.includes('502') || + lower.includes('503') || + lower.includes('504') + ) { + return { + status: 'unknown', + reason: normalizedReason, + }; + } + + logger.warn(`Model probe inconclusive providerModel=${modelId}: ${message}`); + return { + status: 'unknown', + reason: normalizedReason, + }; +} + +export class CliProviderModelAvailabilityService { + private readonly cache = new Map(); + private readonly queue: Array<() => void> = []; + private activeProbeCount = 0; + + constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {} + + invalidate(): void { + this.cache.clear(); + this.queue.length = 0; + } + + getSnapshot(context: ProviderModelAvailabilityContext): ProviderModelAvailabilitySnapshot { + const visibleModels = filterVisibleProviderRuntimeModels( + context.provider.providerId, + context.provider.models + ); + if (!isProviderEligibleForModelVerification(context, visibleModels)) { + return createIdleSnapshot(); + } + + const signature = buildProviderSignature(context, visibleModels); + const existing = this.cache.get(signature); + if (existing) { + return cloneModelAvailabilitySnapshot(existing.snapshot); + } + + const entry: ProviderModelAvailabilityCacheEntry = { + providerId: context.provider.providerId, + signature, + snapshot: createCheckingSnapshot(signature, visibleModels), + envPromise: buildProviderAwareCliEnv({ + binaryPath: context.binaryPath, + providerId: context.provider.providerId, + }).then((result) => result.env), + }; + this.cache.set(signature, entry); + this.startProbes(context, entry); + + return cloneModelAvailabilitySnapshot(entry.snapshot); + } + + private startProbes( + context: ProviderModelAvailabilityContext, + entry: ProviderModelAvailabilityCacheEntry + ): void { + for (const modelId of entry.snapshot.modelAvailability.map((item) => item.modelId)) { + this.enqueue(async () => { + const result = await this.probeModel(context, entry, modelId); + const index = entry.snapshot.modelAvailability.findIndex( + (item) => item.modelId === modelId + ); + if (index < 0) { + return; + } + + entry.snapshot.modelAvailability[index] = { + modelId, + checkedAt: new Date().toISOString(), + ...result, + }; + if ( + entry.snapshot.modelAvailability.every((item) => + isFinalModelAvailabilityStatus(item.status) + ) + ) { + entry.snapshot.modelVerificationState = 'verified'; + } + + this.onUpdate?.( + entry.providerId, + entry.signature, + cloneModelAvailabilitySnapshot(entry.snapshot) + ); + }); + } + } + + private enqueue(task: () => Promise): void { + this.queue.push(() => { + this.activeProbeCount += 1; + void task() + .catch((error) => { + logger.warn(`Model verification task failed: ${getErrorMessage(error)}`); + }) + .finally(() => { + this.activeProbeCount = Math.max(0, this.activeProbeCount - 1); + this.drainQueue(); + }); + }); + this.drainQueue(); + } + + private drainQueue(): void { + while (this.activeProbeCount < MODEL_PROBE_CONCURRENCY) { + const next = this.queue.shift(); + if (!next) { + return; + } + next(); + } + } + + private async probeModel( + context: ProviderModelAvailabilityContext, + entry: ProviderModelAvailabilityCacheEntry, + modelId: string + ): Promise> { + try { + const env = await entry.envPromise; + const { stdout } = await execCli(context.binaryPath, buildProviderModelProbeArgs(modelId), { + timeout: getProviderModelProbeTimeoutMs(context.provider.providerId), + env, + }); + const output = stdout.trim(); + if (isProviderModelProbeSuccessOutput(output)) { + return { + status: 'available', + reason: null, + }; + } + + return { + status: 'unknown', + reason: output || 'Model verification returned an unexpected response.', + }; + } catch (error) { + return classifyFailedProbe(modelId, error); + } + } +} diff --git a/src/main/services/runtime/providerModelProbe.ts b/src/main/services/runtime/providerModelProbe.ts new file mode 100644 index 00000000..0a4b4f7b --- /dev/null +++ b/src/main/services/runtime/providerModelProbe.ts @@ -0,0 +1,119 @@ +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +const PROVIDER_MODEL_PROBE_TIMEOUT_MS = 60_000; +const PROVIDER_MODEL_PROBE_CODEX_TIMEOUT_MS = 60_000; +const PROVIDER_MODEL_PROBE_GEMINI_TIMEOUT_MS = 15_000; +const PROVIDER_MODEL_PROBE_PROMPT = 'Output only the single word PONG.'; + +type SupportedProviderId = CliProviderId | TeamProviderId; + +function resolveProbeProviderId(providerId: SupportedProviderId | undefined): SupportedProviderId { + return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; +} + +export function getProviderModelProbePrompt(): string { + return PROVIDER_MODEL_PROBE_PROMPT; +} + +export function getProviderModelProbeExpectedOutput(): string { + return 'PONG'; +} + +export function isProviderModelProbeSuccessOutput(output: string): boolean { + return new RegExp(`\\b${getProviderModelProbeExpectedOutput()}\\b`, 'i').test(output.trim()); +} + +export function classifyProviderModelProbeFailure(message: string): 'unavailable' | 'unknown' { + const lower = message.toLowerCase(); + + if ( + lower.includes('model is not supported') || + lower.includes('model not supported') || + lower.includes('unsupported model') || + lower.includes('model is not available') || + lower.includes('model not available') || + lower.includes('model unavailable') || + lower.includes('model not found') || + lower.includes('unknown model') || + lower.includes('invalid model') + ) { + return 'unavailable'; + } + + return 'unknown'; +} + +export function isProviderModelProbeTimeoutMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('timeout running:') || + lower.includes('timed out') || + lower.includes('etimedout') || + lower.includes('did not complete') + ); +} + +export function normalizeProviderModelProbeFailureReason(message: string): string { + const trimmed = message.trim(); + if (!trimmed) { + return 'Model verification failed'; + } + + if ( + /The '[^']+' model is not supported when using Codex with a ChatGPT account\./i.test(trimmed) + ) { + return 'Not available with Codex ChatGPT subscription'; + } + if (/The requested model is not available for your account\./i.test(trimmed)) { + return 'Not available for this account'; + } + if (isProviderModelProbeTimeoutMessage(trimmed)) { + return 'Model verification timed out'; + } + + return trimmed; +} + +export function buildProviderModelProbeArgs(modelId: string): string[] { + return [ + '-p', + getProviderModelProbePrompt(), + '--output-format', + 'text', + '--model', + modelId, + '--max-turns', + '1', + '--no-session-persistence', + ]; +} + +export function getProviderModelProbeTimeoutMs( + providerId: SupportedProviderId | undefined +): number { + switch (resolveProbeProviderId(providerId)) { + case 'codex': + return PROVIDER_MODEL_PROBE_CODEX_TIMEOUT_MS; + case 'gemini': + return PROVIDER_MODEL_PROBE_GEMINI_TIMEOUT_MS; + case 'anthropic': + default: + return PROVIDER_MODEL_PROBE_TIMEOUT_MS; + } +} + +export function getProviderPreflightModel(providerId: TeamProviderId | undefined): string { + switch (resolveProbeProviderId(providerId)) { + case 'codex': + return 'gpt-5.4-mini'; + case 'gemini': + return 'gemini-2.5-flash-lite'; + case 'anthropic': + default: + return 'haiku'; + } +} + +export function buildProviderPreflightPingArgs(providerId: TeamProviderId | undefined): string[] { + return buildProviderModelProbeArgs(getProviderPreflightModel(providerId)); +} diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 7143cf53..f8c4fd49 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -32,6 +32,51 @@ const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; +function normalizeProjectPathCandidate(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveProjectPathFromConfig( + config: Pick +): string | undefined { + const direct = normalizeProjectPathCandidate(config.projectPath); + if (direct) { + return direct; + } + + const leadMemberCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd; + const leadResolved = normalizeProjectPathCandidate(leadMemberCwd); + if (leadResolved) { + return leadResolved; + } + + const distinctMemberCwds = Array.from( + new Set( + (config.members ?? []) + .map((member) => normalizeProjectPathCandidate(member.cwd)) + .filter((cwd): cwd is string => Boolean(cwd)) + ) + ); + if (distinctMemberCwds.length === 1) { + return distinctMemberCwds[0]; + } + + if (Array.isArray(config.projectPathHistory)) { + for (let i = config.projectPathHistory.length - 1; i >= 0; i--) { + const historyValue = normalizeProjectPathCandidate(config.projectPathHistory[i]); + if (historyValue) { + return historyValue; + } + } + } + + return undefined; +} + interface LaunchStateSummary { partialLaunchFailure?: true; expectedMemberCount?: number; @@ -250,10 +295,7 @@ export class TeamConfigReader { typeof config.color === 'string' && config.color.trim().length > 0 ? config.color : undefined; - projectPath = - typeof config.projectPath === 'string' && config.projectPath.trim().length > 0 - ? config.projectPath - : undefined; + projectPath = resolveProjectPathFromConfig(config); leadSessionId = typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 ? config.leadSessionId @@ -470,7 +512,8 @@ export class TeamConfigReader { if (typeof config.name !== 'string' || config.name.trim() === '') { return null; } - return config; + const resolvedProjectPath = resolveProjectPathFromConfig(config); + return resolvedProjectPath ? { ...config, projectPath: resolvedProjectPath } : config; } catch (error) { if (error instanceof FileReadTimeoutError) { logger.warn(`[getConfig] ${error.message}`); diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index c25220ac..b2dfdbe9 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -1,4 +1,3 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; @@ -14,8 +13,13 @@ import { import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; -import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types'; +import type { + MemberLogSummary, + MemberSessionLogSummary, + MemberSubagentLogSummary, +} from '@shared/types'; const logger = createLogger('Service:TeamMemberLogsFinder'); @@ -76,23 +80,32 @@ interface SubagentAttribution { firstTimestamp: string | null; } -function trimTrailingSlashes(value: string): string { - let end = value.length; - while (end > 0) { - const ch = value.charCodeAt(end - 1); - // '/' or '\' - if (ch === 47 || ch === 92) { - end--; - continue; - } - break; - } - return end === value.length ? value : value.slice(0, end); +interface RootSessionAttribution { + detectedMember: string; + description: string; + firstTimestamp: string | null; } +type LogCandidate = + | { + kind: 'subagent'; + filePath: string; + sessionId: string; + fileName: string; + } + | { + kind: 'member_session'; + filePath: string; + sessionId: string; + fileName: string; + }; + export class TeamMemberLogsFinder { private readonly fileMentionsCache = new Map(); - private readonly attributionCache = new Map(); + private readonly attributionCache = new Map< + string, + SubagentAttribution | RootSessionAttribution | null + >(); private readonly discoveryCache = new Map< string, { @@ -104,7 +117,10 @@ export class TeamMemberLogsFinder { constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), - private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( + configReader + ) ) {} async findMemberLogs( @@ -134,8 +150,8 @@ export class TeamMemberLogsFinder { } // ── Collect and parallel-scan subagent files ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); - const settled: (MemberSubagentLogSummary | null)[] = new Array(candidates.length).fill(null); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); + const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); let nextIdx = 0; const scanWorker = async (): Promise => { @@ -152,17 +168,27 @@ export class TeamMemberLogsFinder { continue; } } - const summary = await this.parseSubagentSummary( - c.filePath, - projectId, - c.sessionId, - c.fileName, - memberName, - knownMembers - ); + const summary = + c.kind === 'subagent' + ? await this.parseSubagentSummary( + c.filePath, + projectId, + c.sessionId, + c.fileName, + memberName, + knownMembers + ) + : await this.parseMemberSessionSummary( + c.filePath, + projectId, + c.sessionId, + memberName, + teamName, + knownMembers + ); if (summary) settled[idx] = summary; } catch (err) { - logger.warn(`Failed to parse subagent summary: ${c.filePath}`, err); + logger.warn(`Failed to parse member log summary: ${c.filePath}`, err); } } }; @@ -192,7 +218,7 @@ export class TeamMemberLogsFinder { this.discoveryCache.delete(teamName); } - const discovery = await this.discoverProjectSessions(teamName); + const discovery = await this.discoverProjectSessions(teamName, options); if (!discovery) { return null; } @@ -258,7 +284,7 @@ export class TeamMemberLogsFinder { const tLead = performance.now(); // ── Collect all subagent file candidates ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); // ── Parallel scan with concurrency limit ── const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); @@ -273,20 +299,41 @@ export class TeamMemberLogsFinder { if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) continue; mentionHits++; - const attribution = await this.attributeSubagent(c.filePath, knownMembers); - if (!attribution) continue; - const summary = await this.parseSubagentSummary( - c.filePath, - projectId, - c.sessionId, - c.fileName, - attribution.detectedMember, - knownMembers, - attribution - ); + const summary = + c.kind === 'subagent' + ? await (async (): Promise => { + const attribution = await this.attributeSubagent(c.filePath, knownMembers); + if (!attribution) return null; + return this.parseSubagentSummary( + c.filePath, + projectId, + c.sessionId, + c.fileName, + attribution.detectedMember, + knownMembers, + attribution + ); + })() + : await (async (): Promise => { + const attribution = await this.attributeMemberSession( + c.filePath, + teamName, + knownMembers + ); + if (!attribution) return null; + return this.parseMemberSessionSummary( + c.filePath, + projectId, + c.sessionId, + attribution.detectedMember, + teamName, + knownMembers, + attribution + ); + })(); if (summary) settled[idx] = summary; } catch (err) { - logger.warn(`Failed to scan subagent file: ${c.filePath}`, err); + logger.warn(`Failed to scan member log file: ${c.filePath}`, err); } } }; @@ -368,14 +415,18 @@ export class TeamMemberLogsFinder { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + : log.kind === 'member_session' + ? `member:${log.sessionId}` + : `lead:${log.sessionId}`; seen.add(key); } for (const log of filteredOwnerLogs) { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + : log.kind === 'member_session' + ? `member:${log.sessionId}` + : `lead:${log.sessionId}`; if (!seen.has(key)) { seen.add(key); results.push(log); @@ -455,16 +506,28 @@ export class TeamMemberLogsFinder { const sinceMs = this.deriveSinceMs(options); const { projectDir, config, sessionIds, knownMembers } = discovery; - const refs: { filePath: string; memberName: string; sortTime: number }[] = []; + const refs: { + kind: LogCandidate['kind'] | 'lead_session'; + filePath: string; + memberName: string; + sessionId: string; + sortTime: number; + }[] = []; const seen = new Set(); const leadMemberName = config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; - const pushRef = (filePath: string, memberName: string, sortTime = 0): void => { - const key = `${memberName.toLowerCase()}:${filePath}`; + const pushRef = ( + filePath: string, + memberName: string, + sortTime = 0, + kind: LogCandidate['kind'] | 'lead_session' = 'member_session', + sessionId = '' + ): void => { + const key = `${kind}:${sessionId}:${memberName.toLowerCase()}:${filePath}`; if (seen.has(key)) return; seen.add(key); - refs.push({ filePath, memberName, sortTime }); + refs.push({ kind, filePath, memberName, sessionId, sortTime }); }; if (config.leadSessionId) { @@ -473,7 +536,13 @@ export class TeamMemberLogsFinder { await fs.access(leadJsonl); if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) { const firstTimestamp = await this.probeFirstTimestamp(leadJsonl); - pushRef(leadJsonl, leadMemberName, await this.getSortTime(leadJsonl, firstTimestamp)); + pushRef( + leadJsonl, + leadMemberName, + await this.getSortTime(leadJsonl, firstTimestamp), + 'lead_session', + config.leadSessionId + ); } } catch { // file missing or unreadable @@ -482,7 +551,7 @@ export class TeamMemberLogsFinder { const tLead = performance.now(); // ── Collect all subagent file candidates ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); // ── Parallel scan with concurrency limit ── let nextIdx = 0; @@ -496,15 +565,20 @@ export class TeamMemberLogsFinder { if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) continue; mentionHits++; - const attribution = await this.attributeSubagent(c.filePath, knownMembers); + const attribution = + c.kind === 'subagent' + ? await this.attributeSubagent(c.filePath, knownMembers) + : await this.attributeMemberSession(c.filePath, teamName, knownMembers); if (!attribution) continue; pushRef( c.filePath, attribution.detectedMember, - await this.getSortTime(c.filePath, attribution.firstTimestamp) + await this.getSortTime(c.filePath, attribution.firstTimestamp), + c.kind, + c.sessionId ); } catch (err) { - logger.warn(`Failed to scan subagent file: ${c.filePath}`, err); + logger.warn(`Failed to scan member log file: ${c.filePath}`, err); } } }; @@ -585,7 +659,11 @@ export class TeamMemberLogsFinder { pushRef( log.filePath, log.memberName ?? normalizedOwner, - Number.isFinite(new Date(log.startTime).getTime()) ? new Date(log.startTime).getTime() : 0 + Number.isFinite(new Date(log.startTime).getTime()) + ? new Date(log.startTime).getTime() + : 0, + log.kind === 'lead_session' ? 'lead_session' : log.kind, + log.sessionId ); } } @@ -595,22 +673,28 @@ export class TeamMemberLogsFinder { { const refsByKey = new Map(); const leadRefs: (typeof refs)[0][] = []; + const memberSessionRefsByKey = new Map(); for (const ref of refs) { - if (ref.memberName.toLowerCase() === leadMemberName.toLowerCase()) { + if (ref.kind === 'lead_session') { leadRefs.push(ref); continue; } - const parts = ref.filePath.split(path.sep); - const subagentsIdx = parts.lastIndexOf('subagents'); - const sessionId = subagentsIdx > 0 ? parts[subagentsIdx - 1] : ''; - const key = `${sessionId}:${ref.memberName.toLowerCase()}`; + if (ref.kind === 'member_session') { + const key = `member:${ref.sessionId}:${ref.memberName.toLowerCase()}`; + const existing = memberSessionRefsByKey.get(key); + if (!existing || ref.sortTime > existing.sortTime) { + memberSessionRefsByKey.set(key, ref); + } + continue; + } + const key = `${ref.sessionId}:${ref.memberName.toLowerCase()}`; const existing = refsByKey.get(key); if (!existing || ref.sortTime > existing.sortTime) { refsByKey.set(key, ref); } } refs.length = 0; - refs.push(...leadRefs, ...refsByKey.values()); + refs.push(...leadRefs, ...memberSessionRefsByKey.values(), ...refsByKey.values()); } const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime); @@ -650,43 +734,32 @@ export class TeamMemberLogsFinder { } } - for (const sessionId of sessionIds) { - const subagentsDir = path.join(projectDir, sessionId, 'subagents'); - - let files: string[]; + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); + for (const candidate of candidates) { + let mtimeMs = 0; try { - files = await fs.readdir(subagentsDir); + mtimeMs = (await fs.stat(candidate.filePath)).mtimeMs; } catch { continue; } - - for (const file of files) { - if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; - if (file.startsWith('agent-acompact')) continue; - - const filePath = path.join(subagentsDir, file); - // Quick attribution check — only Phase 1 (no full-file streaming) - let mtimeMs = 0; - try { - mtimeMs = (await fs.stat(filePath)).mtimeMs; - } catch { - continue; - } - const attribution = await this.getCachedSubagentAttribution( - filePath, - knownMembers, - mtimeMs - ); - if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { - paths.push(filePath); - } + const attribution = + candidate.kind === 'subagent' + ? await this.getCachedSubagentAttribution(candidate.filePath, knownMembers, mtimeMs) + : await this.getCachedMemberSessionAttribution( + candidate.filePath, + teamName, + knownMembers, + mtimeMs + ); + if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { + paths.push(candidate.filePath); } } return paths; } - async listAttributedSubagentFiles( + async listAttributedMemberFiles( teamName: string ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { const discovery = await this.discoverProjectSessions(teamName); @@ -697,13 +770,7 @@ export class TeamMemberLogsFinder { typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 ? config.leadSessionId.trim() : null; - // Live teammate tool tracking should follow the current team run, not historical - // lead sessions kept in sessionHistory or lingering on disk. - const candidateSessionIds = - currentLeadSessionId && sessionIds.includes(currentLeadSessionId) - ? [currentLeadSessionId] - : sessionIds; - const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const results: { memberName: string; sessionId: string; @@ -714,12 +781,27 @@ export class TeamMemberLogsFinder { const settled = await Promise.all( candidates.map(async (candidate) => { try { + if ( + candidate.kind === 'subagent' && + currentLeadSessionId && + candidate.sessionId !== currentLeadSessionId + ) { + return null; + } const stat = await fs.stat(candidate.filePath); - const attribution = await this.getCachedSubagentAttribution( - candidate.filePath, - knownMembers, - stat.mtimeMs - ); + const attribution = + candidate.kind === 'subagent' + ? await this.getCachedSubagentAttribution( + candidate.filePath, + knownMembers, + stat.mtimeMs + ) + : await this.getCachedMemberSessionAttribution( + candidate.filePath, + teamName, + knownMembers, + stat.mtimeMs + ); if (!attribution) return null; return { memberName: attribution.detectedMember, @@ -737,7 +819,34 @@ export class TeamMemberLogsFinder { if (item) results.push(item); } - return results; + const latestRootSessionsByMember = new Map< + string, + { memberName: string; sessionId: string; filePath: string; mtimeMs: number } + >(); + const passthrough: typeof results = []; + + for (const item of results) { + if ( + !item.filePath.endsWith('.jsonl') || + item.filePath.includes(`${path.sep}subagents${path.sep}`) + ) { + passthrough.push(item); + continue; + } + const key = item.memberName.toLowerCase(); + const existing = latestRootSessionsByMember.get(key); + if (!existing || item.mtimeMs > existing.mtimeMs) { + latestRootSessionsByMember.set(key, item); + } + } + + return [...passthrough, ...latestRootSessionsByMember.values()]; + } + + async listAttributedSubagentFiles( + teamName: string + ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { + return this.listAttributedMemberFiles(teamName); } /** @@ -783,97 +892,32 @@ export class TeamMemberLogsFinder { return false; } - private async discoverProjectSessions(teamName: string): Promise<{ + private async discoverProjectSessions( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise<{ projectDir: string; projectId: string; config: NonNullable>>; sessionIds: string[]; knownMembers: Set; } | null> { - // Check discovery cache — avoids re-reading config/dirs within rapid successive calls - const cached = this.discoveryCache.get(teamName); - if (cached && cached.expiresAt > Date.now()) { - return cached.result; + if (options?.forceRefresh) { + this.discoveryCache.delete(teamName); + } else { + // Check discovery cache — avoids re-reading config/dirs within rapid successive calls + const cached = this.discoveryCache.get(teamName); + if (cached && cached.expiresAt > Date.now()) { + return cached.result; + } } - const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { - logger.debug(`No projectPath for team "${teamName}"`); + const context = await this.projectResolver.getContext(teamName, options); + if (!context) { + logger.debug(`No transcript context for team "${teamName}"`); return null; } - - const normalizedProjectPath = trimTrailingSlashes(config.projectPath); - let projectId = encodePath(normalizedProjectPath); - let baseDir = extractBaseDir(projectId); - let projectDir = path.join(getProjectsBasePath(), baseDir); - - // If the encoded directory doesn't exist (symlink/cwd mismatch), fall back to locating - // the project directory by leadSessionId which is unique and reliable. - try { - const stat = await fs.stat(projectDir); - if (!stat.isDirectory()) { - throw new Error('not a directory'); - } - } catch { - const leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : null; - if (leadSessionId) { - const projectsBase = getProjectsBasePath(); - try { - const entries = await fs.readdir(projectsBase, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const candidateDir = path.join(projectsBase, entry.name); - const leadPath = path.join(candidateDir, `${leadSessionId}.jsonl`); - try { - await fs.access(leadPath); - projectDir = candidateDir; - projectId = entry.name; - baseDir = entry.name; - break; - } catch { - // not this project - } - } - } catch { - // ignore - } - } - } - - const knownSessionIds = new Set(); - if (config.leadSessionId) { - knownSessionIds.add(config.leadSessionId); - } - if (Array.isArray(config.sessionHistory)) { - for (const sid of config.sessionHistory) { - if (typeof sid === 'string' && sid.trim().length > 0) { - knownSessionIds.add(sid.trim()); - } - } - } - - const discoveredSessionIds = await this.listSessionDirs(projectDir); - let sessionIds: string[]; - if (knownSessionIds.size > 0) { - const verified: string[] = []; - for (const sid of knownSessionIds) { - const sidDir = path.join(projectDir, sid); - try { - const stat = await fs.stat(sidDir); - if (stat.isDirectory()) verified.push(sid); - } catch { - // dir doesn't exist - } - } - // Prefer config-backed sessions first, but also include any live session dirs that have - // appeared on disk and are not yet reflected in config/sessionHistory. - sessionIds = Array.from(new Set([...verified, ...discoveredSessionIds])); - } else { - sessionIds = discoveredSessionIds; - } + const { config, projectDir, projectId, sessionIds } = context; const knownMembers = new Set( (config.members ?? []) @@ -927,16 +971,42 @@ export class TeamMemberLogsFinder { return { ...discovery, isLeadMember }; } - /** - * Collect all subagent JSONL file candidates across session directories. - * Filters out non-agent files and compact files (agent-acompact*). - */ - private async collectSubagentCandidates( + private async collectLogCandidates( projectDir: string, - sessionIds: string[] - ): Promise<{ filePath: string; sessionId: string; fileName: string }[]> { - const candidates: { filePath: string; sessionId: string; fileName: string }[] = []; + sessionIds: string[], + config: NonNullable>> + ): Promise { + const candidates: LogCandidate[] = []; + const leadSessionIds = new Set(); + if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { + leadSessionIds.add(config.leadSessionId.trim()); + } + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + if (typeof sessionId === 'string' && sessionId.trim().length > 0) { + leadSessionIds.add(sessionId.trim()); + } + } + } + for (const sessionId of sessionIds) { + const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`); + if (!leadSessionIds.has(sessionId)) { + try { + const stat = await fs.stat(mainTranscript); + if (stat.isFile()) { + candidates.push({ + kind: 'member_session', + filePath: mainTranscript, + sessionId, + fileName: path.basename(mainTranscript), + }); + } + } catch { + // missing root transcript + } + } + const subagentsDir = path.join(projectDir, sessionId, 'subagents'); let dirFiles: string[]; try { @@ -947,7 +1017,12 @@ export class TeamMemberLogsFinder { for (const f of dirFiles) { if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact')) continue; - candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f }); + candidates.push({ + kind: 'subagent', + filePath: path.join(subagentsDir, f), + sessionId, + fileName: f, + }); } } return candidates; @@ -1201,16 +1276,6 @@ export class TeamMemberLogsFinder { return null; } - private async listSessionDirs(projectDir: string): Promise { - try { - const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); - return dirEntries.filter((e) => e.isDirectory()).map((e) => e.name); - } catch { - logger.debug(`Cannot read project dir: ${projectDir}`); - return []; - } - } - private async getCachedSubagentAttribution( filePath: string, knownMembers: Set, @@ -1229,6 +1294,25 @@ export class TeamMemberLogsFinder { return attribution; } + private async getCachedMemberSessionAttribution( + filePath: string, + teamName: string, + knownMembers: Set, + mtimeMs: number + ): Promise { + const cacheKey = `${filePath}:${mtimeMs}:${teamName}:member-session`; + if (this.attributionCache.has(cacheKey)) { + return (this.attributionCache.get(cacheKey) as RootSessionAttribution | null) ?? null; + } + const attribution = await this.attributeMemberSession(filePath, teamName, knownMembers); + this.attributionCache.set(cacheKey, attribution); + if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) { + const oldestKey = this.attributionCache.keys().next().value; + if (oldestKey) this.attributionCache.delete(oldestKey); + } + return attribution; + } + private async parseSubagentSummary( filePath: string, projectId: string, @@ -1292,6 +1376,60 @@ export class TeamMemberLogsFinder { }; } + private async parseMemberSessionSummary( + filePath: string, + projectId: string, + sessionId: string, + targetMember: string, + teamName: string, + knownMembers: Set, + precomputedAttribution?: RootSessionAttribution + ): Promise { + const attribution = + precomputedAttribution ?? + (await this.attributeMemberSession(filePath, teamName, knownMembers)); + if (!attribution) { + return null; + } + + if (attribution.detectedMember.toLowerCase() !== targetMember.toLowerCase()) { + return null; + } + + const metadata = await this.streamFileMetadata(filePath); + const firstTimestamp = + metadata.firstTimestamp ?? attribution.firstTimestamp ?? (await this.getFileMtime(filePath)); + const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; + + const startTime = new Date(firstTimestamp); + const endTime = new Date(lastTimestamp); + const durationMs = endTime.getTime() - startTime.getTime(); + + let isOngoing = false; + try { + const stat = await fs.stat(filePath); + isOngoing = Date.now() - stat.mtimeMs < 60_000; + } catch { + // ignore + } + + return { + kind: 'member_session', + sessionId, + projectId, + description: attribution.description || `${targetMember} session`, + memberName: targetMember, + startTime: firstTimestamp, + durationMs: Math.max(0, durationMs), + messageCount: metadata.messageCount, + isOngoing, + filePath, + lastOutputPreview: metadata.lastOutputPreview ?? undefined, + lastThinkingPreview: metadata.lastThinkingPreview ?? undefined, + recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined, + }; + } + /** * Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals * and extract a human-readable description from the first user message. @@ -1421,6 +1559,122 @@ export class TeamMemberLogsFinder { return { detectedMember: best.member, description, firstTimestamp }; } + private async attributeMemberSession( + filePath: string, + teamName: string, + knownMembers: Set + ): Promise { + const lines: string[] = []; + + try { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + let count = 0; + for await (const line of rl) { + if (count >= ATTRIBUTION_SCAN_LINES) break; + const trimmed = line.trim(); + if (!trimmed) continue; + lines.push(trimmed); + count++; + } + rl.close(); + stream.destroy(); + } catch { + return null; + } + + if (lines.length === 0) { + return null; + } + + const normalizedTeam = teamName.trim().toLowerCase(); + let detectedMember: string | null = null; + let description = ''; + let firstTimestamp: string | null = null; + let teamMatched = false; + + for (const line of lines) { + if (!firstTimestamp) { + firstTimestamp = this.extractTimestampFromLine(line); + } + + try { + const entry = JSON.parse(line) as Record; + const directTeamName = + typeof entry.teamName === 'string' ? entry.teamName.trim().toLowerCase() : null; + if (directTeamName === normalizedTeam) { + teamMatched = true; + } + + if (!detectedMember && typeof entry.agentName === 'string') { + const normalizedMember = entry.agentName.trim().toLowerCase(); + if (normalizedMember.length > 0 && knownMembers.has(normalizedMember)) { + detectedMember = entry.agentName.trim(); + } + } + + const process = entry.process as Record | undefined; + const processTeam = process?.team as Record | undefined; + if (!detectedMember && typeof processTeam?.memberName === 'string') { + const normalizedMember = processTeam.memberName.trim().toLowerCase(); + if (normalizedMember.length > 0 && knownMembers.has(normalizedMember)) { + detectedMember = processTeam.memberName.trim(); + } + } + if (!teamMatched) { + const processTeamName = + typeof processTeam?.teamName === 'string' + ? processTeam.teamName.trim().toLowerCase() + : null; + if (processTeamName === normalizedTeam) { + teamMatched = true; + } + } + + const role = this.extractRole(entry); + const textContent = this.extractTextContent(entry); + if (!teamMatched && textContent && textContent.toLowerCase().includes(normalizedTeam)) { + if ( + textContent.toLowerCase().includes(`on team "${normalizedTeam}"`) || + textContent.toLowerCase().includes(`on team '${normalizedTeam}'`) || + textContent.toLowerCase().includes(`(${normalizedTeam})`) + ) { + teamMatched = true; + } + } + + if (role === 'user' && textContent && !description) { + const normalizedText = textContent.trim(); + if ( + normalizedText.length > 0 && + normalizedText !== 'Warmup' && + !normalizedText.startsWith('You are bootstrapping into team') && + !normalizedText.startsWith('Member briefing for ') + ) { + description = normalizedText.slice(0, 200); + } + } + } catch { + // ignore malformed lines + } + + if (teamMatched && detectedMember && description) { + break; + } + } + + if (!teamMatched || !detectedMember) { + return null; + } + + return { + detectedMember, + description: description || `${detectedMember} session`, + firstTimestamp, + }; + } + /** * Select the best detection signal by precedence. * Signals are collected in file order, so find() returns the earliest occurrence diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index d2295643..0cafac54 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -2,6 +2,7 @@ import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; +import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import type { InboxMessage, @@ -122,6 +123,7 @@ export class TeamMemberResolver { const configMemberMap = new Map< string, { + agentId?: string; agentType?: string; role?: string; workflow?: string; @@ -147,6 +149,7 @@ export class TeamMemberResolver { ? configMember.provider : undefined; configMemberMap.set(m.name.trim(), { + agentId: configMember.agentId, agentType: configMember.agentType, role: configMember.role, workflow: configMember.workflow, @@ -163,6 +166,7 @@ export class TeamMemberResolver { const metaMemberMap = new Map< string, { + agentId?: string; agentType?: string; role?: string; workflow?: string; @@ -177,6 +181,7 @@ export class TeamMemberResolver { for (const member of metaMembers) { if (typeof member?.name === 'string' && member.name.trim() !== '') { metaMemberMap.set(member.name.trim(), { + agentId: member.agentId, agentType: member.agentType, role: member.role, workflow: member.workflow, @@ -226,8 +231,10 @@ export class TeamMemberResolver { const status = this.resolveStatus(latestMessage, currentTask !== null); const configMember = configMemberMap.get(name); const metaMember = metaMemberMap.get(name); + const agentId = configMember?.agentId ?? metaMember?.agentId; members.push({ name, + agentId, status, currentTaskId: currentTask?.id ?? null, taskCount: ownedTasks.length, @@ -245,7 +252,29 @@ export class TeamMemberResolver { }); } - members.sort((a, b) => a.name.localeCompare(b.name)); + const explicitConfigOrder = new Map(); + for (const [index, member] of config.members?.entries() ?? []) { + const stableOwnerId = getStableTeamOwnerId(member); + explicitConfigOrder.set(stableOwnerId, index); + explicitConfigOrder.set(member.name, index); + } + + members.sort((a, b) => { + const aStableId = getStableTeamOwnerId(a); + const bStableId = getStableTeamOwnerId(b); + const aConfigIndex = + explicitConfigOrder.get(aStableId) ?? + explicitConfigOrder.get(a.name) ?? + Number.POSITIVE_INFINITY; + const bConfigIndex = + explicitConfigOrder.get(bStableId) ?? + explicitConfigOrder.get(b.name) ?? + Number.POSITIVE_INFINITY; + if (aConfigIndex !== bConfigIndex) { + return aConfigIndex - bConfigIndex; + } + return aStableId.localeCompare(bStableId); + }); return members; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 81072cb9..2e4bbc77 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,7 +1,8 @@ +import { killTmuxPaneForCurrentPlatformSync } from '@features/tmux-installer/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; -import { killProcessTree, spawnCli } from '@main/utils/childProcess'; +import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { encodePath, @@ -32,6 +33,7 @@ import { import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; +import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { isInboxNoiseMessage, @@ -41,6 +43,7 @@ import { } from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; +import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { parseAllTeammateMessages, @@ -66,6 +69,15 @@ import { resolveGeminiRuntimeAuth, } from '../runtime/geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; +import { + buildProviderModelProbeArgs, + buildProviderPreflightPingArgs, + classifyProviderModelProbeFailure, + getProviderModelProbeExpectedOutput, + getProviderModelProbeTimeoutMs, + isProviderModelProbeSuccessOutput, + normalizeProviderModelProbeFailureReason, +} from '../runtime/providerModelProbe'; import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; import { buildActionModeProtocol } from './actionModeInstructions'; @@ -95,6 +107,7 @@ import { } from './TeamLaunchStateEvaluator'; import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; +import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; @@ -184,9 +197,6 @@ const STDOUT_RING_LIMIT = 64 * 1024; const LOG_PROGRESS_THROTTLE_MS = 300; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; -const PREFLIGHT_TIMEOUT_MS = 60000; -const PREFLIGHT_CODEX_TIMEOUT_MS = 45000; -const PREFLIGHT_GEMINI_TIMEOUT_MS = 15000; const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; @@ -213,11 +223,6 @@ const HANDLED_STREAM_JSON_TYPES = new Set([ 'result', 'system', ]); -const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; -const PREFLIGHT_EXPECTED = 'PONG'; -const PREFLIGHT_CODEX_MODEL = 'gpt-5.4-mini'; -const PREFLIGHT_GEMINI_MODEL = 'gemini-2.5-flash-lite'; - function assertAppDeterministicBootstrapEnabled(): void { if (process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP === '1') { throw new Error( @@ -259,41 +264,36 @@ function classifyDeterministicBootstrapFailure(reason: string): { }; } -function getPreflightPingModel(providerId: TeamProviderId | undefined): string { - switch (resolveTeamProviderId(providerId)) { - case 'codex': - return PREFLIGHT_CODEX_MODEL; - case 'gemini': - return PREFLIGHT_GEMINI_MODEL; - case 'anthropic': - default: - return 'haiku'; - } -} - function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] { - return [ - '-p', - PREFLIGHT_PING_PROMPT, - '--output-format', - 'text', - '--model', - getPreflightPingModel(providerId), - '--max-turns', - '1', - '--no-session-persistence', - ]; + return buildProviderPreflightPingArgs(providerId); } function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number { - switch (resolveTeamProviderId(providerId)) { - case 'codex': - return PREFLIGHT_CODEX_TIMEOUT_MS; - case 'gemini': - return PREFLIGHT_GEMINI_TIMEOUT_MS; - case 'anthropic': - default: - return PREFLIGHT_TIMEOUT_MS; + return getProviderModelProbeTimeoutMs(providerId); +} + +interface ProviderModelListCommandResponse { + schemaVersion?: number; + providers?: Record< + string, + { + defaultModel?: string | null; + models?: (string | { id?: string; label?: string; description?: string })[]; + } + >; +} + +function extractJsonObjectFromCli(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); } } @@ -307,6 +307,21 @@ function isProbeTimeoutMessage(message: string): boolean { ); } +function isTransientModelProbeMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('timeout') || + lower.includes('timed out') || + lower.includes('etimedout') || + lower.includes('econnreset') || + lower.includes('429') || + lower.includes('500') || + lower.includes('502') || + lower.includes('503') || + lower.includes('504') + ); +} + function getTeamProviderLabel(providerId: TeamProviderId): string { switch (providerId) { case 'codex': @@ -768,6 +783,36 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { }; } +interface LiveTeamAgentRuntimeMetadata { + model?: string; +} + +function stripWrappedCliFlagValue(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + const unwrapped = trimmed.slice(1, -1).trim(); + return unwrapped.length > 0 ? unwrapped : undefined; + } + return trimmed; +} + +function extractCliFlagValue(command: string, flagName: string): string | undefined { + const escapedFlag = flagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`(?:^|\\s)${escapedFlag}\\s+("([^"]*)"|'([^']*)'|([^\\s]+))`).exec( + command + ); + if (!match) { + return undefined; + } + return stripWrappedCliFlagValue(match[2] ?? match[3] ?? match[4] ?? match[1]); +} + export function shouldAcceptDeterministicBootstrapEvent(params: { runId: string; teamName: string; @@ -896,11 +941,17 @@ function buildEffectiveTeamMemberSpec( ): TeamMemberInput { const memberProviderId = normalizeTeamMemberProviderId(member.providerId); const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); - const model = member.model?.trim() || defaults.model?.trim() || undefined; + const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic'; + const model = + member.model?.trim() || + (memberProviderId == null || memberProviderId === defaultProviderId + ? defaults.model?.trim() + : undefined) || + undefined; return { ...member, - providerId: memberProviderId ?? defaultProviderId ?? 'anthropic', + providerId: effectiveProviderId, model, effort: member.effort ?? defaults.effort, }; @@ -1044,11 +1095,68 @@ function extractBootstrapFailureReason(text: string): string | null { lower.includes('lookup failure') || lower.includes('validation error') || lower.includes('api error'))) || + lower.includes('model is not supported') || + lower.includes('model is not available') || + lower.includes('model not available') || + lower.includes('model unavailable') || + lower.includes('model not found') || + lower.includes('unknown model') || + lower.includes('invalid model') || + lower.includes('unsupported model') || + lower.includes('not supported when using codex with a chatgpt account') || lower.includes('please check the provided tool list'); if (!looksLikeBootstrapFailure) return null; return trimmed.slice(0, 280); } +function extractTranscriptTextContent(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) { + return []; + } + const parts: string[] = []; + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const record = item as { type?: unknown; text?: unknown; content?: unknown }; + if (record.type === 'text' && typeof record.text === 'string' && record.text.trim()) { + parts.push(record.text.trim()); + continue; + } + parts.push(...extractTranscriptTextContent(record.content)); + } + return parts; +} + +function extractTranscriptMessageText(record: unknown): string | null { + if (!record || typeof record !== 'object') { + return null; + } + const normalizedRecord = record as { + text?: unknown; + content?: unknown; + message?: unknown; + toolUseResult?: unknown; + }; + if (typeof normalizedRecord.text === 'string' && normalizedRecord.text.trim()) { + return normalizedRecord.text.trim(); + } + const fromContent = extractTranscriptTextContent(normalizedRecord.content); + if (fromContent.length > 0) { + return fromContent.join('\n'); + } + const fromToolUseResult = extractTranscriptTextContent(normalizedRecord.toolUseResult); + if (fromToolUseResult.length > 0) { + return fromToolUseResult.join('\n'); + } + if (normalizedRecord.message) { + return extractTranscriptMessageText(normalizedRecord.message); + } + return null; +} + function normalizeMemberDiagnosticText(memberName: string, text: string): string { return `${memberName}: ${text.trim()}`; } @@ -2089,6 +2197,7 @@ function normalizeSameTeamText(text: string): string { export class TeamProvisioningService { private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; + private static readonly BOOTSTRAP_FAILURE_TAIL_BYTES = 128 * 1024; private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000; private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000; private static readonly SAME_TEAM_NATIVE_DELIVERY_GRACE_MS = 15_000; @@ -2113,6 +2222,7 @@ export class TeamProvisioningService { NativeSameTeamFingerprint[] >(); private readonly launchStateStore = new TeamLaunchStateStore(); + private readonly memberLogsFinder: TeamMemberLogsFinder; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private helpOutputCache: string | null = null; private helpOutputCacheTime = 0; @@ -2142,7 +2252,13 @@ export class TeamProvisioningService { _sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(), private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore() - ) {} + ) { + this.memberLogsFinder = new TeamMemberLogsFinder( + this.configReader, + this.inboxReader, + this.membersMetaStore + ); + } setCrossTeamSender( sender: @@ -3316,16 +3432,19 @@ export class TeamProvisioningService { }> { const runId = this.getTrackedRunId(teamName); if (!runId) { - return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => ({ - statuses, - runId: null, - teamLaunchState: snapshot?.teamLaunchState, - launchPhase: snapshot?.launchPhase, - expectedMembers: snapshot?.expectedMembers, - updatedAt: snapshot?.updatedAt, - summary: snapshot?.summary, - source: snapshot ? 'persisted' : 'persisted', - })); + return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => { + this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); + return { + statuses, + runId: null, + teamLaunchState: snapshot?.teamLaunchState, + launchPhase: snapshot?.launchPhase, + expectedMembers: snapshot?.expectedMembers, + updatedAt: snapshot?.updatedAt, + summary: snapshot?.summary, + source: snapshot ? 'persisted' : 'persisted', + }; + }); } const run = this.runs.get(runId); if (!run) { @@ -3346,6 +3465,7 @@ export class TeamProvisioningService { }); const snapshot = persisted ?? liveSnapshot; const statuses = snapshotToMemberSpawnStatuses(snapshot); + this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); return { statuses, runId, @@ -3446,6 +3566,10 @@ export class TeamProvisioningService { run: ProvisioningRun, options?: { force?: boolean } ): Promise { + if (!run.expectedMembers || run.expectedMembers.length === 0) { + return; + } + await this.reconcileBootstrapTranscriptFailures(run); if (this.shouldSkipMemberSpawnAudit(run)) { return; } @@ -3461,6 +3585,32 @@ export class TeamProvisioningService { await this.auditMemberSpawnStatuses(run); } + private async reconcileBootstrapTranscriptFailures(run: ProvisioningRun): Promise { + for (const memberName of run.expectedMembers ?? []) { + const current = run.memberSpawnStatuses.get(memberName); + if ( + !current || + current.launchState === 'failed_to_start' || + current.launchState === 'confirmed_alive' || + current.hardFailure === true || + current.agentToolAccepted !== true + ) { + continue; + } + const acceptedAtMs = + current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason( + run.teamName, + memberName, + Number.isFinite(acceptedAtMs) ? acceptedAtMs : null + ); + if (!transcriptFailureReason) { + continue; + } + this.setMemberSpawnStatus(run, memberName, 'error', transcriptFailureReason); + } + } + private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000; private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000; @@ -3506,7 +3656,13 @@ export class TeamProvisioningService { async prepareForProvisioning( cwd?: string, - opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] } + opts?: { + forceFresh?: boolean; + providerId?: TeamProviderId; + providerIds?: TeamProviderId[]; + modelIds?: string[]; + limitContext?: boolean; + } ): Promise { const targetCwdForValidation = cwd?.trim() || process.cwd(); await this.validatePrepareCwd(targetCwdForValidation); @@ -3534,7 +3690,11 @@ export class TeamProvisioningService { } const warnings: string[] = []; + const details: string[] = []; const blockingMessages: string[] = []; + const selectedModelIds = Array.from( + new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) + ); for (const providerId of providerIds) { const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId); @@ -3554,32 +3714,47 @@ export class TeamProvisioningService { } if (!probeResult.warning) { + if (selectedModelIds.length > 0) { + const modelVerification = await this.verifySelectedProviderModels({ + claudePath: probeResult.claudePath, + cwd: targetCwd, + providerId, + modelIds: selectedModelIds, + limitContext: opts?.limitContext === true, + }); + details.push(...modelVerification.details); + warnings.push(...modelVerification.warnings); + blockingMessages.push(...modelVerification.blockingMessages); + } continue; } - const prefixedWarning = - providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning; - const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); - if (authSource === 'configured_api_key_missing') { - blockingMessages.push(prefixedWarning); - } else if ( - (authSource === 'none' || - authSource === 'codex_runtime' || - authSource === 'gemini_runtime') && - isAuthFailure - ) { - blockingMessages.push(prefixedWarning); - } else if (isBinaryProbeWarning(probeResult.warning)) { - blockingMessages.push(prefixedWarning); - } else { - // Preflight warnings (including timeouts) should not block provisioning. - warnings.push(prefixedWarning); + { + const prefixedWarning = + providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning; + const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); + if (authSource === 'configured_api_key_missing') { + blockingMessages.push(prefixedWarning); + } else if ( + (authSource === 'none' || + authSource === 'codex_runtime' || + authSource === 'gemini_runtime') && + isAuthFailure + ) { + blockingMessages.push(prefixedWarning); + } else if (isBinaryProbeWarning(probeResult.warning)) { + blockingMessages.push(prefixedWarning); + } else { + // Preflight warnings (including timeouts) should not block provisioning. + warnings.push(prefixedWarning); + } } } if (blockingMessages.length > 0) { return { ready: false, + details: details.length > 0 ? details : undefined, message: blockingMessages.length === 1 ? blockingMessages[0] @@ -3590,6 +3765,7 @@ export class TeamProvisioningService { return { ready: true, + details: details.length > 0 ? details : undefined, message: providerIds.length > 1 ? warnings.length > 0 @@ -3602,6 +3778,252 @@ export class TeamProvisioningService { }; } + private async verifySelectedProviderModels({ + claudePath, + cwd, + providerId, + modelIds, + limitContext, + }: { + claudePath: string; + cwd: string; + providerId: TeamProviderId; + modelIds: string[]; + limitContext: boolean; + }): Promise<{ + details: string[]; + warnings: string[]; + blockingMessages: string[]; + }> { + const details: string[] = []; + const warnings: string[] = []; + const blockingMessages: string[] = []; + + if (modelIds.length === 0) { + return { details, warnings, blockingMessages }; + } + + const { env } = await this.buildProvisioningEnv(providerId); + const probeOutcomeByResolvedModelId = new Map< + string, + { kind: 'ready' | 'warning' | 'unavailable'; reason?: string } + >(); + let resolvedDefaultModelId: string | null | undefined; + + const recordOutcome = ( + requestedModelId: string, + outcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string } + ): void => { + if (outcome.kind === 'ready') { + details.push(`Selected model ${requestedModelId} verified for launch.`); + return; + } + if (outcome.kind === 'unavailable') { + blockingMessages.push( + `Selected model ${requestedModelId} is unavailable. ${outcome.reason ?? 'Model verification failed'}` + ); + return; + } + warnings.push( + `Selected model ${requestedModelId} could not be verified. ${outcome.reason ?? 'Model verification failed'}` + ); + }; + + for (const modelId of modelIds) { + const label = modelId.trim(); + if (!label) { + continue; + } + + let targetModelId = label; + if (isDefaultProviderModelSelection(label)) { + if (resolvedDefaultModelId === undefined) { + try { + resolvedDefaultModelId = await this.resolveProviderDefaultModel( + claudePath, + cwd, + providerId, + env, + limitContext + ); + } catch { + resolvedDefaultModelId = null; + } + } + if (!resolvedDefaultModelId) { + recordOutcome(label, { + kind: 'warning', + reason: 'Could not resolve the runtime default model', + }); + continue; + } + targetModelId = resolvedDefaultModelId; + } + + const cachedOutcome = probeOutcomeByResolvedModelId.get(targetModelId); + if (cachedOutcome) { + recordOutcome(label, cachedOutcome); + continue; + } + + try { + const result = await this.spawnProbe( + claudePath, + buildProviderModelProbeArgs(targetModelId), + cwd, + env, + getProviderModelProbeTimeoutMs(providerId), + { + resolveOnOutputMatch: ({ stdout, stderr }) => + isProviderModelProbeSuccessOutput(`${stdout}\n${stderr}`), + } + ); + const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim(); + if (result.exitCode === 0 && isProviderModelProbeSuccessOutput(combinedOutput)) { + const outcome = { kind: 'ready' as const }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + continue; + } + + const reason = combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.`; + const normalizedReason = normalizeProviderModelProbeFailureReason(reason); + if (classifyProviderModelProbeFailure(reason) === 'unavailable') { + const outcome = { kind: 'unavailable' as const, reason: normalizedReason }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + } else { + const outcome = { kind: 'warning' as const, reason: normalizedReason }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + } + } catch (error) { + const message = error instanceof Error ? error.message.trim() : String(error).trim(); + const normalizedMessage = normalizeProviderModelProbeFailureReason(message); + if ( + classifyProviderModelProbeFailure(message) === 'unavailable' && + !isTransientModelProbeMessage(message) + ) { + const outcome = { kind: 'unavailable' as const, reason: normalizedMessage }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + } else { + const outcome = { kind: 'warning' as const, reason: normalizedMessage }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + } + } + } + + return { details, warnings, blockingMessages }; + } + + private async resolveProviderDefaultModel( + claudePath: string, + cwd: string, + providerId: TeamProviderId, + env: NodeJS.ProcessEnv, + limitContext: boolean + ): Promise { + if (providerId === 'anthropic') { + return getAnthropicDefaultTeamModel(limitContext); + } + + const { stdout } = await execCli(claudePath, ['model', 'list', '--json', '--provider', 'all'], { + cwd, + env, + timeout: 10_000, + }); + const parsed = extractJsonObjectFromCli(stdout); + const defaultModel = parsed.providers?.[providerId]?.defaultModel; + return typeof defaultModel === 'string' && defaultModel.trim().length > 0 + ? defaultModel.trim() + : null; + } + + private async materializeEffectiveTeamMemberSpecs(params: { + claudePath: string; + cwd: string; + members: TeamCreateRequest['members']; + defaults: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + }; + primaryProviderId?: TeamProviderId; + primaryEnv?: ProvisioningEnvResolution; + limitContext?: boolean; + }): Promise { + const envByProvider = new Map>(); + const defaultModelByProvider = new Map>(); + const normalizedPrimaryProviderId = resolveTeamProviderId(params.primaryProviderId); + + const getProvisioningEnv = (providerId: TeamProviderId): Promise => { + if (normalizedPrimaryProviderId === providerId && params.primaryEnv != null) { + return Promise.resolve(params.primaryEnv); + } + + const cached = envByProvider.get(providerId); + if (cached) { + return cached; + } + + const created = this.buildProvisioningEnv(providerId); + envByProvider.set(providerId, created); + return created; + }; + + const getResolvedDefaultModel = (providerId: TeamProviderId): Promise => { + const cached = defaultModelByProvider.get(providerId); + if (cached) { + return cached; + } + + const providerLabel = getTeamProviderLabel(providerId); + const created = (async () => { + const envResolution = await getProvisioningEnv(providerId); + if (envResolution.warning) { + throw new Error(envResolution.warning); + } + + const resolvedDefaultModel = await this.resolveProviderDefaultModel( + params.claudePath, + params.cwd, + providerId, + envResolution.env, + params.limitContext === true + ); + const normalized = resolvedDefaultModel?.trim(); + if (!normalized) { + throw new Error( + `Could not resolve the runtime default model for ${providerLabel} teammates. Select an explicit model and retry.` + ); + } + return normalized; + })(); + + defaultModelByProvider.set(providerId, created); + return created; + }; + + const effectiveMembers: TeamCreateRequest['members'] = []; + for (const member of params.members) { + const effectiveMember = buildEffectiveTeamMemberSpec(member, params.defaults); + const providerId = normalizeTeamMemberProviderId(effectiveMember.providerId) ?? 'anthropic'; + if (providerId === 'anthropic' || effectiveMember.model?.trim()) { + effectiveMembers.push(effectiveMember); + continue; + } + + effectiveMembers.push({ + ...effectiveMember, + model: await getResolvedDefaultModel(providerId), + }); + } + + return effectiveMembers; + } + private getFreshCachedProbeResult( cwd: string, providerId: TeamProviderId | undefined @@ -4456,10 +4878,23 @@ export class TeamProvisioningService { throw new Error('Claude CLI not found; install it or provide a valid path'); } - const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(request.members, { - providerId: request.providerId, - model: request.model, - effort: request.effort, + const provisioningEnv = await this.buildProvisioningEnv(request.providerId); + const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; + if (envWarning) { + throw new Error(envWarning); + } + const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ + claudePath, + cwd: request.cwd, + members: request.members, + defaults: { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }, + primaryProviderId: request.providerId, + primaryEnv: provisioningEnv, + limitContext: request.limitContext, }); const runId = randomUUID(); const startedAt = nowIso(); @@ -4564,14 +4999,6 @@ export class TeamProvisioningService { const initialUserPrompt = request.prompt?.trim() ?? ''; const promptSize = getPromptSizeSummary(initialUserPrompt); let child: ReturnType; - const { - env: shellEnv, - geminiRuntimeAuth, - warning: envWarning, - } = await this.buildProvisioningEnv(request.providerId); - if (envWarning) { - throw new Error(envWarning); - } shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); if (teammateModeDecision.forceProcessTeammates) { @@ -4663,10 +5090,16 @@ export class TeamProvisioningService { }); await this.membersMetaStore.writeMembers( request.teamName, - request.members.map((m) => ({ + effectiveMemberSpecs.map((m) => ({ name: m.name.trim(), role: m.role?.trim() || undefined, workflow: m.workflow?.trim() || undefined, + providerId: normalizeOptionalTeamProviderId(m.providerId), + model: m.model?.trim() || undefined, + effort: + m.effort === 'low' || m.effort === 'medium' || m.effort === 'high' + ? m.effort + : undefined, agentType: 'general-purpose' as const, color: getMemberColorByName(m.name.trim()), joinedAt: Date.now(), @@ -5000,16 +5433,30 @@ export class TeamProvisioningService { const runId = randomUUID(); const startedAt = nowIso(); - const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, { - providerId: request.providerId, - model: request.model, - effort: request.effort, + const provisioningEnv = await this.buildProvisioningEnv(request.providerId); + const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; + if (envWarning) { + throw new Error(envWarning); + } + + const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ + claudePath, + cwd: request.cwd, + members: expectedMemberSpecs, + defaults: { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }, + primaryProviderId: request.providerId, + primaryEnv: provisioningEnv, + limitContext: request.limitContext, }); // Build a synthetic TeamCreateRequest for reuse by shared infrastructure const syntheticRequest: TeamCreateRequest = { teamName: request.teamName, - members: expectedMemberSpecs, + members: effectiveMemberSpecs, cwd: request.cwd, providerId: request.providerId, model: request.model, @@ -5148,14 +5595,6 @@ export class TeamProvisioningService { ); const promptSize = getPromptSizeSummary(prompt); let child: ReturnType; - const { - env: shellEnv, - geminiRuntimeAuth, - warning: envWarning, - } = await this.buildProvisioningEnv(request.providerId); - if (envWarning) { - throw new Error(envWarning); - } shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); if (teammateModeDecision.forceProcessTeammates) { @@ -6542,12 +6981,34 @@ export class TeamProvisioningService { } private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean { - return this.getLiveTeamAgentNames(teamName).has(memberName); + return this.getLiveTeamAgentRuntimeMetadata(teamName).has(memberName); + } + + private attachLiveRuntimeMetadataToStatuses( + teamName: string, + statuses: Record + ): void { + for (const [memberName, metadata] of this.getLiveTeamAgentRuntimeMetadata(teamName).entries()) { + const current = statuses[memberName]; + if (!current || !metadata.model) { + continue; + } + statuses[memberName] = { + ...current, + runtimeModel: metadata.model, + }; + } } private getLiveTeamAgentNames(teamName: string): Set { + return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys()); + } + + private getLiveTeamAgentRuntimeMetadata( + teamName: string + ): Map { if (process.platform === 'win32') { - return new Set(); + return new Map(); } let output = ''; @@ -6557,11 +7018,11 @@ export class TeamProvisioningService { stdio: ['ignore', 'pipe', 'ignore'], }); } catch { - return new Set(); + return new Map(); } const teamMarker = `--team-name ${teamName}`; - const names = new Set(); + const metadataByAgent = new Map(); for (const line of output.split('\n')) { const trimmed = line.trim(); if (!trimmed.includes(teamMarker)) continue; @@ -6569,10 +7030,13 @@ export class TeamProvisioningService { if (!match) continue; const agentName = match[1]?.trim(); if (agentName) { - names.add(agentName); + const model = extractCliFlagValue(trimmed, '--model'); + metadataByAgent.set(agentName, { + ...(model ? { model } : {}), + }); } } - return names; + return metadataByAgent; } private async clearPersistedLaunchState(teamName: string): Promise { @@ -6698,7 +7162,7 @@ export class TeamProvisioningService { const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName); const persisted = await this.launchStateStore.read(teamName); const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, persisted); - if (preferredSnapshot) { + if (preferredSnapshot && preferredSnapshot === bootstrapSnapshot) { return { snapshot: preferredSnapshot, statuses: snapshotToMemberSpawnStatuses(preferredSnapshot), @@ -6783,6 +7247,8 @@ export class TeamProvisioningService { const heartbeatReason = heartbeatMessage ? extractBootstrapFailureReason(heartbeatMessage.text) : null; + const acceptedAtMs = + current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; current.runtimeAlive = runtimeAlive; current.lastRuntimeAliveAt = runtimeAlive ? now : current.lastRuntimeAliveAt; current.sources = { @@ -6805,8 +7271,18 @@ export class TeamProvisioningService { current.hardFailure = false; current.hardFailureReason = undefined; } - const acceptedAtMs = - current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + if (!current.bootstrapConfirmed && !current.hardFailure) { + const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason( + teamName, + expected, + Number.isFinite(acceptedAtMs) ? acceptedAtMs : null + ); + if (transcriptFailureReason) { + current.hardFailure = true; + current.hardFailureReason = transcriptFailureReason; + current.sources.hardFailureSignal = true; + } + } const graceExpired = current.agentToolAccepted === true && Number.isFinite(acceptedAtMs) && @@ -6850,6 +7326,139 @@ export class TeamProvisioningService { }; } + private async findBootstrapTranscriptFailureReason( + teamName: string, + memberName: string, + sinceMs: number | null + ): Promise { + let summaries: Awaited>; + try { + summaries = await this.memberLogsFinder.findMemberLogs(teamName, memberName, sinceMs); + } catch { + return null; + } + + for (const summary of summaries) { + if (!summary.filePath) continue; + const reason = await this.readRecentBootstrapFailureReason( + summary.filePath, + sinceMs, + memberName + ); + if (reason) { + return reason; + } + } + + return this.findBootstrapFailureReasonInProjectRoot(teamName, memberName, sinceMs); + } + + private async readRecentBootstrapFailureReason( + filePath: string, + sinceMs: number | null, + memberName?: string + ): Promise { + let handle: fs.promises.FileHandle | null = null; + const normalizedMemberName = memberName?.trim().toLowerCase() || null; + try { + handle = await fs.promises.open(filePath, 'r'); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size <= 0) { + return null; + } + const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES); + const buffer = Buffer.alloc(stat.size - start); + if (buffer.length === 0) { + return null; + } + await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.toString('utf8').split('\n'); + if (start > 0) { + lines.shift(); + } + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) continue; + let parsed: { timestamp?: unknown } | null = null; + try { + parsed = JSON.parse(line) as { timestamp?: unknown }; + } catch { + continue; + } + const timestampMs = + typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; + if (sinceMs != null && Number.isFinite(timestampMs) && timestampMs < sinceMs) { + continue; + } + if (normalizedMemberName) { + const parsedAgentName = + typeof (parsed as { agentName?: unknown }).agentName === 'string' + ? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null + : null; + if (parsedAgentName && parsedAgentName !== normalizedMemberName) { + continue; + } + } + const text = extractTranscriptMessageText(parsed); + if (!text) continue; + const reason = extractBootstrapFailureReason(text); + if (reason) { + return reason; + } + } + } catch { + return null; + } finally { + await handle?.close().catch(() => undefined); + } + + return null; + } + + private async findBootstrapFailureReasonInProjectRoot( + teamName: string, + memberName: string, + sinceMs: number | null + ): Promise { + let config: Awaited>; + try { + config = await this.configReader.getConfig(teamName); + } catch { + return null; + } + const projectPath = config?.projectPath?.trim(); + if (!projectPath) { + return null; + } + + const projectDir = path.join(getProjectsBasePath(), extractBaseDir(encodePath(projectPath))); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(projectDir, { withFileTypes: true }); + } catch { + return null; + } + + const jsonlFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .sort((left, right) => right.name.localeCompare(left.name)); + for (const entry of jsonlFiles) { + if (config?.leadSessionId && entry.name === `${config.leadSessionId}.jsonl`) { + continue; + } + const reason = await this.readRecentBootstrapFailureReason( + path.join(projectDir, entry.name), + sinceMs, + memberName + ); + if (reason) { + return reason; + } + } + + return null; + } + private captureSendMessages(run: ProvisioningRun, content: Record[]): void { for (const part of content) { if (part.type !== 'tool_use' || typeof part.name !== 'string') continue; @@ -7280,7 +7889,7 @@ export class TeamProvisioningService { continue; } try { - execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' }); + killTmuxPaneForCurrentPlatformSync(paneId); logger.info(`[${teamName}] Killed teammate pane ${name} (${paneId}) during stop`); } catch (error) { logger.debug( @@ -11561,7 +12170,9 @@ export class TeamProvisioningService { } const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim(); - const isPong = new RegExp(`\\b${PREFLIGHT_EXPECTED}\\b`, 'i').test(pongCandidate); + const isPong = new RegExp(`\\b${getProviderModelProbeExpectedOutput()}\\b`, 'i').test( + pongCandidate + ); if (!isPong) { return { warning: diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts new file mode 100644 index 00000000..4099ce1d --- /dev/null +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -0,0 +1,297 @@ +import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { createReadStream, type Dirent } from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as readline from 'readline'; + +import { TeamConfigReader } from './TeamConfigReader'; + +import type { TeamConfig } from '@shared/types'; + +const logger = createLogger('Service:TeamTranscriptProjectResolver'); + +const SESSION_DISCOVERY_CACHE_TTL = 30_000; +const TEAM_AFFINITY_SCAN_LINES = 40; +const ROOT_DISCOVERY_CONCURRENCY = 12; + +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0) { + const ch = value.charCodeAt(end - 1); + if (ch === 47 || ch === 92) { + end -= 1; + continue; + } + break; + } + return end === value.length ? value : value.slice(0, end); +} + +function isSessionDirectoryName(name: string): boolean { + return name !== 'memory' && !name.startsWith('.'); +} + +function extractTextContent(entry: Record): string | null { + if (typeof entry.content === 'string') { + return entry.content; + } + if (Array.isArray(entry.content)) { + const textParts = (entry.content as Record[]) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text as string); + if (textParts.length > 0) { + return textParts.join(' '); + } + } + if (entry.message && typeof entry.message === 'object') { + return extractTextContent(entry.message as Record); + } + return null; +} + +function extractDirectTeamName(entry: Record): string | null { + if (typeof entry.teamName === 'string') { + return entry.teamName.trim().toLowerCase(); + } + + const process = entry.process as Record | undefined; + const processTeam = process?.team as Record | undefined; + if (typeof processTeam?.teamName === 'string') { + return processTeam.teamName.trim().toLowerCase(); + } + + return null; +} + +function lineMentionsTeam(text: string, teamName: string): boolean { + const normalizedText = text.trim().toLowerCase(); + const normalizedTeam = teamName.trim().toLowerCase(); + if (!normalizedText.includes(normalizedTeam)) { + return false; + } + return ( + normalizedText.includes(`on team "${normalizedTeam}"`) || + normalizedText.includes(`on team '${normalizedTeam}'`) || + normalizedText.includes(`team "${normalizedTeam}"`) || + normalizedText.includes(`team '${normalizedTeam}'`) || + normalizedText.includes(`(${normalizedTeam})`) + ); +} + +function collectKnownSessionIds(config: TeamConfig): string[] { + const knownSessionIds = new Set(); + const push = (value: unknown): void => { + if (typeof value !== 'string') { + return; + } + const trimmed = value.trim(); + if (trimmed.length > 0) { + knownSessionIds.add(trimmed); + } + }; + + push(config.leadSessionId); + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + push(sessionId); + } + } + + return [...knownSessionIds]; +} + +export interface TeamTranscriptProjectContext { + projectDir: string; + projectId: string; + config: TeamConfig; + sessionIds: string[]; +} + +export class TeamTranscriptProjectResolver { + private readonly contextCache = new Map< + string, + { value: TeamTranscriptProjectContext; expiresAt: number } + >(); + + constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + + async getContext( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise { + if (options?.forceRefresh) { + this.contextCache.delete(teamName); + } + + const cached = this.contextCache.get(teamName); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const config = await this.configReader.getConfig(teamName); + if (!config?.projectPath) { + return null; + } + + const { projectDir, projectId } = await this.resolveProjectDirectory(config); + const sessionIds = await this.discoverSessionIds(teamName, projectDir, config); + const value = { projectDir, projectId, config, sessionIds }; + this.contextCache.set(teamName, { + value, + expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL, + }); + return value; + } + + private async resolveProjectDirectory( + config: TeamConfig + ): Promise<{ projectDir: string; projectId: string }> { + const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? ''); + let projectId = encodePath(normalizedProjectPath); + let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + + try { + const stat = await fs.stat(projectDir); + if (!stat.isDirectory()) { + throw new Error('not a directory'); + } + return { projectDir, projectId }; + } catch { + const leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId.trim() + : null; + if (!leadSessionId) { + return { projectDir, projectId }; + } + + try { + const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue; + const candidateDir = path.join(getProjectsBasePath(), entry.name); + try { + await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); + projectDir = candidateDir; + projectId = entry.name; + break; + } catch { + // not this project + } + } + } catch { + // best-effort fallback + } + } + + return { projectDir, projectId }; + } + + private async discoverSessionIds( + teamName: string, + projectDir: string, + config: TeamConfig + ): Promise { + const knownSessionIds = collectKnownSessionIds(config); + const [teamRootSessionIds, sessionDirIds] = await Promise.all([ + this.listTeamRootSessionIds(projectDir, teamName), + this.listSessionDirIds(projectDir), + ]); + + return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort( + (left, right) => left.localeCompare(right) + ); + } + + private async listSessionDirIds(projectDir: string): Promise { + try { + const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + return dirEntries + .filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name)) + .map((entry) => entry.name); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + return []; + } + } + + private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise { + let dirEntries: Dirent[]; + try { + dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + return []; + } + + const rootJsonlEntries = dirEntries.filter( + (entry) => entry.isFile() && entry.name.endsWith('.jsonl') + ); + const discovered = new Set(); + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < rootJsonlEntries.length) { + const index = nextIndex++; + const entry = rootJsonlEntries[index]; + const filePath = path.join(projectDir, entry.name); + if (!(await this.fileBelongsToTeam(filePath, teamName))) { + continue; + } + discovered.add(entry.name.slice(0, -'.jsonl'.length)); + } + }; + + await Promise.all( + Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () => + worker() + ) + ); + + return [...discovered]; + } + + private async fileBelongsToTeam(filePath: string, teamName: string): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + const normalizedTeam = teamName.trim().toLowerCase(); + + try { + let inspected = 0; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + inspected += 1; + try { + const entry = JSON.parse(trimmed) as Record; + const directTeamName = extractDirectTeamName(entry); + if (directTeamName === normalizedTeam) { + return true; + } + + const textContent = extractTextContent(entry); + if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { + return true; + } + } catch { + // ignore malformed head lines + } + + if (inspected >= TEAM_AFFINITY_SCAN_LINES) { + break; + } + } + } catch { + return false; + } finally { + rl.close(); + stream.destroy(); + } + + return false; + } +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index ed3deb52..3223ab4a 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -10,8 +10,8 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher'; export { MemberStatsComputer } from './MemberStatsComputer'; export { ReviewApplierService } from './ReviewApplierService'; export { TaskBoundaryParser } from './TaskBoundaryParser'; -export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; export { BoardTaskActivityDetailService } from './taskLogs/activity/BoardTaskActivityDetailService'; +export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index 4dbed40b..648961a0 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -1,34 +1,20 @@ +import { isTmuxRuntimeReadyForCurrentPlatform } from '@features/tmux-installer/main'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; -import { execFile } from 'child_process'; - -const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000; interface DesktopTeammateModeDecision { injectedTeammateMode: 'tmux' | null; forceProcessTeammates: boolean; } -let tmuxAvailabilityCache: { value: boolean; at: number } | null = null; let tmuxAvailablePromise: Promise | null = null; -function execFileAsync(command: string, args: string[], timeout: number): Promise { - return new Promise((resolve, reject) => { - execFile(command, args, { timeout }, (error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - function getExplicitTeammateMode( rawExtraCliArgs: string | undefined ): 'auto' | 'tmux' | 'in-process' | null { const tokens = parseCliArgs(rawExtraCliArgs); for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]; + // eslint-disable-next-line security/detect-possible-timing-attacks -- parsing user-supplied CLI flags, not comparing secrets if (token === '--teammate-mode') { const next = tokens[i + 1]; if (next === 'auto' || next === 'tmux' || next === 'in-process') { @@ -49,21 +35,10 @@ function getExplicitTeammateMode( } async function isTmuxAvailable(): Promise { - if ( - tmuxAvailabilityCache && - Date.now() - tmuxAvailabilityCache.at < TMUX_AVAILABILITY_CACHE_TTL_MS - ) { - return tmuxAvailabilityCache.value; - } - if (!tmuxAvailablePromise) { - tmuxAvailablePromise = execFileAsync('tmux', ['-V'], 3_000) - .then(() => true) + tmuxAvailablePromise = isTmuxRuntimeReadyForCurrentPlatform() + .then((value) => value) .catch(() => false) - .then((value) => { - tmuxAvailabilityCache = { value, at: Date.now() }; - return value; - }) .finally(() => { tmuxAvailablePromise = null; }); @@ -90,13 +65,6 @@ export async function resolveDesktopTeammateModeDecision( }; } - if (process.platform === 'win32') { - return { - injectedTeammateMode: null, - forceProcessTeammates: false, - }; - } - if (!(await isTmuxAvailable())) { return { injectedTeammateMode: null, diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts index dd5805cc..f3ff491d 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts @@ -1,3 +1,4 @@ +import { isEnhancedAIChunk } from '@main/types'; import { describeBoardTaskActivityLabel, formatBoardTaskActivityTaskLabel, @@ -6,21 +7,21 @@ import { describeBoardTaskActivityActorLabel, describeBoardTaskActivityContextLines, } from '@shared/utils/boardTaskActivityPresentation'; -import { isEnhancedAIChunk } from '@main/types'; -import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; +import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource'; + +import type { BoardTaskExactLogBundleCandidate } from '../exact/BoardTaskExactLogTypes'; import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; +import type { ContentBlock, EnhancedChunk, ParsedMessage, ToolUseResultData } from '@main/types'; import type { BoardTaskActivityDetail, BoardTaskActivityDetailMetadataRow, BoardTaskActivityDetailResult, } from '@shared/types'; -import type { BoardTaskExactLogBundleCandidate } from '../exact/BoardTaskExactLogTypes'; -import type { ContentBlock, EnhancedChunk, ParsedMessage, ToolUseResultData } from '@main/types'; const READ_ONLY_TOOL_NAMES = new Set(['task_get', 'task_get_comment']); @@ -236,7 +237,7 @@ function cloneBlock(block: T): T { } as T; } - return { ...block } as T; + return { ...block }; } function sanitizeToolResultContent( diff --git a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts index 1daee3cb..8dbd7e4d 100644 --- a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts +++ b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts @@ -1,27 +1,10 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; -import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; -import { TeamConfigReader } from '../../TeamConfigReader'; +import { TeamTranscriptProjectResolver } from '../../TeamTranscriptProjectResolver'; import type { TeamConfig } from '@shared/types'; -const logger = createLogger('Service:TeamTranscriptSourceLocator'); - -function trimTrailingSlashes(value: string): string { - let end = value.length; - while (end > 0) { - const ch = value.charCodeAt(end - 1); - if (ch === 47 || ch === 92) { - end -= 1; - continue; - } - break; - } - return end === value.length ? value : value.slice(0, end); -} - export interface TeamTranscriptSourceContext { projectDir: string; projectId: string; @@ -31,50 +14,17 @@ export interface TeamTranscriptSourceContext { } export class TeamTranscriptSourceLocator { - constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + constructor( + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver() + ) {} async getContext(teamName: string): Promise { - const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { + const context = await this.projectResolver.getContext(teamName); + if (!context) { return null; } - const normalizedProjectPath = trimTrailingSlashes(config.projectPath); - let projectId = encodePath(normalizedProjectPath); - let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); - - try { - const stat = await fs.stat(projectDir); - if (!stat.isDirectory()) { - throw new Error('not a directory'); - } - } catch { - const leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : null; - if (leadSessionId) { - try { - const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); - for (const entry of projectEntries) { - if (!entry.isDirectory()) continue; - const candidateDir = path.join(getProjectsBasePath(), entry.name); - try { - await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); - projectDir = candidateDir; - projectId = entry.name; - break; - } catch { - // not this project - } - } - } catch { - // best-effort fallback - } - } - } - - const sessionIds = await this.discoverSessionIds(projectDir, config); + const { projectDir, projectId, config, sessionIds } = context; const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds); return { projectDir, projectId, config, sessionIds, transcriptFiles }; } @@ -83,51 +33,6 @@ export class TeamTranscriptSourceLocator { const context = await this.getContext(teamName); return context?.transcriptFiles ?? []; } - - private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise { - const knownSessionIds = new Set(); - if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { - knownSessionIds.add(config.leadSessionId.trim()); - } - if (Array.isArray(config.sessionHistory)) { - for (const sessionId of config.sessionHistory) { - if (typeof sessionId === 'string' && sessionId.trim().length > 0) { - knownSessionIds.add(sessionId.trim()); - } - } - } - - let discoveredSessionDirs: string[] = []; - try { - const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); - discoveredSessionDirs = dirEntries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); - } catch { - logger.debug(`Cannot read transcript project dir: ${projectDir}`); - } - - if (knownSessionIds.size === 0) { - return discoveredSessionDirs.sort(); - } - - const verifiedSessionIds: string[] = []; - for (const sessionId of knownSessionIds) { - try { - const stat = await fs.stat(path.join(projectDir, sessionId)); - if (stat.isDirectory()) { - verifiedSessionIds.push(sessionId); - } - } catch { - // ignore stale config session - } - } - - return Array.from( - new Set([...knownSessionIds, ...verifiedSessionIds, ...discoveredSessionDirs]) - ).sort(); - } - private async listTranscriptFilesForSessions( projectDir: string, sessionIds: string[] @@ -160,6 +65,6 @@ export class TeamTranscriptSourceLocator { } } - return [...transcriptFiles].sort(); + return [...transcriptFiles].sort((left, right) => left.localeCompare(right)); } } diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 39d44938..0a3e1083 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -16,6 +16,7 @@ // runtime which is unavailable in standalone (pure Node.js) mode. Standalone // error tracking can be added later with @sentry/node if needed. +import { createRecentProjectsFeature } from '@features/recent-projects/main'; import { createLogger } from '@shared/utils/logger'; import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider'; @@ -130,6 +131,11 @@ async function start(): Promise { // Create HTTP server httpServer = new HttpServer(); + const recentProjectsFeature = createRecentProjectsFeature({ + getActiveContext: () => localContext, + getLocalContext: () => localContext, + logger: createLogger('Feature:RecentProjects'), + }); // Wire file watcher events to SSE broadcast localContext.fileWatcher.on('file-change', (event: unknown) => { @@ -157,6 +163,7 @@ async function start(): Promise { subagentResolver: localContext.subagentResolver, chunkBuilder: localContext.chunkBuilder, dataCache: localContext.dataCache, + recentProjectsFeature, updaterService: updaterServiceStub, sshConnectionManager: sshConnectionManagerStub, }; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 6c25b52d..7bdf2e17 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -438,6 +438,9 @@ export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus'; /** Get status for a single provider */ export const CLI_INSTALLER_GET_PROVIDER_STATUS = 'cliInstaller:getProviderStatus'; +/** Trigger on-demand model verification for a single provider */ +export const CLI_INSTALLER_VERIFY_PROVIDER_MODELS = 'cliInstaller:verifyProviderModels'; + /** Start CLI install/update */ export const CLI_INSTALLER_INSTALL = 'cliInstaller:install'; @@ -446,9 +449,14 @@ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress'; /** Invalidate cached CLI status (forces fresh check on next getStatus) */ export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus'; - -/** Get current tmux runtime availability for dashboard diagnostics */ -export const TMUX_GET_STATUS = 'tmux:getStatus'; +export { + TMUX_CANCEL_INSTALL, + TMUX_GET_INSTALLER_SNAPSHOT, + TMUX_GET_STATUS, + TMUX_INSTALL, + TMUX_INSTALLER_PROGRESS, + TMUX_INVALIDATE_STATUS, +} from '@features/tmux-installer/contracts'; // ============================================================================= // Terminal API Channels diff --git a/src/preload/index.ts b/src/preload/index.ts index 79c12a8d..772422e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,5 @@ +import { createRecentProjectsBridge } from '@features/recent-projects/preload'; +import { createTmuxInstallerBridge } from '@features/tmux-installer/preload'; import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer, webUtils } from 'electron'; @@ -13,6 +15,7 @@ import { CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, + CLI_INSTALLER_VERIFY_PROVIDER_MODELS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -184,7 +187,6 @@ import { TERMINAL_RESIZE, TERMINAL_SPAWN, TERMINAL_WRITE, - TMUX_GET_STATUS, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -243,6 +245,7 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, + CliProviderId, ConflictCheckResult, ContextInfo, CreateScheduleInput, @@ -300,7 +303,6 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, - TmuxStatus, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -448,6 +450,7 @@ ipcRenderer.on( // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object const electronAPI: ElectronAPI = { + ...createRecentProjectsBridge(), getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), @@ -855,13 +858,17 @@ const electronAPI: ElectronAPI = { prepareProvisioning: async ( cwd?: string, providerId?: TeamLaunchRequest['providerId'], - providerIds?: TeamLaunchRequest['providerId'][] + providerIds?: TeamLaunchRequest['providerId'][], + selectedModels?: string[], + limitContext?: boolean ) => { return invokeIpcWithResult( TEAM_PREPARE_PROVISIONING, cwd, providerId, - providerIds + providerIds, + selectedModels, + limitContext ); }, createTeam: async (request: TeamCreateRequest) => { @@ -1408,9 +1415,12 @@ const electronAPI: ElectronAPI = { getStatus: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_GET_STATUS); }, - getProviderStatus: async (providerId: import('@shared/types').CliProviderId) => { + getProviderStatus: async (providerId: CliProviderId) => { return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId); }, + verifyProviderModels: async (providerId: CliProviderId) => { + return invokeIpcWithResult(CLI_INSTALLER_VERIFY_PROVIDER_MODELS, providerId); + }, install: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_INSTALL); }, @@ -1431,11 +1441,7 @@ const electronAPI: ElectronAPI = { }, }, - tmux: { - getStatus: async (): Promise => { - return invokeIpcWithResult(TMUX_GET_STATUS); - }, - }, + tmux: createTmuxInstallerBridge({ ipcRenderer, invokeIpcWithResult }), // ===== Terminal API ===== terminal: { @@ -1572,7 +1578,8 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult(MCP_REGISTRY_GET_BY_ID, registryId), getInstalled: (projectPath?: string) => invokeIpcWithResult(MCP_REGISTRY_GET_INSTALLED, projectPath), - diagnose: () => invokeIpcWithResult(MCP_REGISTRY_DIAGNOSE), + diagnose: (projectPath?: string) => + invokeIpcWithResult(MCP_REGISTRY_DIAGNOSE, projectPath), install: (request: McpInstallRequest) => invokeIpcWithResult(MCP_REGISTRY_INSTALL, request), installCustom: (request: McpCustomInstallRequest) => @@ -1616,8 +1623,8 @@ const electronAPI: ElectronAPI = { list: () => invokeIpcWithResult(API_KEYS_LIST), save: (request: ApiKeySaveRequest) => invokeIpcWithResult(API_KEYS_SAVE, request), delete: (id: string) => invokeIpcWithResult(API_KEYS_DELETE, id), - lookup: (envVarNames: string[]) => - invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames), + lookup: (envVarNames: string[], projectPath?: string) => + invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames, projectPath), getStorageStatus: () => invokeIpcWithResult(API_KEYS_STORAGE_STATUS), }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b205a4f6..6d40e202 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,6 +6,7 @@ * to run in a regular browser connected to an HTTP server. */ +import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { AppConfig, AttachmentFileData, @@ -20,6 +21,7 @@ import type { ConfigAPI, ContextInfo, ConversationGroup, + CreateScheduleInput, CreateTaskRequest, CrossTeamAPI, ElectronAPI, @@ -70,6 +72,7 @@ import type { TriggerTestResult, UpdateKanbanPatch, UpdaterAPI, + UpdateSchedulePatch, WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; @@ -214,6 +217,9 @@ export class HttpAPIClient implements ElectronAPI { getAppVersion = (): Promise => this.get('/api/version'); + getDashboardRecentProjects = (): Promise => + this.get('/api/dashboard/recent-projects'); + getProjects = (): Promise => this.get('/api/projects'); getSessions = (projectId: string): Promise => @@ -711,7 +717,9 @@ export class HttpAPIClient implements ElectronAPI { prepareProvisioning: async ( _cwd?: string, _providerId?: TeamLaunchRequest['providerId'], - _providerIds?: TeamLaunchRequest['providerId'][] + _providerIds?: TeamLaunchRequest['providerId'][], + _selectedModels?: string[], + _limitContext?: boolean ): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, @@ -1111,6 +1119,7 @@ export class HttpAPIClient implements ElectronAPI { providers: [], }), getProviderStatus: async (): Promise => null, + verifyProviderModels: async (): Promise => null, install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); }, @@ -1122,14 +1131,58 @@ export class HttpAPIClient implements ElectronAPI { tmux: TmuxAPI = { getStatus: async (): Promise => ({ - available: true, - version: null, - binaryPath: null, platform: 'unknown', - nativeSupported: true, + nativeSupported: false, checkedAt: new Date().toISOString(), + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'tmux diagnostics are not available in browser mode.', + }, error: null, + autoInstall: { + supported: false, + strategy: 'manual', + packageManagerLabel: null, + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: 'tmux installation is only available in Electron mode.', + manualHints: [], + }, }), + getInstallerSnapshot: async () => ({ + phase: 'idle', + strategy: null, + message: null, + detail: 'tmux installer is not available in browser mode.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: [], + updatedAt: new Date().toISOString(), + }), + install: async (): Promise => { + throw new Error('tmux installer is not available in browser mode'); + }, + cancelInstall: async (): Promise => {}, + submitInstallerInput: async (): Promise => {}, + invalidateStatus: async (): Promise => {}, + onProgress: (): (() => void) => { + return () => {}; + }, }; // --------------------------------------------------------------------------- @@ -1218,42 +1271,48 @@ export class HttpAPIClient implements ElectronAPI { }, }; - schedules = { + schedules: ElectronAPI['schedules'] = { list: async () => { console.warn('Schedules not available in browser mode'); return [] as Schedule[]; }, - get: async () => { + get: async (_id: string): Promise => { console.warn('Schedules not available in browser mode'); return null; }, - create: async () => { + create: async (_input: CreateScheduleInput): Promise => { throw new Error('Schedules not available in browser mode'); }, - update: async () => { + update: async (_id: string, _patch: UpdateSchedulePatch): Promise => { throw new Error('Schedules not available in browser mode'); }, - delete: async () => { + delete: async (_id: string): Promise => { throw new Error('Schedules not available in browser mode'); }, - pause: async () => { + pause: async (_id: string): Promise => { throw new Error('Schedules not available in browser mode'); }, - resume: async () => { + resume: async (_id: string): Promise => { throw new Error('Schedules not available in browser mode'); }, - triggerNow: async () => { + triggerNow: async (_id: string): Promise => { throw new Error('Schedules not available in browser mode'); }, - getRuns: async () => { + getRuns: async ( + _scheduleId: string, + _opts?: { limit?: number; offset?: number } + ): Promise => { console.warn('Schedules not available in browser mode'); return [] as ScheduleRun[]; }, - getRunLogs: async () => { + getRunLogs: async ( + _scheduleId: string, + _runId: string + ): Promise<{ stdout: string; stderr: string }> => { console.warn('Schedules not available in browser mode'); return { stdout: '', stderr: '' }; }, - onScheduleChange: () => { + onScheduleChange: (): (() => void) => { return () => {}; }, }; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 471c10f9..05fc5569 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -22,6 +22,7 @@ import { isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; +import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; import { SettingsToggle } from '@renderer/components/settings/components'; @@ -32,10 +33,7 @@ import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; -import { - getTeamModelBadgeLabel, - getVisibleTeamProviderModels, -} from '@renderer/utils/teamModelCatalog'; +import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility'; import { AlertTriangle, CheckCircle, @@ -266,37 +264,6 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): { }; } -function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { - return getTeamModelBadgeLabel(providerId, model) ?? model; -} - -const ModelBadges = ({ - providerId, - models, -}: { - readonly providerId: CliProviderId; - readonly models: string[]; -}): React.JSX.Element => { - const visibleModels = getVisibleTeamProviderModels(providerId, models); - return ( -
- {visibleModels.map((model) => ( - - {formatModelBadgeLabel(providerId, model)} - - ))} -
- ); -}; - const ProviderDetailSkeleton = (): React.JSX.Element => { return (
@@ -355,7 +322,11 @@ function formatRuntimeAuthSummary( cliStatus: NonNullable['cliStatus']>, visibleProviders: readonly CliProviderStatus[] ): string | null { - if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) { + if (isMultimodelRuntimeStatus(cliStatus)) { + if (visibleProviders.length === 0) { + return null; + } + if ( visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated @@ -385,7 +356,7 @@ function isCheckingMultimodelStatus( visibleProviders: readonly CliProviderStatus[] ): boolean { return ( - cliStatus.flavor === 'agent_teams_orchestrator' && + isMultimodelRuntimeStatus(cliStatus) && visibleProviders.length > 0 && visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated @@ -393,6 +364,12 @@ function isCheckingMultimodelStatus( ); } +function hasVisibleAuthenticatedMultimodelProvider( + visibleProviders: readonly CliProviderStatus[] +): boolean { + return visibleProviders.some((provider) => provider.authenticated); +} + const InstalledBanner = ({ cliStatus, cliStatusLoading, @@ -416,6 +393,7 @@ const InstalledBanner = ({ () => filterMainScreenCliProviders(cliStatus.providers), [cliStatus.providers] ); + const canOpenExtensions = cliStatus.installed; const runtimeLabel = formatRuntimeLabel(cliStatus); const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders); @@ -500,8 +478,8 @@ const InstalledBanner = ({ disabled={isBusy || cliStatusLoading || multimodelBusy} />
- {/* Extensions button — only when installed + authenticated */} - {cliStatus.authLoggedIn && ( + {/* Extensions button — available whenever the runtime is installed */} + {canOpenExtensions && (
@@ -873,6 +856,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info'; if (cliStatus.authStatusChecking) return 'info'; if (!cliStatus.installed) return 'error'; + if (isMultimodelRuntimeStatus(cliStatus) && visibleCliProviders.length === 0) { + return 'warning'; + } + if ( + isMultimodelRuntimeStatus(cliStatus) && + visibleCliProviders.length > 0 && + !hasVisibleAuthenticatedMultimodelProvider(visibleCliProviders) + ) { + return 'warning'; + } if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; if (cliStatus.updateAvailable) return 'info'; return 'success'; diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 3ec47ef8..ad1f4d68 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -1,53 +1,20 @@ /** - * DashboardView - Main dashboard with "Productivity Luxury" aesthetic. - * Inspired by Linear, Vercel, and Raycast design patterns. - * Features: - * - Subtle spotlight gradient - * - Centralized command search with inline project filtering - * - Border-first project cards with minimal backgrounds + * DashboardView - Main dashboard shell. + * Keeps only screen composition and delegates recent-projects logic to the feature slice. */ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { api } from '@renderer/api'; -import { Button } from '@renderer/components/ui/button'; +import { RecentProjectsSection } from '@features/recent-projects/renderer'; import { useStore } from '@renderer/store'; -import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers'; -import { formatProjectPath } from '@renderer/utils/pathDisplay'; -import { - buildTaskCountsByProject, - normalizePath, - type TaskStatusCounts, -} from '@renderer/utils/pathNormalize'; -import { projectColor } from '@renderer/utils/projectColor'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { createLogger } from '@shared/utils/logger'; +import { Command, Search, Users } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -const logger = createLogger('Component:DashboardView'); -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { formatDistanceToNow } from 'date-fns'; -import { - Command, - FolderGit2, - FolderOpen, - GitBranch, - GitFork, - Search, - Terminal, - Users, -} from 'lucide-react'; - import { CliStatusBanner } from './CliStatusBanner'; import { DashboardUpdateBanner } from './DashboardUpdateBanner'; import { TmuxStatusBanner } from './TmuxStatusBanner'; - -import type { RepositoryGroup } from '@renderer/types/data'; -import type { TeamSummary } from '@shared/types'; - -// ============================================================================= -// Command Search Input -// ============================================================================= +import { WebPreviewBanner } from './WebPreviewBanner'; interface CommandSearchProps { value: string; @@ -58,17 +25,16 @@ const CommandSearch = ({ value, onChange }: Readonly): React const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(null); const { openCommandPalette, selectedProjectId } = useStore( - useShallow((s) => ({ - openCommandPalette: s.openCommandPalette, - selectedProjectId: s.selectedProjectId, + useShallow((state) => ({ + openCommandPalette: state.openCommandPalette, + selectedProjectId: state.selectedProjectId, })) ); - // Handle Cmd+K to open full command palette useEffect(() => { - const handleKeyDown = (e: KeyboardEvent): void => { - if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') { - e.preventDefault(); + const handleKeyDown = (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.code === 'KeyK') { + event.preventDefault(); openCommandPalette(); } }; @@ -77,24 +43,24 @@ const CommandSearch = ({ value, onChange }: Readonly): React return () => window.removeEventListener('keydown', handleKeyDown); }, [openCommandPalette]); - // Focus search when the dashboard mounts (packaged Electron can skip native autoFocus). useLayoutEffect(() => { - const el = inputRef.current; - if (!el) { + const input = inputRef.current; + if (!input) { return; } - el.focus({ preventScroll: true }); - const t = window.setTimeout(() => { - if (document.activeElement !== el) { - el.focus({ preventScroll: true }); + + input.focus({ preventScroll: true }); + const timeoutId = window.setTimeout(() => { + if (document.activeElement !== input) { + input.focus({ preventScroll: true }); } }, 50); - return () => window.clearTimeout(t); + + return () => window.clearTimeout(timeoutId); }, []); return (
- {/* Search container with glow effect on focus */}
): React ref={inputRef} type="text" value={value} - onChange={(e) => onChange(e.target.value)} + onChange={(event) => onChange(event.target.value)} placeholder="Search projects..." className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted" onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} /> - {/* Keyboard shortcut badge - opens full command palette */} - ); -}; - -// ============================================================================= -// Ghost Card (New Project) -// ============================================================================= - -interface WorktreeMatch { - repoId: string; - worktreeId: string; -} - -function findMatchingWorktree( - groups: RepositoryGroup[], - selectedPath: string -): WorktreeMatch | null { - const norm = normalizePath(selectedPath); - for (const repo of groups) { - for (const worktree of repo.worktrees) { - if (normalizePath(worktree.path) === norm) { - return { repoId: repo.id, worktreeId: worktree.id }; - } - } - } - return null; -} - -const NewProjectCard = (): React.JSX.Element => { - const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore( - useShallow((s) => ({ - repositoryGroups: s.repositoryGroups, - fetchRepositoryGroups: s.fetchRepositoryGroups, - openTeamsTab: s.openTeamsTab, - })) - ); - - const navigateToMatch = (match: WorktreeMatch): void => { - useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId)); - void useStore.getState().fetchSessionsInitial(match.worktreeId); - }; - - const handleClick = async (): Promise => { - try { - const selectedPaths = await api.config.selectFolders(); - if (!selectedPaths || selectedPaths.length === 0) { - return; // User cancelled - } - - const selectedPath = selectedPaths[0]; - - // Match selected path against known repository worktrees (normalized comparison) - const match = findMatchingWorktree(repositoryGroups, selectedPath); - if (match) { - navigateToMatch(match); - openTeamsTab(); - return; - } - - // No match — refresh repository groups and retry - await fetchRepositoryGroups(); - const refreshedGroups = useStore.getState().repositoryGroups; - const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath); - if (matchAfterRefresh) { - navigateToMatch(matchAfterRefresh); - openTeamsTab(); - return; - } - - // Still no match — create a synthetic group for this new folder and navigate to it. - // This allows launching teams in projects that don't have Claude sessions yet. - // Persist the path so it survives app restarts. - await api.config.addCustomProjectPath(selectedPath); - - const encodedId = selectedPath.replace(/[/\\]/g, '-'); - const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath; - const now = Date.now(); - - const syntheticGroup: RepositoryGroup = { - id: encodedId, - identity: null, - worktrees: [ - { - id: encodedId, - path: selectedPath, - name: folderName, - isMainWorktree: true, - source: 'unknown', - sessions: [], - totalSessions: 0, - createdAt: now, - }, - ], - name: folderName, - mostRecentSession: undefined, - totalSessions: 0, - }; - - useStore.setState((state) => ({ - repositoryGroups: [syntheticGroup, ...state.repositoryGroups], - })); - navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); - openTeamsTab(); - } catch (error) { - logger.error('Error selecting folder:', error); - } - }; - - return ( - - ); -}; - -// ============================================================================= -// Projects Grid -// ============================================================================= - -interface ProjectsGridProps { - searchQuery: string; - maxProjects?: number; -} - -const INITIAL_RECENT_PROJECTS = 11; -const LOAD_MORE_STEP = 8; - -const ProjectsGrid = ({ - searchQuery, - maxProjects = INITIAL_RECENT_PROJECTS, -}: Readonly): React.JSX.Element => { - const { - repositoryGroups, - repositoryGroupsLoading, - repositoryGroupsError, - fetchRepositoryGroups, - selectRepository, - globalTasks, - globalTasksLoading, - fetchAllTasks, - openTeamsTab, - teams, - } = useStore( - useShallow((s) => ({ - repositoryGroups: s.repositoryGroups, - repositoryGroupsLoading: s.repositoryGroupsLoading, - repositoryGroupsError: s.repositoryGroupsError, - fetchRepositoryGroups: s.fetchRepositoryGroups, - selectRepository: s.selectRepository, - globalTasks: s.globalTasks, - globalTasksLoading: s.globalTasksLoading, - fetchAllTasks: s.fetchAllTasks, - openTeamsTab: s.openTeamsTab, - teams: s.teams, - })) - ); - - const hasFetchedTasksRef = React.useRef(false); - const [visibleProjects, setVisibleProjects] = useState(maxProjects); - const [aliveTeams, setAliveTeams] = useState([]); - - useEffect(() => { - if (repositoryGroups.length === 0 && !repositoryGroupsLoading && !repositoryGroupsError) { - void fetchRepositoryGroups(); - } - }, [ - repositoryGroups.length, - repositoryGroupsLoading, - repositoryGroupsError, - fetchRepositoryGroups, - ]); - - useEffect(() => { - if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) { - hasFetchedTasksRef.current = true; - void fetchAllTasks(); - } - }, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]); - - // Fetch alive teams for online indicators - useEffect(() => { - let cancelled = false; - void api.teams - .aliveList() - .then((list) => { - if (!cancelled) setAliveTeams(list); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [teams]); - - // Map: normalizedProjectPath → alive TeamSummary[] - const activeTeamsByProject = useMemo(() => { - const aliveSet = new Set(aliveTeams); - const map = new Map(); - for (const team of teams) { - if (!aliveSet.has(team.teamName) || !team.projectPath) continue; - const key = normalizePath(team.projectPath); - const arr = map.get(key); - if (arr) { - arr.push(team); - } else { - map.set(key, [team]); - } - } - return map; - }, [teams, aliveTeams]); - - useEffect(() => { - if (!searchQuery.trim()) { - setVisibleProjects(maxProjects); - } - }, [searchQuery, maxProjects]); - - const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); - - // Filter projects based on search query - const filteredRepos = useMemo(() => { - const query = searchQuery.toLowerCase().trim(); - return repositoryGroups.filter((repo) => { - if (!query) return true; - // Match by name - if (repo.name.toLowerCase().includes(query)) return true; - // Match by path - const path = repo.worktrees[0]?.path || ''; - if (path.toLowerCase().includes(query)) return true; - return false; - }); - }, [repositoryGroups, searchQuery]); - - const displayedRepos = useMemo(() => { - if (searchQuery.trim()) { - return filteredRepos; - } - return filteredRepos.slice(0, visibleProjects); - }, [filteredRepos, searchQuery, visibleProjects]); - - const canLoadMore = !searchQuery.trim() && filteredRepos.length > visibleProjects; - - if (repositoryGroupsLoading) { - // Organic widths per card — no repeating stamp - const titleWidths = [60, 66, 50, 55, 75, 45, 40, 65]; - const pathWidths = [80, 75, 85, 66, 70, 80, 60, 72]; - - return ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- {/* Icon placeholder */} -
- {/* Title placeholder */} -
- {/* Path placeholder */} -
- {/* Meta row placeholder */} -
-
-
-
-
- ))} -
- ); - } - - if (repositoryGroupsError && repositoryGroups.length === 0) { - return ( -
-
- -
-
-

Failed to load projects

-

{repositoryGroupsError}

-
- -
- ); - } - - if (filteredRepos.length === 0 && searchQuery.trim()) { - return ( -
-
- -
-

No projects found

-

No matches for "{searchQuery}"

-
- ); - } - - if (repositoryGroups.length === 0) { - return ( -
-
- -
-

No projects found

-

~/.claude/projects/

-
- ); - } - - return ( -
-
- {!searchQuery.trim() && } - {displayedRepos.map((repo) => { - const counts = repo.worktrees.reduce( - (acc, wt) => { - const c = taskCountsMap.get(normalizePath(wt.path)); - if (c) { - acc.pending += c.pending; - acc.inProgress += c.inProgress; - acc.completed += c.completed; - } - return acc; - }, - { pending: 0, inProgress: 0, completed: 0 } - ); - // Collect active teams for this project (deduplicated by teamName) - const seen = new Set(); - const repoActiveTeams: TeamSummary[] = []; - for (const wt of repo.worktrees) { - const matched = activeTeamsByProject.get(normalizePath(wt.path)); - if (matched) { - for (const t of matched) { - if (!seen.has(t.teamName)) { - seen.add(t.teamName); - repoActiveTeams.push(t); - } - } - } - } - return ( - { - selectRepository(repo.id); - openTeamsTab(); - }} - isHighlighted={!!searchQuery.trim()} - taskCounts={globalTasksLoading ? undefined : counts} - tasksLoading={globalTasksLoading} - activeTeams={repoActiveTeams.length > 0 ? repoActiveTeams : undefined} - /> - ); - })} -
- - {canLoadMore && ( -
- -
- )} -
- ); -}; - -// ============================================================================= -// Dashboard View -// ============================================================================= - export const DashboardView = (): React.JSX.Element => { const [searchQuery, setSearchQuery] = useState(''); - const openTeamsTab = useStore((s) => s.openTeamsTab); + const openTeamsTab = useStore((state) => state.openTeamsTab); return (
- {/* Spotlight gradient background */}
); diff --git a/src/renderer/components/dashboard/TmuxStatusBanner.tsx b/src/renderer/components/dashboard/TmuxStatusBanner.tsx index e3baa475..a36aebaa 100644 --- a/src/renderer/components/dashboard/TmuxStatusBanner.tsx +++ b/src/renderer/components/dashboard/TmuxStatusBanner.tsx @@ -1,347 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { TmuxInstallerBannerView } from '@features/tmux-installer/renderer'; -import { api, isElectronMode } from '@renderer/api'; -import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react'; +import type { JSX } from 'react'; -import type { TmuxPlatform, TmuxStatus } from '@shared/types'; - -const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing'; -const TMUX_README_URL = 'https://github.com/tmux/tmux/blob/master/README'; -const HOMEBREW_TMUX_URL = 'https://formulae.brew.sh/formula/tmux'; -const MACPORTS_TMUX_URL = 'https://ports.macports.org/port/tmux/'; -const MICROSOFT_WSL_INSTALL_URL = 'https://learn.microsoft.com/en-us/windows/wsl/install'; - -interface SourceLink { - label: string; - url: string; -} - -interface PlatformInstallGuideStep { - kind: 'text' | 'code'; - value: string; -} - -interface PlatformInstallGuide { - platform: Exclude; - title: string; - steps: PlatformInstallGuideStep[]; - sources: SourceLink[]; -} - -type BannerState = - | { loading: true; status: null; error: null } - | { loading: false; status: TmuxStatus; error: null } - | { loading: false; status: null; error: string }; - -const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; - -const PLATFORM_INSTALL_GUIDES: readonly PlatformInstallGuide[] = [ - { - platform: 'darwin', - title: 'macOS', - steps: [ - { kind: 'text', value: 'Recommended: Homebrew' }, - { kind: 'code', value: 'brew install tmux' }, - { kind: 'text', value: 'Alternative: MacPorts' }, - { kind: 'code', value: 'sudo port install tmux' }, - ], - sources: [ - { label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }, - { label: 'Homebrew', url: HOMEBREW_TMUX_URL }, - { label: 'MacPorts', url: MACPORTS_TMUX_URL }, - ], - }, - { - platform: 'linux', - title: 'Linux', - steps: [ - { kind: 'text', value: 'Use your distro package manager:' }, - { kind: 'code', value: 'sudo apt install tmux' }, - { kind: 'code', value: 'sudo dnf install tmux' }, - { kind: 'code', value: 'sudo yum install tmux' }, - { kind: 'code', value: 'sudo zypper install tmux' }, - { kind: 'code', value: 'sudo pacman -S tmux' }, - ], - sources: [{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }], - }, - { - platform: 'win32', - title: 'Windows', - steps: [ - { - kind: 'text', - value: 'The tmux docs do not provide an official native Windows install command.', - }, - { kind: 'text', value: '1. Install WSL' }, - { kind: 'code', value: 'wsl --install' }, - { kind: 'text', value: '2. Inside Ubuntu or another distro' }, - { kind: 'code', value: 'sudo apt install tmux' }, - ], - sources: [ - { label: 'tmux README', url: TMUX_README_URL }, - { label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }, - { label: 'Microsoft WSL', url: MICROSOFT_WSL_INSTALL_URL }, - ], - }, -] as const; - -const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => { - return ( -
-
- Sources -
-
- {links.map((link) => ( - - ))} -
-
- ); -}; - -function getPlatformLabel(platform: TmuxPlatform): string { - if (platform === 'darwin') return 'macOS'; - if (platform === 'linux') return 'Linux'; - if (platform === 'win32') return 'Windows'; - return 'your OS'; -} - -const PlatformInstallCard = ({ guide }: { guide: PlatformInstallGuide }): React.JSX.Element => { - return ( -
-
- {guide.title} -
-
- {guide.steps.map((step) => - step.kind === 'code' ? ( - - {step.value} - - ) : ( -
{step.value}
- ) - )} - -
-
- ); -}; - -const PlatformInstallMatrix = ({ platform }: { platform: TmuxPlatform }): React.JSX.Element => { - const guides = - platform === 'unknown' - ? PLATFORM_INSTALL_GUIDES - : PLATFORM_INSTALL_GUIDES.filter((guide) => guide.platform === platform); - const singleGuide = guides.length === 1; - - return ( -
- {singleGuide && ( -
- Detected OS: {getPlatformLabel(platform)} -
- )} -
- {guides.map((guide) => ( - - ))} -
-
- ); -}; - -function getPrimaryDetail(status: TmuxStatus): string { - if (status.platform === 'darwin') { - return 'On macOS, the simplest options are Homebrew or MacPorts.'; - } - if (status.platform === 'linux') { - return 'On Linux, install tmux with your distro package manager.'; - } - if (status.platform === 'win32') { - return 'On Windows, the clearest path is WSL, then installing tmux inside your Linux distro.'; - } - return 'Install tmux with your operating system package manager.'; -} - -export const TmuxStatusBanner = (): React.JSX.Element | null => { - const isElectron = useMemo(() => isElectronMode(), []); - const [state, setState] = useState(INITIAL_STATE); - - const loadStatus = useCallback(async () => { - return api.tmux.getStatus(); - }, []); - - const fetchStatus = useCallback(async () => { - setState( - (prev) => - ({ - loading: true, - status: prev.status, - error: null, - }) as BannerState - ); - - try { - const status = await loadStatus(); - setState({ loading: false, status, error: null }); - } catch (error) { - setState({ - loading: false, - status: null, - error: error instanceof Error ? error.message : 'Failed to check tmux status', - }); - } - }, [loadStatus]); - - useEffect(() => { - if (!isElectron) { - return; - } - - let cancelled = false; - - const loadInitialStatus = async (): Promise => { - try { - const status = await loadStatus(); - if (!cancelled) { - setState({ loading: false, status, error: null }); - } - } catch (error) { - if (!cancelled) { - setState({ - loading: false, - status: null, - error: error instanceof Error ? error.message : 'Failed to check tmux status', - }); - } - } - }; - - void loadInitialStatus(); - - return () => { - cancelled = true; - }; - }, [isElectron, loadStatus]); - - if (!isElectron) return null; - if (state.loading && !state.status) return null; - - if (state.error && !state.status) { - return ( -
-
-
- -
-
- Failed to check tmux availability -
-

- {state.error} -

-
-
- -
-
- ); - } - - if (!state.status || state.status.available) { - return null; - } - - return ( -
-
-
- -
-
- tmux is not installed -
-

- Persistent team agents are more reliable on the process/tmux path. Without tmux, the - app falls back to the heavier in-process path. {getPrimaryDetail(state.status)} -

- {state.status.error && ( -

- Last check error: {state.status.error} -

- )} -
-
- -
- - -
-
- - -
- ); +export const TmuxStatusBanner = (): JSX.Element => { + return ; }; diff --git a/src/renderer/components/dashboard/WebPreviewBanner.tsx b/src/renderer/components/dashboard/WebPreviewBanner.tsx new file mode 100644 index 00000000..2b4467c3 --- /dev/null +++ b/src/renderer/components/dashboard/WebPreviewBanner.tsx @@ -0,0 +1,27 @@ +import { isElectronMode } from '@renderer/api'; +import { FlaskConical } from 'lucide-react'; + +export const WebPreviewBanner = (): React.JSX.Element | null => { + if (isElectronMode()) { + return null; + } + + return ( +
+ +
+

Web version is still in development

+

+ Some desktop features are not available in the browser yet. Project actions, integrations, + and live status data may be limited or not work as expected. +

+
+
+ ); +}; diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index ec905ee0..a1677a31 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -7,6 +7,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs'; import { @@ -18,8 +20,25 @@ import { import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; +import { + formatCliExtensionCapabilityStatus, + getVisibleMultimodelProviders, + isMultimodelRuntimeStatus, +} from '@renderer/utils/multimodelProviderVisibility'; import { resolveProjectPathById } from '@renderer/utils/projectLookup'; -import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; +import { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; +import { + AlertTriangle, + BookOpen, + Info, + Key, + Loader2, + Plus, + Puzzle, + RefreshCw, + Server, +} from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { ApiKeysPanel } from './apikeys/ApiKeysPanel'; @@ -29,6 +48,55 @@ import { PluginsPanel } from './plugins/PluginsPanel'; import { SkillsPanel } from './skills/SkillsPanel'; import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger'; +import type { CliProviderStatus } from '@shared/types'; + +const ProviderCapabilityCardSkeleton = ({ + providerId, + displayName, +}: { + providerId: 'anthropic' | 'codex' | 'gemini'; + displayName: string; +}): React.JSX.Element => ( +
+
+
+

+ + {displayName} +

+
+ + Checking provider status... +
+
+ + Loading... + +
+
+ {Array.from({ length: 3 }, (_, index) => ( + + ))} +
+
+); + +function isProviderCapabilityCardLoading( + provider: CliProviderStatus, + providerLoading: boolean +): boolean { + return ( + providerLoading || + (!provider.authenticated && + provider.statusMessage === 'Checking...' && + provider.models.length === 0 && + provider.backend == null) + ); +} + export const ExtensionStoreView = (): React.JSX.Element => { const tabId = useTabIdOptional(); const { @@ -38,11 +106,13 @@ export const ExtensionStoreView = (): React.JSX.Element => { fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, + apiKeysLoading, pluginCatalogLoading, mcpBrowseLoading, skillsLoading, cliStatus, cliStatusLoading, + cliProviderStatusLoading, openDashboard, sessions, projects, @@ -55,11 +125,13 @@ export const ExtensionStoreView = (): React.JSX.Element => { fetchSkillsCatalog: s.fetchSkillsCatalog, mcpBrowse: s.mcpBrowse, mcpFetchInstalled: s.mcpFetchInstalled, + apiKeysLoading: s.apiKeysLoading, pluginCatalogLoading: s.pluginCatalogLoading, mcpBrowseLoading: s.mcpBrowseLoading, skillsLoading: s.skillsLoading, cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading, + cliProviderStatusLoading: s.cliProviderStatusLoading, openDashboard: s.openDashboard, sessions: s.sessions, projects: s.projects, @@ -90,21 +162,21 @@ export const ExtensionStoreView = (): React.JSX.Element => { label: 'Plugins', icon: Puzzle, description: - 'Small add-ons for Claude. They give the app extra features and integrations you can install when you need them.', + 'Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.', }, { value: 'mcp-servers' as const, label: 'MCP Servers', icon: Server, description: - 'Connections to outside tools and apps. They let Claude read data or do actions beyond this app.', + 'Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.', }, { value: 'skills' as const, label: 'Skills', icon: BookOpen, description: - 'Ready-made instructions for common jobs. They help Claude do specific tasks better and more consistently.', + 'Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.', }, { value: 'api-keys' as const, @@ -143,22 +215,52 @@ export const ExtensionStoreView = (): React.JSX.Element => { // Refresh all data (plugins + MCP browse + installed + skills) const handleRefresh = useCallback(() => { + void fetchCliStatus(); + void fetchApiKeys(); void fetchPluginCatalog(projectPath ?? undefined, true); void mcpBrowse(); // re-fetch first page void mcpFetchInstalled(projectPath ?? undefined); void fetchSkillsCatalog(projectPath ?? undefined); - }, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]); + }, [ + fetchApiKeys, + fetchCliStatus, + fetchPluginCatalog, + fetchSkillsCatalog, + mcpBrowse, + mcpFetchInstalled, + projectPath, + ]); - const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + const isRefreshing = + cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + const mcpMutationDisableReason = useMemo( + () => + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }), + [cliStatus, cliStatusLoading] + ); const cliStatusBanner = useMemo(() => { - if (cliStatusLoading || cliStatus === null) { + const providers = cliStatus?.providers ?? []; + const visibleProviders = getVisibleMultimodelProviders(providers); + const isMultimodel = isMultimodelRuntimeStatus(cliStatus); + const shouldShowMultimodelProviderCards = + isMultimodel && visibleProviders.length > 0 && cliStatus !== null; + + if ((cliStatusLoading || cliStatus === null) && !shouldShowMultimodelProviderCards) { return (
-

Checking Claude CLI availability

+

+ Checking extensions runtime availability +

- Extensions need Claude CLI to install plugins, run MCP servers, and validate auth. + Extensions need the configured runtime to manage plugins, MCP servers, skills, and + provider connections.

@@ -173,13 +275,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {

{cliLaunchIssue - ? 'Claude CLI was found but failed to start' - : 'Claude CLI is not available'} + ? 'The configured runtime was found but failed to start' + : 'The configured runtime is not available'}

{cliLaunchIssue - ? 'Plugin installs are disabled until Claude CLI passes its startup health check. Open the Dashboard to repair or reinstall it.' - : 'Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to install it and retry.'} + ? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.' + : 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}

{cliLaunchIssue && cliStatus.launchError && (

@@ -194,7 +296,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); } - if (!cliStatus.authLoggedIn) { + if (!isMultimodel && !cliStatus.authLoggedIn) { return (

@@ -213,6 +315,97 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); } + if (isMultimodel) { + return ( +
+
+ +
+

Multimodel runtime capabilities

+

+ Provider support can differ by section. Plugins are shown only where the runtime + explicitly declares support. +

+
+
+ {visibleProviders.length > 0 && ( +
+ {visibleProviders.map((provider) => { + const providerLoading = cliProviderStatusLoading[provider.providerId] === true; + if (isProviderCapabilityCardLoading(provider, providerLoading)) { + return ( + + ); + } + + const statusTone = provider.authenticated + ? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300' + : provider.supported + ? 'border-amber-500/30 bg-amber-500/5 text-amber-300' + : 'border-border bg-surface-raised text-text-muted'; + const statusLabel = provider.authenticated + ? 'Connected' + : provider.supported + ? 'Needs setup' + : 'Unsupported'; + const extensionCapabilities = getCliProviderExtensionCapabilities(provider); + const pluginStatus = extensionCapabilities.plugins.status; + + return ( +
+
+
+

+ + {provider.displayName} +

+

+ {provider.statusMessage ?? + provider.backend?.label ?? + 'Ready to configure'} +

+
+ + {statusLabel} + +
+
+ + Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)} + + + MCP: {formatCliExtensionCapabilityStatus(extensionCapabilities.mcp.status)} + + + Skills: {extensionCapabilities.skills.ownership} + +
+
+ ); + })} +
+ )} +
+ ); + } + return (
@@ -225,7 +418,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
); - }, [cliStatus, cliStatusLoading, openDashboard]); + }, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]); // Browser mode guard if (!api.plugins && !api.mcpRegistry && !api.skills) { @@ -267,7 +460,8 @@ export const ExtensionStoreView = (): React.JSX.Element => { {!cliInstalled && (
- Claude CLI is required to install or uninstall extensions. Install it from Settings. + The configured runtime is required to install or uninstall extensions. Install or + repair it from the Dashboard.
)} {/* Active sessions warning */} @@ -296,20 +490,31 @@ export const ExtensionStoreView = (): React.JSX.Element => { ))} {tabState.activeSubTab === 'mcp-servers' && ( - + + + + + + + {mcpMutationDisableReason && ( + {mcpMutationDisableReason} + )} + )}
{ { - + @@ -358,6 +564,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { setCustomMcpDialogOpen(false)} + projectPath={projectPath} />
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx index 653f0fa2..a946adec 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx @@ -66,6 +66,12 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
+ {apiKey.scope === 'project' && apiKey.projectPath && ( +

+ {apiKey.projectPath} +

+ )} + {/* Env var name */}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx index 72e07225..f2b53396 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx @@ -32,6 +32,8 @@ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; interface ApiKeyFormDialogProps { open: boolean; editingKey: ApiKeyEntry | null; + currentProjectPath: string | null; + currentProjectLabel: string | null; onClose: () => void; } @@ -45,6 +47,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ export const ApiKeyFormDialog = ({ open, editingKey, + currentProjectPath, + currentProjectLabel, onClose, }: ApiKeyFormDialogProps): React.JSX.Element => { const saveApiKey = useStore((s) => s.saveApiKey); @@ -57,6 +61,14 @@ export const ApiKeyFormDialog = ({ const [scope, setScope] = useState('user'); const [error, setError] = useState(null); const [envVarError, setEnvVarError] = useState(null); + const editingProjectPath = + editingKey?.scope === 'project' ? (editingKey.projectPath ?? null) : null; + const effectiveProjectPath = editingProjectPath ?? currentProjectPath; + const effectiveProjectLabel = + effectiveProjectPath && effectiveProjectPath === currentProjectPath + ? currentProjectLabel + : effectiveProjectPath; + const canUseProjectScope = Boolean(effectiveProjectPath); // Reset form when dialog opens/closes or editing key changes useEffect(() => { @@ -77,6 +89,12 @@ export const ApiKeyFormDialog = ({ } }, [open, editingKey]); + useEffect(() => { + if (open && scope === 'project' && !canUseProjectScope) { + setScope('user'); + } + }, [canUseProjectScope, open, scope]); + const validateEnvVar = (v: string) => { if (!v.trim()) { setEnvVarError(null); @@ -109,6 +127,10 @@ export const ApiKeyFormDialog = ({ setError('Key value is required'); return; } + if (scope === 'project' && !effectiveProjectPath) { + setError('Project-scoped API keys require an active project'); + return; + } try { await saveApiKey({ @@ -117,6 +139,7 @@ export const ApiKeyFormDialog = ({ envVarName: envVarName.trim(), value, scope, + projectPath: scope === 'project' ? (effectiveProjectPath ?? undefined) : undefined, }); onClose(); } catch (err) { @@ -125,7 +148,13 @@ export const ApiKeyFormDialog = ({ }; const isEdit = editingKey !== null; - const canSubmit = name.trim() && envVarName.trim() && value && !envVarError && !apiKeySaving; + const canSubmit = + name.trim() && + envVarName.trim() && + value && + !envVarError && + !apiKeySaving && + (scope !== 'project' || canUseProjectScope); return ( !o && onClose()}> @@ -212,12 +241,23 @@ export const ApiKeyFormDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - - {opt.label} + + {opt.value === 'project' + ? effectiveProjectPath + ? `Project: ${effectiveProjectLabel}` + : 'Project unavailable' + : opt.label} ))} + {scope === 'project' && effectiveProjectPath && ( +

Bound to {effectiveProjectPath}

+ )}
{/* Error display */} diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index 951aecb0..3352fb3c 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -2,7 +2,7 @@ * ApiKeysPanel — grid of saved API keys with add button and empty state. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -15,16 +15,26 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import type { ApiKeyEntry } from '@shared/types/extensions'; -export const ApiKeysPanel = (): React.JSX.Element => { - const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore( - useShallow((s) => ({ - apiKeys: s.apiKeys, - apiKeysLoading: s.apiKeysLoading, - apiKeysError: s.apiKeysError, - storageStatus: s.apiKeyStorageStatus, - fetchStorageStatus: s.fetchApiKeyStorageStatus, - })) - ); +interface ApiKeysPanelProps { + projectPath: string | null; + projectLabel: string | null; +} + +export const ApiKeysPanel = ({ + projectPath, + projectLabel, +}: ApiKeysPanelProps): React.JSX.Element => { + const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } = + useStore( + useShallow((s) => ({ + apiKeys: s.apiKeys, + apiKeysLoading: s.apiKeysLoading, + apiKeysError: s.apiKeysError, + storageStatus: s.apiKeyStorageStatus, + fetchStorageStatus: s.fetchApiKeyStorageStatus, + cliStatus: s.cliStatus, + })) + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingKey, setEditingKey] = useState(null); @@ -49,9 +59,82 @@ export const ApiKeysPanel = (): React.JSX.Element => { }; const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain'; + const providerKeyCards = useMemo(() => { + if (!cliStatus?.providers?.length) { + return []; + } + + return ( + [ + { + providerId: 'anthropic', + label: 'Anthropic runtime', + envVar: 'ANTHROPIC_API_KEY', + }, + { + providerId: 'codex', + label: 'Codex runtime', + envVar: 'OPENAI_API_KEY', + }, + ] as const + ).flatMap((item) => { + const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId); + if (!provider) { + return []; + } + + return [ + { + ...item, + authenticated: provider.authenticated, + apiKeyConfigured: provider.connection?.apiKeyConfigured ?? false, + sourceLabel: provider.connection?.apiKeySourceLabel ?? null, + statusMessage: provider.statusMessage ?? null, + }, + ]; + }); + }, [cliStatus]); return (
+ {providerKeyCards.length > 0 && ( +
+ {providerKeyCards.map((provider) => ( +
+
+
+

{provider.label}

+

{provider.envVar}

+
+ + {provider.authenticated + ? 'Connected' + : provider.apiKeyConfigured + ? 'Key configured' + : 'Key missing'} + +
+

+ {provider.sourceLabel + ? `Current source: ${provider.sourceLabel}.` + : 'No stored or environment key detected for this provider.'} + {provider.statusMessage ? ` ${provider.statusMessage}` : ''} +

+
+ ))} +
+ )} {/* Header row */}

@@ -138,7 +221,13 @@ export const ApiKeysPanel = (): React.JSX.Element => { )} {/* Form dialog */} - +

); }; diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 93bdf907..78930ad7 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { Check, Loader2, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -23,6 +24,7 @@ interface InstallButtonProps { isInstalled: boolean; onInstall: () => void; onUninstall: () => void; + section?: 'plugins' | 'mcp'; disabled?: boolean; size?: 'sm' | 'default'; errorMessage?: string; @@ -33,6 +35,7 @@ export const InstallButton = ({ isInstalled, onInstall, onUninstall, + section = 'plugins', disabled, size = 'sm', errorMessage, @@ -43,18 +46,12 @@ export const InstallButton = ({ cliStatusLoading: s.cliStatusLoading, })) ); - const cliUnknown = cliStatus === null; - const cliMissing = cliStatus?.installed === false; - const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn; - const disableReason = cliStatusLoading - ? 'Checking Claude CLI status...' - : cliUnknown - ? 'Checking Claude CLI availability...' - : cliMissing - ? 'Claude CLI required. Install it from the Dashboard.' - : authMissing - ? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.' - : null; + const disableReason = getExtensionActionDisableReason({ + isInstalled, + cliStatus, + cliStatusLoading, + section, + }); const isDisabled = disabled || Boolean(disableReason); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 35624aad..727d2603 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -3,7 +3,7 @@ * Supports stdio (npm package) and HTTP/SSE transports. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; @@ -24,6 +24,13 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; +import { + getDefaultMcpSharedScope, + getMcpScopeLabel, + isProjectScopedMcpScope, + isSharedMcpScope, +} from '@shared/utils/mcpScopes'; import { Plus, Server, Trash2 } from 'lucide-react'; import type { @@ -37,16 +44,12 @@ const SERVER_NAME_RE = /^[\w.-]{1,100}$/; interface CustomMcpServerDialogProps { open: boolean; onClose: () => void; + projectPath: string | null; } type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; -type Scope = 'local' | 'user'; - -const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ - { value: 'user', label: 'User (global)' }, - { value: 'local', label: 'Local' }, -]; +type Scope = 'local' | 'user' | 'project' | 'global'; const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [ { value: 'streamable-http', label: 'Streamable HTTP' }, @@ -62,13 +65,22 @@ interface EnvEntry { export const CustomMcpServerDialog = ({ open, onClose, + projectPath, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); + const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const scopeOptions: { value: Scope; label: string }[] = [ + { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, + { value: 'project', label: 'Project' }, + { value: 'local', label: 'Local' }, + ]; // Form state const [serverName, setServerName] = useState(''); const [transportMode, setTransportMode] = useState('stdio'); - const [scope, setScope] = useState('user'); + const [scope, setScope] = useState(defaultSharedScope); // Stdio fields const [npmPackage, setNpmPackage] = useState(''); @@ -83,13 +95,31 @@ export const CustomMcpServerDialog = ({ const [envVars, setEnvVars] = useState([]); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); + const autoFilledValuesRef = useRef>({}); + const wasOpenRef = useRef(false); + const previousDefaultSharedScopeRef = useRef(defaultSharedScope); + const envVarLookupNames = envVars + .map((entry) => entry.key.trim()) + .filter(Boolean) + .sort() + .join('\0'); + const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) + ? (projectPath ?? undefined) + : undefined; + const mutationDisableReason = getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }); // Reset on open useEffect(() => { - if (open) { + const justOpened = open && !wasOpenRef.current; + if (justOpened) { setServerName(''); setTransportMode('stdio'); - setScope('user'); + setScope(defaultSharedScope); setNpmPackage(''); setNpmVersion(''); setHttpUrl(''); @@ -98,33 +128,98 @@ export const CustomMcpServerDialog = ({ setEnvVars([]); setError(null); setInstalling(false); + autoFilledValuesRef.current = {}; } - }, [open]); + wasOpenRef.current = open; + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; + } + }, [defaultSharedScope, open]); + + useEffect(() => { + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; + return; + } + + const previousDefaultSharedScope = previousDefaultSharedScopeRef.current; + if ( + previousDefaultSharedScope !== defaultSharedScope && + scope === previousDefaultSharedScope && + isSharedMcpScope(scope) + ) { + setScope(defaultSharedScope); + } + + previousDefaultSharedScopeRef.current = defaultSharedScope; + }, [defaultSharedScope, open, scope]); + + useEffect(() => { + if (open && isProjectScopedMcpScope(scope) && !projectPath) { + setScope(defaultSharedScope); + } + }, [defaultSharedScope, open, projectPath, scope]); // Auto-fill env vars from saved API keys useEffect(() => { if (!open || envVars.length === 0 || !api.apiKeys) return; - const envVarNames = envVars.map((e) => e.key).filter(Boolean); + const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean); if (envVarNames.length === 0) return; - void api.apiKeys.lookup(envVarNames).then( + void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then( (results) => { - if (results.length === 0) return; - const lookup = new Map(results.map((r) => [r.envVarName, r.value])); - setEnvVars((prev) => - prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e)) + const previousAutoFilledValues = autoFilledValuesRef.current; + const nextAutoFilledValues = Object.fromEntries( + results.map((result) => [result.envVarName, result.value]) ); + setEnvVars((prev) => { + let changed = false; + const next = prev.map((entry) => { + const envVarName = entry.key.trim(); + if (!envVarName) { + return entry; + } + + const previousValue = previousAutoFilledValues[envVarName]; + const nextValue = nextAutoFilledValues[envVarName]; + + if (!nextValue) { + if (previousValue && entry.value === previousValue) { + changed = true; + return { ...entry, value: '' }; + } + return entry; + } + + if (!entry.value || entry.value === previousValue) { + if (entry.value !== nextValue) { + changed = true; + return { ...entry, value: nextValue }; + } + } + + return entry; + }); + + return changed ? next : prev; + }); + autoFilledValuesRef.current = nextAutoFilledValues; }, () => { // Silently fail } ); - }, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps + }, [apiKeyLookupProjectPath, envVarLookupNames, open]); // eslint-disable-line react-hooks/exhaustive-deps const handleInstall = async () => { setError(null); + if (mutationDisableReason) { + setError(mutationDisableReason); + return; + } + if (!serverName.trim()) { setError('Server name is required'); return; @@ -168,6 +263,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, + projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -197,6 +293,8 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && + !(isProjectScopedMcpScope(scope) && !projectPath) && + !mutationDisableReason && !installing; return ( @@ -371,8 +469,12 @@ export const CustomMcpServerDialog = ({ - {SCOPE_OPTIONS.map((opt) => ( - + {scopeOptions.map((opt) => ( + {opt.label} ))} @@ -421,6 +523,11 @@ export const CustomMcpServerDialog = ({
{/* Error */} + {mutationDisableReason && ( +
+ {mutationDisableReason} +
+ )} {error && (
{error} diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index e2f04f87..10844f74 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -11,18 +11,29 @@ import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters'; -import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; +import { + getMcpInstallationSummaryLabel, + getMcpOperationKey, + sanitizeMcpServerName, +} from '@shared/utils/extensionNormalizers'; +import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes'; import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; import { Github as GithubIcon } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { SourceBadge } from '../common/SourceBadge'; -import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions'; +import type { + InstalledMcpEntry, + McpCatalogItem, + McpServerDiagnostic, +} from '@shared/types/extensions'; interface McpServerCardProps { server: McpCatalogItem; isInstalled: boolean; + installedEntry?: InstalledMcpEntry | null; + installedEntries?: InstalledMcpEntry[]; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; onClick: (serverId: string) => void; @@ -31,23 +42,44 @@ interface McpServerCardProps { export const McpServerCard = ({ server, isInstalled, + installedEntry, + installedEntries = [], diagnostic, diagnosticsLoading, onClick, }: McpServerCardProps): React.JSX.Element => { - const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle'); + const cliStatus = useStore((s) => s.cliStatus); + const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const operationKey = getMcpOperationKey(server.id, sharedScope); + const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle'); const installMcpServer = useStore((s) => s.installMcpServer); const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); - const installError = useStore((s) => s.installErrors[server.id]); + const installError = useStore((s) => s.installErrors[operationKey]); const stars = useStore((s) => server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined ); const canAutoInstall = !!server.installSpec; + const normalizedInstalledEntries = installedEntries.length + ? installedEntries + : installedEntry + ? [installedEntry] + : []; const requiresConfiguration = server.installSpec?.type === 'http' || server.envVars.length > 0 || server.requiresAuth || (server.authHeaders?.length ?? 0) > 0; + const defaultServerName = sanitizeMcpServerName(server.name); + const sharedInstallEntry = + normalizedInstalledEntries.find((entry) => entry.scope === sharedScope) ?? null; + const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); + const supportsDirectInstalledAction = + isInstalled && + normalizedInstalledEntries.length === 1 && + sharedInstallEntry?.name === defaultServerName && + !requiresConfiguration; + const shouldShowDirectInstallButton = + canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); const [imgError, setImgError] = useState(false); const hasIcon = !!server.iconUrl && !imgError; const diagnosticBadgeClass = @@ -103,7 +135,7 @@ export const McpServerCard = ({ className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400" variant="outline" > - Installed + {installSummaryLabel ?? 'Installed'} )} {isInstalled && diagnosticsLoading && !diagnostic && ( @@ -224,27 +256,34 @@ export const McpServerCard = ({ )}
- {canAutoInstall && !requiresConfiguration && ( + {shouldShowDirectInstallButton && (
installMcpServer({ registryId: server.id, - serverName: sanitizeMcpServerName(server.name), - scope: 'user', + serverName: defaultServerName, + scope: sharedScope, envValues: {}, headers: [], }) } - onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))} + onUninstall={() => + uninstallMcpServer( + server.id, + sharedInstallEntry?.name ?? defaultServerName, + sharedScope + ) + } size="sm" errorMessage={installError} />
)} - {canAutoInstall && requiresConfiguration && ( + {canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
)} - {(isInstalled || diagnosticsLoading) && ( + {isInstalledForScope && (
- Claude Status + {statusSectionLabel} {diagnosticsLoading && !diagnostic ? (

- {isInstalled ? 'Manage Installation' : 'Install Server'} + {isInstalledForScope ? 'Manage Installation' : 'Install Server'}

{/* Server name */} @@ -380,6 +461,7 @@ export const McpServerDetailDialog = ({ onChange={(e) => setServerName(e.target.value)} placeholder="my-server" className="h-8 text-sm" + disabled={isInstalledForScope} />
@@ -391,8 +473,12 @@ export const McpServerDetailDialog = ({ - {SCOPE_OPTIONS.map((opt) => ( - + {scopeOptions.map((opt) => ( + {opt.label} ))} @@ -499,7 +585,8 @@ export const McpServerDetailDialog = ({
void; mcpSearchResults: McpCatalogItem[]; @@ -65,6 +71,7 @@ interface McpServersPanelProps { } export const McpServersPanel = ({ + projectPath, mcpSearchQuery, mcpSearch, mcpSearchResults, @@ -73,19 +80,27 @@ export const McpServersPanel = ({ selectedMcpServerId, setSelectedMcpServerId, }: McpServersPanelProps): React.JSX.Element => { + const projectStateKey = getMcpProjectStateKey(projectPath); const { browseCatalog, browseNextCursor, browseLoading, browseError, mcpBrowse, - installedServers, + installedServersByProjectPath, + installedServersFallback, fetchMcpGitHubStars, - mcpDiagnostics, - mcpDiagnosticsLoading, - mcpDiagnosticsError, - mcpDiagnosticsLastCheckedAt, + mcpDiagnosticsByProjectPath, + mcpDiagnosticsFallback, + mcpDiagnosticsLoadingByProjectPath, + mcpDiagnosticsLoadingFallback, + mcpDiagnosticsErrorByProjectPath, + mcpDiagnosticsErrorFallback, + mcpDiagnosticsLastCheckedAtByProjectPath, + mcpDiagnosticsLastCheckedAtFallback, runMcpDiagnostics, + cliStatus, + cliStatusLoading, } = useStore( useShallow((s) => ({ browseCatalog: s.mcpBrowseCatalog, @@ -93,28 +108,69 @@ export const McpServersPanel = ({ browseLoading: s.mcpBrowseLoading, browseError: s.mcpBrowseError, mcpBrowse: s.mcpBrowse, - installedServers: s.mcpInstalledServers, + installedServersByProjectPath: s.mcpInstalledServersByProjectPath, + installedServersFallback: s.mcpInstalledServers, fetchMcpGitHubStars: s.fetchMcpGitHubStars, - mcpDiagnostics: s.mcpDiagnostics, - mcpDiagnosticsLoading: s.mcpDiagnosticsLoading, - mcpDiagnosticsError: s.mcpDiagnosticsError, - mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt, + mcpDiagnosticsByProjectPath: s.mcpDiagnosticsByProjectPath, + mcpDiagnosticsFallback: s.mcpDiagnostics, + mcpDiagnosticsLoadingByProjectPath: s.mcpDiagnosticsLoadingByProjectPath, + mcpDiagnosticsLoadingFallback: s.mcpDiagnosticsLoading, + mcpDiagnosticsErrorByProjectPath: s.mcpDiagnosticsErrorByProjectPath, + mcpDiagnosticsErrorFallback: s.mcpDiagnosticsError, + mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath, + mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt, runMcpDiagnostics: s.runMcpDiagnostics, + cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, })) ); + const installedServers = + installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? []; + const mcpDiagnostics = + mcpDiagnosticsByProjectPath?.[projectStateKey] ?? mcpDiagnosticsFallback ?? {}; + const mcpDiagnosticsLoading = + mcpDiagnosticsLoadingByProjectPath?.[projectStateKey] ?? mcpDiagnosticsLoadingFallback ?? false; + const mcpDiagnosticsError = + mcpDiagnosticsErrorByProjectPath?.[projectStateKey] ?? mcpDiagnosticsErrorFallback ?? null; + const mcpDiagnosticsLastCheckedAt = + mcpDiagnosticsLastCheckedAtByProjectPath?.[projectStateKey] ?? + mcpDiagnosticsLastCheckedAtFallback ?? + null; const [mcpSort, setMcpSort] = useState('name-asc'); // Load initial browse data useEffect(() => { - if (browseCatalog.length === 0 && !browseLoading) { + if (browseCatalog.length === 0 && !browseLoading && !browseError) { void mcpBrowse(); } - }, [browseCatalog.length, browseLoading, mcpBrowse]); + }, [browseCatalog.length, browseError, browseLoading, mcpBrowse]); + + const diagnosticsDisableReason = useMemo(() => { + if (cliStatusLoading) { + return 'Checking runtime status...'; + } + + if (cliStatus === null || typeof cliStatus === 'undefined') { + return 'Checking runtime availability...'; + } + + if (cliStatus?.installed === false) { + if (cliStatus.binaryPath && cliStatus.launchError) { + return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.'; + } + return 'The configured runtime is required. Install or repair it from the Dashboard.'; + } + + return null; + }, [cliStatus, cliStatusLoading]); useEffect(() => { - void runMcpDiagnostics(); - }, [runMcpDiagnostics]); + if (diagnosticsDisableReason) { + return; + } + void runMcpDiagnostics(projectPath ?? undefined); + }, [diagnosticsDisableReason, projectPath, runMcpDiagnostics]); // Fetch GitHub stars after catalog loads (fire-and-forget) useEffect(() => { @@ -136,21 +192,33 @@ export const McpServersPanel = ({ [installedServers] ); - const installedEntriesByName = useMemo( - () => new Map(installedServers.map((entry) => [entry.name.toLowerCase(), entry] as const)), - [installedServers] - ); + const installedEntriesByName = useMemo(() => { + const entriesByName = new Map(); + for (const entry of installedServers) { + const key = entry.name.toLowerCase(); + entriesByName.set(key, [...(entriesByName.get(key) ?? []), entry]); + } + return entriesByName; + }, [installedServers]); /** Check if a catalog server is installed by comparing sanitized names */ const isServerInstalled = (server: McpCatalogItem): boolean => installedNames.has(sanitizeMcpServerName(server.name)); + const getInstalledEntries = (server: McpCatalogItem): InstalledMcpEntry[] => + installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? []; + const getInstalledEntry = (server: McpCatalogItem): InstalledMcpEntry | null => - installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? null; + getPreferredMcpInstallationEntry(getInstalledEntries(server)); const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => { const installedEntry = getInstalledEntry(server); - return installedEntry ? (mcpDiagnostics[installedEntry.name] ?? null) : null; + return installedEntry + ? (mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name, installedEntry.scope)] ?? + mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name)] ?? + mcpDiagnostics[installedEntry.name] ?? + null) + : null; }; const allDiagnostics = useMemo( @@ -173,6 +241,8 @@ export const McpServersPanel = ({ // Sort displayed servers const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]); + const runtimeLabel = + cliStatus?.flavor === 'agent_teams_orchestrator' ? 'multimodel runtime' : 'Claude CLI'; // Find selected server (search in both lists to avoid losing selection during search toggle) const selectedServer = useMemo(() => { @@ -193,24 +263,21 @@ export const McpServersPanel = ({

MCP Health Status

{mcpDiagnosticsLoading ? ( - <> - Checking installed MCP servers via Claude CLI (claude mcp list) ... - + <>Checking installed MCP servers via {runtimeLabel} ... + ) : diagnosticsDisableReason ? ( + diagnosticsDisableReason ) : mcpDiagnosticsLastCheckedAt ? ( `Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}` ) : ( - <> - Run diagnostics (claude mcp list) to verify installed MCP - connectivity. - + <>Run diagnostics from this page to verify installed MCP connectivity. )}

@@ -438,7 +482,7 @@ export const SkillEditorDialog = ({
- + @@ -468,7 +512,7 @@ export const SkillEditorDialog = ({ const nextValue = event.target.value; setName(nextValue); if (mode === 'create' && !folderNameEdited) { - setFolderName(toSuggestedFolderName(nextValue || 'New Skill')); + setFolderName(toSuggestedSkillFolderName(nextValue || 'New Skill')); } applyFormToRawContent({ name: nextValue }); }} @@ -531,7 +575,7 @@ export const SkillEditorDialog = ({
- +